This commit is contained in:
liaibo
2025-12-06 16:47:17 +08:00
parent 15921bfeb7
commit 9ef8e06d67
75 changed files with 559237 additions and 93 deletions
+33
View File
@@ -0,0 +1,33 @@
{
"_name_or_path": "/fsx-llm/lblecher/checkpoints/nougat/small/20230426_125023",
"align_long_axis": false,
"architectures": [
"NougatModel"
],
"decoder_layer": 4,
"embed_dim": 128,
"encoder_layer": [
2,
2,
14,
2
],
"hidden_dimension": 1024,
"input_size": [
896,
672
],
"max_length": 3584,
"max_position_embeddings": 3584,
"model_type": "nougat",
"num_heads": [
4,
8,
16,
32
],
"patch_size": 4,
"torch_dtype": "float32",
"transformers_version": "4.25.1",
"window_size": 7
}
+6
View File
@@ -0,0 +1,6 @@
{
"bos_token": "<s>",
"eos_token": "</s>",
"pad_token": "<pad>",
"unk_token": "<unk>"
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
{
"model_max_length": 1000000000000000019884624838656,
"tokenizer_class": "PreTrainedTokenizerFast"
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+76
View File
@@ -0,0 +1,76 @@
针对化学书籍(包含大量分子式、化学反应方程式、苯环结构图、上下标)的识别,传统的 OCR(如 RapidOCR、PaddleOCR)基本无法满足需求,因为它们只能识别“文字”,无法理解“化学结构的拓扑关系”或将其转换为 LaTeX 公式。
鉴于你是为了集成到笔记软件中,且已经验证 Ollama 可以成功调用你的 6800XT,最佳的“高精度 + 本地化”方案是利用 多模态大模型(VLM) 进行“视觉转录”,而不是传统的 OCR。
以下是针对 Windows + 6800XT 的具体高精度方案:
方案一:Ollama + MiniCPM-V 2.6 (最推荐,支持 6800XT)
这是目前开源界公认的小参数量下 OCR 能力最强(尤其是针对密集文本和科学公式)的模型之一。它能直接“看懂”图片里的化学式,并输出 Markdown/LaTeX。
1. 为什么选这个?
懂化学: 它不是像传统 OCR 那样把苯环识别成圆圈,而是理解这是化学结构,并能输出 LaTeX 代码。
硬件兼容: 既然你的 Ollama 能跑显卡,直接 pull 这个模型就能利用 6800XT 加速。
API 友好: 笔记软件插件可以通过 HTTP 请求直接调用本地的 localhost:11434。
2. 操作步骤
在终端中运行(确保 Ollama 已更新到最新版):
Bash
ollama run minicpm-v
# 或者尝试更轻量的
ollama run llama3.2-vision
3. 笔记软件集成逻辑 (Python 示例)
VLM 最大的优势是可以接受 Prompt(提示词)。你可以强制要求它只输出 LaTeX 格式。
Python
import base64
import requests
import json
def recognize_chemistry_page(image_path):
# 1. 图片转 Base64
with open(image_path, "rb") as f:
base64_image = base64.b64encode(f.read()).decode("utf-8")
# 2. 构造 Prompt,专门针对化学公式优化
prompt = """
Identify the content in this image.
Transcribe all text into Markdown format.
For chemical formulas and mathematical equations, you MUST use LaTeX format (e.g., $H_2O$, $C_6H_6$).
Do not explain the image, just output the content.
"""
# 3. 调用 Ollama API
url = "http://localhost:11434/api/generate"
data = {
"model": "minicpm-v", # 或 llama3.2-vision
"prompt": prompt,
"images": [base64_image],
"stream": False
}
response = requests.post(url, json=data)
result = response.json()
return result['response']
# 测试
print(recognize_chemistry_page("chem_book_page.jpg"))
方案二:Nougat (Meta 专门开发的学术 OCR)
如果你发现通用的大模型有时候会“胡编乱造”(幻觉),那么 Nougat 是专门为科学文献(PDF转Markdown)设计的“小”模型。
优点: 极高精度还原数学和化学公式(LaTeX)。
Windows 兼容性: 它基于 PyTorch。虽然原生 Windows + AMD 比较麻烦,但你可以强制使用 CPU 运行。
注意: 因为模型参数不大(Base版约 350M),用 CPU 跑虽然比 GPU 慢,但处理单页截图作为笔记插件完全可以接受(通常几秒钟)。
安装与使用:
Bash
pip install nougat-ocr
代码调用(CPU模式): 你需要通过命令行或 Python 封装调用,强制指定设备为 CPU。虽然慢一点,但它是目前开源界处理科学公式的“标准答案”。
+9
View File
@@ -0,0 +1,9 @@
# install4j Wizard
![](images/516ed8cc717d5949e9b831ecb5c409711ba657b4fe8a39a36cd02c79b56d0b28.jpg)
The install4j wizard could not find a Java(TM) Runtime Environment on your system. Please locate a suitable 64-bit JRE. (minimum version: 17, maximum version: 23)
Locate
Cancel
@@ -0,0 +1,71 @@
[
{
"type": "text",
"text": "install4j Wizard",
"text_level": 1,
"bbox": [
19,
10,
327,
128
],
"page_idx": 0
},
{
"type": "image",
"img_path": "images/516ed8cc717d5949e9b831ecb5c409711ba657b4fe8a39a36cd02c79b56d0b28.jpg",
"image_caption": [],
"image_footnote": [],
"bbox": [
52,
288,
156,
481
],
"page_idx": 0
},
{
"type": "text",
"text": "The install4j wizard could not find a Java(TM) Runtime Environment on your system. Please locate a suitable 64-bit JRE. (minimum version: 17, maximum version: 23)",
"bbox": [
191,
229,
967,
572
],
"page_idx": 0
},
{
"type": "text",
"text": "Locate",
"bbox": [
568,
834,
667,
903
],
"page_idx": 0
},
{
"type": "text",
"text": "Cancel",
"bbox": [
813,
834,
913,
903
],
"page_idx": 0
},
{
"type": "header",
"text": "X",
"bbox": [
937,
21,
982,
101
],
"page_idx": 0
}
]
+227
View File
@@ -0,0 +1,227 @@
{
"pdf_info": [
{
"para_blocks": [
{
"bbox": [
8,
2,
132,
24
],
"type": "title",
"angle": 0,
"lines": [
{
"bbox": [
8,
2,
132,
24
],
"spans": [
{
"bbox": [
8,
2,
132,
24
],
"type": "text",
"content": "install4j Wizard"
}
]
}
],
"index": 0
},
{
"type": "image",
"bbox": [
21,
54,
63,
90
],
"blocks": [
{
"bbox": [
21,
54,
63,
90
],
"lines": [
{
"bbox": [
21,
54,
63,
90
],
"spans": [
{
"bbox": [
21,
54,
63,
90
],
"type": "image",
"image_path": "516ed8cc717d5949e9b831ecb5c409711ba657b4fe8a39a36cd02c79b56d0b28.jpg"
}
]
}
],
"index": 2,
"angle": 0,
"type": "image_body"
}
],
"index": 2
},
{
"bbox": [
77,
43,
390,
107
],
"type": "text",
"angle": 0,
"lines": [
{
"bbox": [
77,
43,
390,
107
],
"spans": [
{
"bbox": [
77,
43,
390,
107
],
"type": "text",
"content": "The install4j wizard could not find a Java(TM) Runtime Environment on your system. Please locate a suitable 64-bit JRE. (minimum version: 17, maximum version: 23)"
}
]
}
],
"index": 3
},
{
"bbox": [
229,
156,
269,
169
],
"type": "text",
"angle": 0,
"lines": [
{
"bbox": [
229,
156,
269,
169
],
"spans": [
{
"bbox": [
229,
156,
269,
169
],
"type": "text",
"content": "Locate"
}
]
}
],
"index": 4
},
{
"bbox": [
328,
156,
368,
169
],
"type": "text",
"angle": 0,
"lines": [
{
"bbox": [
328,
156,
368,
169
],
"spans": [
{
"bbox": [
328,
156,
368,
169
],
"type": "text",
"content": "Cancel"
}
]
}
],
"index": 5
}
],
"discarded_blocks": [
{
"bbox": [
378,
4,
396,
19
],
"type": "header",
"angle": 0,
"lines": [
{
"bbox": [
378,
4,
396,
19
],
"spans": [
{
"bbox": [
378,
4,
396,
19
],
"type": "text",
"content": "X"
}
]
}
],
"index": 1
}
],
"page_size": [
403,
187
],
"page_idx": 0
}
],
"_backend": "vlm",
"_version_name": "2.6.3"
}
+70
View File
@@ -0,0 +1,70 @@
[
[
{
"type": "title",
"bbox": [
0.02,
0.014,
0.328,
0.131
],
"angle": 0,
"content": "install4j Wizard"
},
{
"type": "header",
"bbox": [
0.939,
0.025,
0.985,
0.103
],
"angle": 0,
"content": "X"
},
{
"type": "image",
"bbox": [
0.053,
0.29,
0.157,
0.484
],
"angle": 0,
"content": null
},
{
"type": "text",
"bbox": [
0.193,
0.234,
0.969,
0.577
],
"angle": 0,
"content": "The install4j wizard could not find a Java(TM) Runtime Environment on your system. Please locate a suitable 64-bit JRE. (minimum version: 17, maximum version: 23)"
},
{
"type": "text",
"bbox": [
0.569,
0.839,
0.668,
0.906
],
"angle": 0,
"content": "Locate"
},
{
"type": "text",
"bbox": [
0.814,
0.837,
0.915,
0.906
],
"angle": 0,
"content": "Cancel"
}
]
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,7 @@
首先是羰基氧原子接受质子, 而后生成烯醇。决定反应速率的步骤是生成烯醇的一步, 生成烯醇后, 卤素作为亲电试剂与烯醇的双键发生亲电加成, 生成 $\alpha$ -卤代羰基化合物和卤化氢, 所以酸催化常常是自催化。例如治疗支气管炎、喘息性支气管炎药物氯丙那林(Clorprenaline)中间体 $\alpha$ -溴代邻氯苯乙酮(1)的制备 (张宝丰. 中国医药工业杂志, 1989, 20:33):
![](images/13d3f822a19e58284df7e8f6375844713ec35907bdd2e920ebf752ecfc2c1b95.jpg)
反应时经常有一个诱导期,因为反应开始时烯醇化速率较慢。随着反应的进行,卤化氢浓度增大,烯醇化速率加快,反应也相应加快。反应初期,可加入少量氢卤酸以缩短诱导期,光照也常起到明显的催化效果。Lewis酸对某些反应有催化作用,例如苯乙酮的溴化,在催化量的三氯化铝存在下生成 $\alpha$ -溴代苯乙酮,而三氯化铝过量时生成间-溴苯乙酮。
![](images/b1459176fc74696f7ac62ec4c74a5593cc9087862c96fbcc27be5e98a3933f39.jpg)
@@ -0,0 +1,50 @@
[
{
"type": "text",
"text": "首先是羰基氧原子接受质子, 而后生成烯醇。决定反应速率的步骤是生成烯醇的一步, 生成烯醇后, 卤素作为亲电试剂与烯醇的双键发生亲电加成, 生成 $\\alpha$ -卤代羰基化合物和卤化氢, 所以酸催化常常是自催化。例如治疗支气管炎、喘息性支气管炎药物氯丙那林(Clorprenaline)中间体 $\\alpha$ -溴代邻氯苯乙酮(1)的制备 (张宝丰. 中国医药工业杂志, 1989, 20:33):",
"bbox": [
10,
0,
998,
200
],
"page_idx": 0
},
{
"type": "image",
"img_path": "images/13d3f822a19e58284df7e8f6375844713ec35907bdd2e920ebf752ecfc2c1b95.jpg",
"image_caption": [],
"image_footnote": [],
"bbox": [
45,
223,
531,
431
],
"page_idx": 0
},
{
"type": "text",
"text": "反应时经常有一个诱导期,因为反应开始时烯醇化速率较慢。随着反应的进行,卤化氢浓度增大,烯醇化速率加快,反应也相应加快。反应初期,可加入少量氢卤酸以缩短诱导期,光照也常起到明显的催化效果。Lewis酸对某些反应有催化作用,例如苯乙酮的溴化,在催化量的三氯化铝存在下生成 $\\alpha$ -溴代苯乙酮,而三氯化铝过量时生成间-溴苯乙酮。",
"bbox": [
14,
516,
992,
714
],
"page_idx": 0
},
{
"type": "image",
"img_path": "images/b1459176fc74696f7ac62ec4c74a5593cc9087862c96fbcc27be5e98a3933f39.jpg",
"image_caption": [],
"image_footnote": [],
"bbox": [
45,
741,
730,
996
],
"page_idx": 0
}
]
@@ -0,0 +1,232 @@
{
"pdf_info": [
{
"para_blocks": [
{
"bbox": [
14,
0,
1298,
216
],
"type": "text",
"angle": 0,
"lines": [
{
"bbox": [
14,
0,
1298,
216
],
"spans": [
{
"bbox": [
14,
0,
1298,
216
],
"type": "text",
"content": "首先是羰基氧原子接受质子, 而后生成烯醇。决定反应速率的步骤是生成烯醇的一步, 生成烯醇后, 卤素作为亲电试剂与烯醇的双键发生亲电加成, 生成 "
},
{
"bbox": [
14,
0,
1298,
216
],
"type": "inline_equation",
"content": "\\alpha"
},
{
"bbox": [
14,
0,
1298,
216
],
"type": "text",
"content": "-卤代羰基化合物和卤化氢, 所以酸催化常常是自催化。例如治疗支气管炎、喘息性支气管炎药物氯丙那林(Clorprenaline)中间体 "
},
{
"bbox": [
14,
0,
1298,
216
],
"type": "inline_equation",
"content": "\\alpha"
},
{
"bbox": [
14,
0,
1298,
216
],
"type": "text",
"content": "-溴代邻氯苯乙酮(1)的制备 (张宝丰. 中国医药工业杂志, 1989, 20:33):"
}
]
}
],
"index": 0
},
{
"type": "image",
"bbox": [
59,
241,
691,
464
],
"blocks": [
{
"bbox": [
59,
241,
691,
464
],
"lines": [
{
"bbox": [
59,
241,
691,
464
],
"spans": [
{
"bbox": [
59,
241,
691,
464
],
"type": "image",
"image_path": "13d3f822a19e58284df7e8f6375844713ec35907bdd2e920ebf752ecfc2c1b95.jpg"
}
]
}
],
"index": 1,
"angle": 0,
"type": "image_body"
}
],
"index": 1
},
{
"bbox": [
19,
556,
1290,
769
],
"type": "text",
"angle": 0,
"lines": [
{
"bbox": [
19,
556,
1290,
769
],
"spans": [
{
"bbox": [
19,
556,
1290,
769
],
"type": "text",
"content": "反应时经常有一个诱导期,因为反应开始时烯醇化速率较慢。随着反应的进行,卤化氢浓度增大,烯醇化速率加快,反应也相应加快。反应初期,可加入少量氢卤酸以缩短诱导期,光照也常起到明显的催化效果。Lewis酸对某些反应有催化作用,例如苯乙酮的溴化,在催化量的三氯化铝存在下生成 "
},
{
"bbox": [
19,
556,
1290,
769
],
"type": "inline_equation",
"content": "\\alpha"
},
{
"bbox": [
19,
556,
1290,
769
],
"type": "text",
"content": "-溴代苯乙酮,而三氯化铝过量时生成间-溴苯乙酮。"
}
]
}
],
"index": 2
},
{
"type": "image",
"bbox": [
59,
798,
950,
1072
],
"blocks": [
{
"bbox": [
59,
798,
950,
1072
],
"lines": [
{
"bbox": [
59,
798,
950,
1072
],
"spans": [
{
"bbox": [
59,
798,
950,
1072
],
"type": "image",
"image_path": "b1459176fc74696f7ac62ec4c74a5593cc9087862c96fbcc27be5e98a3933f39.jpg"
}
]
}
],
"index": 3,
"angle": 0,
"type": "image_body"
}
],
"index": 3
}
],
"discarded_blocks": [],
"page_size": [
1300,
1076
],
"page_idx": 0
}
],
"_backend": "vlm",
"_version_name": "2.6.3"
}
@@ -0,0 +1,48 @@
[
[
{
"type": "text",
"bbox": [
0.011,
0.0,
0.999,
0.201
],
"angle": 0,
"content": "首先是羰基氧原子接受质子, 而后生成烯醇。决定反应速率的步骤是生成烯醇的一步, 生成烯醇后, 卤素作为亲电试剂与烯醇的双键发生亲电加成, 生成 \\(\\alpha\\)-卤代羰基化合物和卤化氢, 所以酸催化常常是自催化。例如治疗支气管炎、喘息性支气管炎药物氯丙那林(Clorprenaline)中间体 \\(\\alpha\\)-溴代邻氯苯乙酮(1)的制备 (张宝丰. 中国医药工业杂志, 1989, 20:33):"
},
{
"type": "image",
"bbox": [
0.046,
0.224,
0.532,
0.432
],
"angle": 0,
"content": null
},
{
"type": "text",
"bbox": [
0.015,
0.517,
0.993,
0.715
],
"angle": 0,
"content": "反应时经常有一个诱导期,因为反应开始时烯醇化速率较慢。随着反应的进行,卤化氢浓度增大,烯醇化速率加快,反应也相应加快。反应初期,可加入少量氢卤酸以缩短诱导期,光照也常起到明显的催化效果。Lewis酸对某些反应有催化作用,例如苯乙酮的溴化,在催化量的三氯化铝存在下生成 \\(\\alpha\\)-溴代苯乙酮,而三氯化铝过量时生成间-溴苯乙酮。"
},
{
"type": "image",
"bbox": [
0.046,
0.742,
0.731,
0.997
],
"angle": 0,
"content": null
}
]
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

+4 -2
View File
@@ -44,7 +44,7 @@
| 前端框架 | Next.js 15App Router + RSC | 依旧最强,RSC 可直接调用后端 API,首屏极快 | — | | 前端框架 | Next.js 15App Router + RSC | 依旧最强,RSC 可直接调用后端 API,首屏极快 | — |
| UI/样式 | Tailwind + shadcn/ui + Radix + lucide-react | 保持不变,直接 `npx shadcn@latest add` | — | | UI/样式 | Tailwind + shadcn/ui + Radix + lucide-react | 保持不变,直接 `npx shadcn@latest add` | — |
| 笔记编辑器 | @blocknote/react + @blocknote/core(免费版足以)| 已验证与 Yjs + hocuspocus + Supabase Realtime 最稳定 | 前端 | | 笔记编辑器 | @blocknote/react + @blocknote/core(免费版足以)| 已验证与 Yjs + hocuspocus + Supabase Realtime 最稳定 | 前端 |
| 思维导图 | @xyflow/react (React Flow 11+) | RSC 友好,自定义节点最强 | 前端 | | 思维导图 | simple-mind-map + 自研 KMindRenderer | 直接复用 Wolai 风格交互,兼容现有 KMind 主题 & 快照 | 前端 |
| 后端框架 | FastAPI + Uvicorn + Celery[redis] + Redis | 最高性能 Python Web 框架,Celery 队列成熟 | 后端 | | 后端框架 | FastAPI + Uvicorn + Celery[redis] + Redis | 最高性能 Python Web 框架,Celery 队列成熟 | 后端 |
| OCR | MinerU (opendatalab/MinerU latest) | 2025 年 PDF 提取精度最高(尤其数学公式、表格、中文) | 后端 | | OCR | MinerU (opendatalab/MinerU latest) | 2025 年 PDF 提取精度最高(尤其数学公式、表格、中文) | 后端 |
| RAG / KG | LightRAGlatest+ pgvector | 支持 KG + Vector 混合检索最强,增量更新极快,成本最低 | 后端,直接操作同一 Supabase pgvector 表 | | RAG / KG | LightRAGlatest+ pgvector | 支持 KG + Vector 混合检索最强,增量更新极快,成本最低 | 后端,直接操作同一 Supabase pgvector 表 |
@@ -133,7 +133,9 @@ create table document_table_rows (
| 2 | 后端基础 API + MinerU OCR 队列 | 文件上传 → Storage → 调用 /tasks/ocr → Celery worker 执行 MinerU → 更新 document.raw_text + index_status | 3-4 天 | “FastAPI endpoint that accepts Supabase signed URL, downloads PDF, runs MinerU magic_pdf(..., ocr=True), saves markdown to document.content and raw_text, updates index_status.” | | 2 | 后端基础 API + MinerU OCR 队列 | 文件上传 → Storage → 调用 /tasks/ocr → Celery worker 执行 MinerU → 更新 document.raw_text + index_status | 3-4 天 | “FastAPI endpoint that accepts Supabase signed URL, downloads PDF, runs MinerU magic_pdf(..., ocr=True), saves markdown to document.content and raw_text, updates index_status.” |
| 3 | LightRAG 索引管道 | 笔记保存或 OCR 完成 → Celery 任务 LightRAG.index(document_id, text, user_id)(增量更新) | 3 天 | “Use LightRAG with PGVectorStore, implement incremental update when document.updated_at changes, support user-level isolation.” | | 3 | LightRAG 索引管道 | 笔记保存或 OCR 完成 → Celery 任务 LightRAG.index(document_id, text, user_id)(增量更新) | 3 天 | “Use LightRAG with PGVectorStore, implement incremental update when document.updated_at changes, support user-level isolation.” |
| 4 | AI 问答侧边栏(流式) | 前端调用后端 /chat stream → LightRAG.query() → SSE 返回带来源引用 | 2-3 天 | “FastAPI SSE endpoint /api/v1/chat that uses LightRAG.query(query, user_id) and streams token + sources.” | | 4 | AI 问答侧边栏(流式) | 前端调用后端 /chat stream → LightRAG.query() → SSE 返回带来源引用 | 2-3 天 | “FastAPI SSE endpoint /api/v1/chat that uses LightRAG.query(query, user_id) and streams token + sources.” |
| 5 | 思维导图 + 双向同步 | React Flow + 与 BlockNote outline 实时同步 | 3 天 | 同 v2.0 | | 5 | 思维导图 + 双向同步 | 基于 simple-mind-map 的 KMindRenderer,与 BlockNote outline 实时同步、导出/预览 | 3 天 | 同 v2.0 |
> **进度提示**simple-mind-map 渲染器已在前端切换完成,但与 BlockNote 大纲的双向同步、导出为 PNG/PDF/Markdown 以及思维导图 ↔ 笔记互跳等能力仍属于未完成事项,需要在阶段 5 内继续收敛。
| 6 | 任务进度实时推送 | Supabase Realtime 监听 background_tasks 表变化 → 前端显示进度条 | 1 天 | — | | 6 | 任务进度实时推送 | Supabase Realtime 监听 background_tasks 表变化 → 前端显示进度条 | 1 天 | — |
| 7 | 性能优化 + 测试 + 部署 | 前端 skeleton + lazy,后端 rate limit + concurrency=3Cypress + pytest | 4 天 | — | | 7 | 性能优化 + 测试 + 部署 | 前端 skeleton + lazy,后端 rate limit + concurrency=3Cypress + pytest | 4 天 | — |
+138
View File
@@ -0,0 +1,138 @@
```markdown
# MNOTE v3.0 未完成项计划与检查清单(截至 2025-12-02
> 本清单聚焦 design3.0.md 中仍未落地的要点,按依赖顺序排列。勾选项即完成,可作为阶段验收依据。
## 阶段性计划
1. **后端基础补全**:补建数据库表、打通 MinerU OCR 流程。
2. **LightRAG 全链路**:实现增量索引与 `/api/v1/chat` 流式回答。
3. **思维导图增强**:在 simple-mind-map 基础上补齐双向同步与导出。
4. **实时协作与任务反馈**:用 Supabase Realtime 推送 background_tasks 进度、完善媒体/PDF OCR API。
5. **表格后端化**:按照 design3.0 P1/P2 要求提供 FastAPI CRUD,以承载 Luckysheet 协同。
## 详细 Checklist
- [x] **背景任务表迁移**
-`supabase/migrations` 中新增 `background_tasks` 表及 RLS、索引、触发器,与 design3.0.md 第 3 节保持一致。
- 验证 `task_tracker` 读写正常(POST/GET `/api/v1/tasks/*`)。
- [x] **MinerU OCR 管线**
- `app/workers/tasks.py` 真正串联 MinerU:下载 Supabase Storage 文件 → 调用 `services/mineru` → 写入 `documents.content/raw_text/index_status`
- `/api/v1/tasks/ocr` 支持真实参数(signed URL、document_id),并返回 Celery track_id。
- `DocumentTaskPanel` 将示例 URL 改为真实 Storage URL,增加失败提示。
- [x] **媒体 OCR API 对齐**
- FastAPI 新增 `/api/v1/tasks/media-ocr`,与前端 `/api/media/ocr` 对接。
- 统一 media_assets.ocr_status / ocr_payload 写入逻辑,并复用 MinerU 输出。
- [x] **Supabase Realtime 任务推送**
- 前端订阅 `background_tasks` 频道(Supabase Channel),实时更新状态 & 提示。
- 关闭现有轮询 fallback 或保留为降级方案。
- [ ] **documents 表字段与索引对齐**
- 确认 `documents` 表已包含 `mindmap_data/raw_text/index_status` 字段与默认值,并在 `supabase/migrations` 中补齐缺失迁移。
- 补建更新触发器/索引,保证 `updated_at``index_status` 的写入由后端与 Celery 任务一致,方便 LightRAG 增量监测。
- [ ] **LightRAG 索引与聊天**
- `lightrag_service` 调用实际 LightRAG:初始化 PGVectorStore、支持 `queue_index``query`
- 保存笔记/完成 OCR 后触发增量索引;`/api/v1/chat` 以 SSE 返回引用、来源数组。
-`user_id`/workspace 作为 LightRAG namespace,保证多租户隔离,并实现 `/api/v1/lightrag/health` 自检 + `--init-index` 重建脚本。
- [ ] **Celery 队列优先级与并发治理**
- 将 OCR/索引任务放入 Redis 优先级队列,worker 默认 concurrency=3,并限制同账号并发数(避免 CPU 撑爆)。
- Celery 任务需写入 `progress/message` 字段,失败自动进入 retry/backoff,配合 Supabase Realtime 显示。
- [ ] **Supabase Auth JWT 校验与跨域策略**
- FastAPI 中使用 `supabase-py` 验证前端携带的 JWT,拉通 `user_id` 注入依赖,拒绝匿名调用。
- 统一 CORS 配置(Next.js 域名 + 桌面端自签),并在 `/api/*` 中校验 workspace 权限。
- [ ] **simple-mind-map ↔ BlockNote 双向同步**
-`KMindRenderer`/Lab 页面中监听节点改动,生成概要数据写回 `documents.mindmap_data`
- BlockNote 侧根据最新 `mindmap_data` 更新大纲/引用,支持跳转到具体 mindmap 节点。
- 实现 PNG/PDF/Markdown 导出与文档内缩略预览。
- [ ] **思维导图/笔记互跳与引用**
- PDF/引用面板可跳转 mindmap 节点(事件 `mindmap:jump-to-node`)。
- Mindmap 节点可附加 BlockNote 块链接,实现双向导航。
- [ ] **表格后端接口 & 协同**
- FastAPI 新增 `/api/v1/tables`, `/api/v1/tables/{id}/rows` CRUD,与 `document_tables`/`document_table_rows` RLS 一致。
- 前端 Luckysheet 协同使用该接口(或 Supabase Rest)写入,支持版本快照/节流。
- [ ] **BlockNote 表格块 & 预览(P3**
- Slash 命令支持“插入协同表格”,创建即落库 `document_tables` 并在 BlockNote 中插入引用块。
- 列出前 5 行 + hover 工具栏预览,支持点击跳转全屏编辑。
- [ ] **全屏 Luckysheet 编辑器(P4**
- `<FullScreenTableEditor>` 完成多选、冻结、合并单元格、条件格式、暗黑模式和在线协作者头像。
- 工具栏、快捷键与飞书表格一致,兼容 simple-mind-map 的快捷跳转。
- [ ] **表格持久化与性能(P5**
- Yjs SubDoc → Supabase 节流写入,增量 diff 仅写改动行,并定期生成 `table_versions` 快照。
- 提供 CSV/Excel 导入、虚拟滚动与断线重连恢复,验证 500×50 表格不卡顿。
- [ ] **PDF Sticky OCR 与引用**
- `/api/pdf/ocr/[assetId]` 返回的数据来自后端 MinerU(非直接读表),支持重新拉取。
- PDF 引用写回 `pdf_quotes` 表,Mindmap/PDF/BlockNote 三方共享同一引用格式。
- [ ] **文档保存 → LightRAG 自动更新**
- `DocumentContent` 提交后触发后台任务(轻量 queue)写入 LightRAG,而不仅是 Supabase。
- 失败时在 UI 中提示“索引重试”按钮。
- [ ] **实时协作共享链接与权限控制**
- 基于 Supabase Realtime Presence 实现 20 人同时编辑的游标/选择框,并测量延迟 < 800ms。
- 提供私有/工作区/公共只读分享链接,结合 `workspace_members` RLS 校验访问级别。
- [ ] **Supabase Storage 签名 URL 与服务端下载闭环**
- 前端仅生成短时 signed URLFastAPI 使用 service_role 拉取原始文件,超时自动重新签名。
- 针对桌面端上传,补充失败重传/断点续传与 Storage 目录规划(user_id/workspace/document_id)。
- [ ] **性能优化 & 自动化测试(阶段 7)**
- 前端提供 skeleton + lazy chunkBlockNote/思维导图切分 bundle,桌面端 `npm run desktop:hot` 自检指标 < 800ms。
- 后端加入速率限制、pytest + Celery 集成测试、Cypress 多人协作 E2E,并在 CI 中跑。
完成上述清单后,即可宣告 design3.0 的核心路径(OCR + LightRAG + simple-mind-map + 协同表格)满足验收标准。
```
现状速记
- 后端 OCR 流程已串好:wolai-backend/app/workers/tasks.py 中 ocr_pipeline 通过 storage_fetcher 下载签
名 URL,调用 mineru_service.extract_markdown_syncVLM 后端可配置),写回 documents.content/raw_text/
index_status 并自动投递 lightrag_index_pipeline。media_ocr_pipeline 也会更新 media_assets.ocr_status/
ocr_payload。
- 数据面同步到位:supabase/migrations/20251204_alter_documents_add_mindmap_fields.sql 补齐 mindmap_data/ raw_text/index_status 及索引/触发器,20251203_add_background_tasks.sql 完成任务表 + RLS。
- 前端任务面板已接入 Realtimewolai-frontend/src/components/document-task-panel.tsx 订阅
background_tasks 频道,可手工输入 Storage URL 触发 /api/v1/tasks/ocr,并展示 OCR/索引进度。
- 文档保存会触发索引:wolai-frontend/src/app/api/documents/save/route.ts 在保存内容时写入 raw_text/
index_status=pending,并向后端 /api/v1/lightrag/index 异步投递。
- LightRAG 壳已就绪:wolai-backend/app/services/lightrag_service.py 支持 workspace/user namespace/api/ v1/chat 提供 SSE,占位的 scripts/init_lightrag.py 可批量重建索引(未验证实链)。
主要缺口 / 风险
- LightRAG 功能未闭环:缺少前端聊天 UI(未消费 /api/v1/chat SSE),也未验证真实 LightRAG PGVector/LLM 调 用,当前 fallback 可能静默返回占位数据。
- Celery 治理不足:虽配置了优先级队列与并发=3,但未做账号级限流/去重/重试回退,进度消息较粗,失败场景
无 backoff。
- 鉴权与隔离:FastAPI 仅做 token 校验,写库全部用 service_rolesupabase_rest),缺少 workspace 成员校验 与 CORS/桌面端域名统一策略。
- 思维导图 ↔ 笔记未同步:mindmap_data 仅在导图页保存,BlockNote 无法反哺/消费导图结构,也未实现节点 ↔
BlockNote 块/引用的互跳。
- 表格后端缺失:FastAPI 无 /api/v1/tables CRUD,当前全靠 Next 路由直写 SupabaseLuckysheet 协同仅有 WS
转发,未落库增量/节流/快照。
- PDF OCR/引用链路半成品:前端 pdf-asset-viewer 读取 /api/pdf/ocr 和 pdf_quotes,但后端没有 MinerU PDF
OCR 接口,media_ocr_pipeline 仅填空 payload,无法重新拉取真 OCR。
- Storage 签名与续签:OCR 触发仍靠手填 URL,后端也未实现 service_role 下载 + 失效重签。
下一步优先级建议
1. 验证并打通 LightRAG 实链:为 PG/LLM 配好 env,跑一条索引 + /api/v1/chat 流式问答自检,补全引用数组格
式;前端落地聊天侧边栏消费 SSE。
2. 强化 Celery 任务治理:增加 per-user 并发/队列优先级策略、重试/退避、进度消息细化
pending→processing→completed/failed),并确保 Realtime 推送正确。
3. 补齐鉴权与 CORS:在 FastAPI 统一用 supabase-py 校验 JWT,所有写操作检查 workspace 成员;整理 CORS 允
许列表覆盖桌面端自签域。
4. 实现导图↔笔记双向同步:BlockNote 监听节点摘要写回 documents.mindmap_data,导图侧消费最新大纲/块链接, 增加互跳事件(block_id/mindmap node id)。
5. 表格后端化:在 FastAPI 增加 /api/v1/tables 与 /api/v1/tables/{id}/rows,对齐 document_tables/
document_table_rows RLSLuckysheet 读写走该接口并加节流/快照。
6. 完善 PDF OCR/引用链路:新增 /api/v1/tasks/pdf-ocr 或复用 media-ocr 生成带页码/rect 的 OCR payload/
api/pdf/ocr/[assetId] 回源 MinerU 结果并支持重拉。
7. Storage 签名闭环:前端生成短时 signed URL,后端负责下载/过期重签,任务触发面板改为自动选取文档关联文
件而非手填 URL。
View File
+120 -2
View File
@@ -16,12 +16,67 @@ const { spawn } = require("child_process");
const path = require("path"); const path = require("path");
const net = require("net"); const net = require("net");
const { URL } = require("url"); const { URL } = require("url");
const fs = require("fs");
const rootDir = path.resolve(__dirname, ".."); const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend"); const frontendDir = path.join(rootDir, "wolai-frontend");
const backendDir = path.join(rootDir, "wolai-backend"); const backendDir = path.join(rootDir, "wolai-backend");
const mineruDir = path.join(rootDir, "services", "mineru");
const baseEnv = { ...process.env };
const frontendEnv = { ...baseEnv };
const backendEnv = { ...baseEnv };
const mineruEnv = { ...baseEnv };
const pythonBin = process.env.PYTHON_BIN || "python"; function loadEnvFile(filePath, targetEnv, { overrideSupabase = false } = {}) {
if (!fs.existsSync(filePath)) {
return;
}
const content = fs.readFileSync(filePath, "utf8");
content.split(/\r?\n/).forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const eqIndex = trimmed.indexOf("=");
if (eqIndex <= 0) return;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
const hasValue = Boolean(targetEnv[key]);
const shouldForce =
overrideSupabase && key.startsWith("SUPABASE_") && (!hasValue || targetEnv[key].includes("supabase.co"));
if (!hasValue || shouldForce) {
targetEnv[key] = value;
}
});
}
// 强制后端优先使用本地 .env 中的 Supabase 配置,避免系统环境变量指向线上实例。
loadEnvFile(path.join(backendDir, ".env"), backendEnv, { overrideSupabase: true });
loadEnvFile(path.join(rootDir, ".env.local"), backendEnv);
loadEnvFile(path.join(rootDir, ".env.local"), frontendEnv);
loadEnvFile(path.join(rootDir, ".env.local"), mineruEnv);
function resolvePythonBin() {
const suffixes =
process.platform === "win32"
? [
["venv", "Scripts", "python.exe"],
[".venv", "Scripts", "python.exe"],
]
: [
["venv", "bin", "python3"],
["venv", "bin", "python"],
[".venv", "bin", "python3"],
[".venv", "bin", "python"],
];
const candidates = suffixes.map((parts) => path.join(backendDir, ...parts));
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
const pythonBin = process.env.PYTHON_BIN || resolvePythonBin() || "python";
const celeryBin = process.env.CELERY_BIN || "celery"; const celeryBin = process.env.CELERY_BIN || "celery";
const skipCelery = const skipCelery =
(process.env.SKIP_CELERY || "").toLowerCase() === "1" || (process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
@@ -34,6 +89,7 @@ const tasks = [
name: "frontend", name: "frontend",
command: process.env.FRONTEND_CMD || "pnpm dev", command: process.env.FRONTEND_CMD || "pnpm dev",
cwd: frontendDir, cwd: frontendDir,
env: frontendEnv,
}, },
{ {
name: "backend", name: "backend",
@@ -41,9 +97,59 @@ const tasks = [
process.env.BACKEND_CMD || process.env.BACKEND_CMD ||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`, `${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
cwd: backendDir, cwd: backendDir,
env: backendEnv,
}, },
]; ];
function resolveMineru() {
// 优先使用带 ROCm 的全局 venv,找不到再回退 services/mineru/.venv
const rocmPython = path.join("F:\\", "rocm", ".venv-rocm312", "Scripts", "python.exe");
const localVenvPython = path.join(mineruDir, ".venv", "Scripts", "python.exe");
const pythonCandidates = [rocmPython, localVenvPython].filter((p) => fs.existsSync(p));
const venvPython = pythonCandidates[0];
const srcDir = path.join(mineruDir, "src");
if (!venvPython || !fs.existsSync(srcDir)) {
logPrefix(
"mineru",
"未找到 MinerU 虚拟环境或源码,已跳过自动启动。请先运行 services/mineru/setup.ps1",
);
return null;
}
const endpoint = mineruEnv.MINERU_ENDPOINT || "http://127.0.0.1:18888";
const url = new URL(endpoint);
const host = url.hostname || "127.0.0.1";
const port = url.port || "18888";
const command =
process.env.MINERU_CMD ||
`${venvPython} -m uvicorn mineru.cli.fast_api:app --host ${host} --port ${port}`;
const modelPath = path.join(mineruDir, "models", "MinerU2.5-2509-1.2B");
const configPath = path.join(mineruDir, "mineru.json");
return {
name: "mineru",
command,
cwd: srcDir,
env: {
...mineruEnv,
PYTHONPATH: srcDir,
MINERU_ENDPOINT: endpoint,
// 默认使用本地 VLM 模型
MINERU_TOOLS_CONFIG_JSON: configPath,
MINERU_MODEL_SOURCE: "local",
MINERU_MODEL_PATH: modelPath,
MINERU_DEFAULT_BACKEND: "vlm-transformers",
MINERU_TIMEOUT_SECONDS: mineruEnv.MINERU_TIMEOUT_SECONDS || "600",
},
};
}
const mineruTask = resolveMineru();
if (mineruTask) {
tasks.unshift(mineruTask);
backendEnv.MINERU_ENDPOINT = mineruTask.env.MINERU_ENDPOINT;
}
console.log(`[system] Python 解释器:${pythonBin}`);
const children = []; const children = [];
let shuttingDown = false; let shuttingDown = false;
@@ -57,7 +163,7 @@ function startTask(task) {
cwd: task.cwd, cwd: task.cwd,
stdio: "inherit", stdio: "inherit",
shell: true, shell: true,
env: { ...process.env }, env: task.env ? { ...task.env } : { ...process.env },
}); });
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
@@ -133,6 +239,7 @@ async function checkRedisReachable(urlString, timeoutMs = 2000) {
} }
async function main() { async function main() {
let celeryScheduled = false;
if (!skipCelery) { if (!skipCelery) {
const celeryTask = { const celeryTask = {
name: "celery", name: "celery",
@@ -142,8 +249,10 @@ async function main() {
if (celeryCmdFromEnv) { if (celeryCmdFromEnv) {
tasks.push(celeryTask); tasks.push(celeryTask);
celeryScheduled = true;
} else if (await checkRedisReachable(redisUrl)) { } else if (await checkRedisReachable(redisUrl)) {
tasks.push(celeryTask); tasks.push(celeryTask);
celeryScheduled = true;
} else { } else {
// Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。 // Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。
logPrefix( logPrefix(
@@ -153,6 +262,15 @@ async function main() {
} }
} }
if (!celeryScheduled) {
backendEnv.CELERY_TASK_ALWAYS_EAGER = "1";
backendEnv.CELERY_TASK_EAGER_PROPAGATES = backendEnv.CELERY_TASK_EAGER_PROPAGATES || "1";
logPrefix(
"backend",
"未启动 Celery,自动启用 task_always_eager 模式用于同步执行后台任务。",
);
}
if (tasks.length === 0) { if (tasks.length === 0) {
console.error("未配置任何可运行的任务,检查环境变量设置。"); console.error("未配置任何可运行的任务,检查环境变量设置。");
process.exit(1); process.exit(1);
+858
View File
@@ -0,0 +1,858 @@
<div align="center" xmlns="http://www.w3.org/1999/html">
<!-- logo -->
<p align="center">
<img src="https://gcore.jsdelivr.net/gh/opendatalab/MinerU@master/docs/images/MinerU-logo.png" width="300px" style="vertical-align:middle;">
</p>
<!-- icon -->
[![stars](https://img.shields.io/github/stars/opendatalab/MinerU.svg)](https://github.com/opendatalab/MinerU)
[![forks](https://img.shields.io/github/forks/opendatalab/MinerU.svg)](https://github.com/opendatalab/MinerU)
[![open issues](https://img.shields.io/github/issues-raw/opendatalab/MinerU)](https://github.com/opendatalab/MinerU/issues)
[![issue resolution](https://img.shields.io/github/issues-closed-raw/opendatalab/MinerU)](https://github.com/opendatalab/MinerU/issues)
[![PyPI version](https://img.shields.io/pypi/v/mineru)](https://pypi.org/project/mineru/)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mineru)](https://pypi.org/project/mineru/)
[![Downloads](https://static.pepy.tech/badge/mineru)](https://pepy.tech/project/mineru)
[![Downloads](https://static.pepy.tech/badge/mineru/month)](https://pepy.tech/project/mineru)
[![OpenDataLab](https://img.shields.io/badge/webapp_on_mineru.net-blue?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTM0IiBoZWlnaHQ9IjEzNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtMTIyLDljMCw1LTQsOS05LDlzLTktNC05LTksNC05LDktOSw5LDQsOSw5eiIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Im0xMjIsOWMwLDUtNCw5LTksOXMtOS00LTktOSw0LTksOS05LDksNCw5LDl6IiBmaWxsPSIjMDEwMTAxIi8+PHBhdGggZD0ibTkxLDE4YzAsNS00LDktOSw5cy05LTQtOS05LDQtOSw5LTksOSw0LDksOXoiIGZpbGw9InVybCgjYikiLz48cGF0aCBkPSJtOTEsMThjMCw1LTQsOS05LDlzLTktNC05LTksNC05LDktOSw5LDQsOSw5eiIgZmlsbD0iIzAxMDEwMSIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtMzksNjJjMCwxNiw4LDMwLDIwLDM4LDctNiwxMi0xNiwxMi0yNlY0OWMwLTQsMy03LDYtOGw0Ni0xMmM1LTEsMTEsMywxMSw4djMxYzAsMzctMzAsNjYtNjYsNjYtMzcsMC02Ni0zMC02Ni02NlY0NmMwLTQsMy03LDYtOGwyMC02YzUtMSwxMSwzLDExLDh2MjF6bS0yOSw2YzAsMTYsNiwzMCwxNyw0MCwzLDEsNSwxLDgsMSw1LDAsMTAtMSwxNS0zQzM3LDk1LDI5LDc5LDI5LDYyVjQybC0xOSw1djIweiIgZmlsbD0idXJsKCNjKSIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtMzksNjJjMCwxNiw4LDMwLDIwLDM4LDctNiwxMi0xNiwxMi0yNlY0OWMwLTQsMy03LDYtOGw0Ni0xMmM1LTEsMTEsMywxMSw4djMxYzAsMzctMzAsNjYtNjYsNjYtMzcsMC02Ni0zMC02Ni02NlY0NmMwLTQsMy03LDYtOGwyMC02YzUtMSwxMSwzLDExLDh2MjF6bS0yOSw2YzAsMTYsNiwzMCwxNyw0MCwzLDEsNSwxLDgsMSw1LDAsMTAtMSwxNS0zQzM3LDk1LDI5LDc5LDI5LDYyVjQybC0xOSw1djIweiIgZmlsbD0iIzAxMDEwMSIvPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iYSIgeDE9Ijg0IiB5MT0iNDEiIHgyPSI3NSIgeTI9IjEyMCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMyZTJlMmUiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9Ijg0IiB5MT0iNDEiIHgyPSI3NSIgeTI9IjEyMCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMyZTJlMmUiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYyIgeDE9Ijg0IiB5MT0iNDEiIHgyPSI3NSIgeTI9IjEyMCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMyZTJlMmUiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=&labelColor=white)](https://mineru.net/OpenSourceTools/Extractor?source=github)
[![ModelScope](https://img.shields.io/badge/Demo_on_ModelScope-purple?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjIzIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggaWQ9InN2Z18xNCIgZmlsbD0iIzYyNGFmZiIgZD0ibTAsODkuODRsMjUuNjUsMGwwLDI1LjY0OTk5bC0yNS42NSwwbDAsLTI1LjY0OTk5eiIvPgogIDxwYXRoIGlkPSJzdmdfMTUiIGZpbGw9IiM2MjRhZmYiIGQ9Im05OS4xNCwxMTUuNDlsMjUuNjUsMGwwLDI1LjY1bC0yNS42NSwwbDAsLTI1LjY1eiIvPgogIDxwYXRoIGlkPSJzdmdfMTYiIGZpbGw9IiM2MjRhZmYiIGQ9Im0xNzYuMDksMTQxLjE0bC0yNS42NDk5OSwwbDAsMjIuMTlsNDcuODQsMGwwLC00Ny44NGwtMjIuMTksMGwwLDI1LjY1eiIvPgogIDxwYXRoIGlkPSJzdmdfMTciIGZpbGw9IiMzNmNmZDEiIGQ9Im0xMjQuNzksODkuODRsMjUuNjUsMGwwLDI1LjY0OTk5bC0yNS42NSwwbDAsLTI1LjY0OTk5eiIvPgogIDxwYXRoIGlkPSJzdmdfMTgiIGZpbGw9IiMzNmNmZDEiIGQ9Im0wLDY0LjE5bDI1LjY1LDBsMCwyNS42NWwtMjUuNjUsMGwwLC0yNS42NXoiLz4KICA8cGF0aCBpZD0ic3ZnXzE5IiBmaWxsPSIjNjI0YWZmIiBkPSJtMTk4LjI4LDg5Ljg0bDI1LjY0OTk5LDBsMCwyNS42NDk5OWwtMjUuNjQ5OTksMGwwLC0yNS42NDk5OXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIwIiBmaWxsPSIjMzZjZmQxIiBkPSJtMTk4LjI4LDY0LjE5bDI1LjY0OTk5LDBsMCwyNS42NWwtMjUuNjQ5OTksMGwwLC0yNS42NXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIxIiBmaWxsPSIjNjI0YWZmIiBkPSJtMTUwLjQ0LDQybDAsMjIuMTlsMjUuNjQ5OTksMGwwLDI1LjY1bDIyLjE5LDBsMCwtNDcuODRsLTQ3Ljg0LDB6Ii8+CiAgPHBhdGggaWQ9InN2Z18yMiIgZmlsbD0iIzM2Y2ZkMSIgZD0ibTczLjQ5LDg5Ljg0bDI1LjY1LDBsMCwyNS42NDk5OWwtMjUuNjUsMGwwLC0yNS42NDk5OXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIzIiBmaWxsPSIjNjI0YWZmIiBkPSJtNDcuODQsNjQuMTlsMjUuNjUsMGwwLC0yMi4xOWwtNDcuODQsMGwwLDQ3Ljg0bDIyLjE5LDBsMCwtMjUuNjV6Ii8+CiAgPHBhdGggaWQ9InN2Z18yNCIgZmlsbD0iIzYyNGFmZiIgZD0ibTQ3Ljg0LDExNS40OWwtMjIuMTksMGwwLDQ3Ljg0bDQ3Ljg0LDBsMCwtMjIuMTlsLTI1LjY1LDBsMCwtMjUuNjV6Ii8+CiA8L2c+Cjwvc3ZnPg==&labelColor=white)](https://www.modelscope.cn/studios/OpenDataLab/MinerU)
[![HuggingFace](https://img.shields.io/badge/Demo_on_HuggingFace-yellow.svg?logo=data:image/png;base64,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&labelColor=white)](https://huggingface.co/spaces/opendatalab/MinerU)
[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/gist/myhloli/a3cb16570ab3cfeadf9d8f0ac91b4fca/mineru_demo.ipynb)
[![arXiv](https://img.shields.io/badge/MinerU-Technical%20Report-b31b1b.svg?logo=arXiv)](https://arxiv.org/abs/2409.18839)
[![arXiv](https://img.shields.io/badge/MinerU2.5-Technical%20Report-b31b1b.svg?logo=arXiv)](https://arxiv.org/abs/2509.22186)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/opendatalab/MinerU)
<a href="https://trendshift.io/repositories/11174" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11174" alt="opendatalab%2FMinerU | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<!-- language -->
[English](README.md) | [简体中文](README_zh-CN.md)
<!-- hot link -->
<p align="center">
🚀<a href="https://mineru.net/?source=github">MinerU 官网入口→✅ 免装在线版 ✅ 全功能客户端 ✅ 开发者API在线调用,省去部署麻烦,多种产品形态一键get,速冲!</a>
</p>
<!-- join us -->
<p align="center">
👋 join us on <a href="https://discord.gg/Tdedn9GTXq" target="_blank">Discord</a> and <a href="https://mineru.net/community-portal/?aliasId=3c430f94" target="_blank">WeChat</a>
</p>
</div>
# 更新记录
- 2025/12/02 2.6.6 发布
- `Ascend`适配优化
- 优化命令行工具初始化流程,使Ascend适配方案中`vlm-vllm-engine`后端在命令行工具中可用。
- 为Atlas 300I Duo(310p)设备更新适配文档。
- `mineru-api`工具优化
-`mineru-api`接口参数增加描述性文本,优化接口文档可读性。
- 可通过环境变量`MINERU_API_ENABLE_FASTAPI_DOCS`控制是否启用自动生成的接口文档页面,默认为启用。
-`vlm-vllm-async-engine``vlm-lmdeploy-engine``vlm-http-client`后端增加并发数配置选项,用户可通过环境变量`MINERU_API_MAX_CONCURRENT_REQUESTS`控制api接口的最大并发请求数,默认为不限制数量。
- 2025/11/26 2.6.5 发布
- 增加新后端`vlm-lmdeploy-engine`支持,使用方式与`vlm-vllm-(async)engine`类似,但使用`lmdeploy`作为推理引擎,与`vllm`相比额外支持Windows平台原生推理加速。
- 新增国产算力平台`昇腾/npu``平头哥/ppu``沐曦/maca`的适配支持,用户可在对应平台上使用`pipeline``vlm`模型,并使用`vllm`/`lmdeploy`引擎加速vlm模型推理,具体使用方式请参考[其他加速卡适配](https://opendatalab.github.io/MinerU/zh/usage/)。
- 国产平台适配不易,我们已尽量确保适配的完整性和稳定性,但仍可能存在一些稳定性/兼容问题与精度对齐问题,请大家根据适配文档页面内红绿灯情况自行选择合适的环境与场景进行使用。
- 如在使用国产化平台适配方案的过程中遇到任何文档未提及的问题,为便于其他用户查找解决方案,请在discussions的[指定帖子](https://github.com/opendatalab/MinerU/discussions/4064)中进行反馈。
- 2025/11/04 2.6.4 发布
- 为pdf渲染图片增加超时配置,默认为300秒,可通过环境变量`MINERU_PDF_RENDER_TIMEOUT`进行配置,防止部分异常pdf文件导致渲染过程长时间阻塞。
- 为onnx模型增加cpu线程数配置选项,默认为系统cpu核心数,可通过环境变量`MINERU_INTRA_OP_NUM_THREADS``MINERU_INTER_OP_NUM_THREADS`进行配置,以减少高并发场景下的对cpu资源的抢占冲突。
- 2025/10/31 2.6.3 发布
- 增加新后端`vlm-mlx-engine`支持,在Apple Silicon设备上支持使用`MLX`加速`MinerU2.5`模型推理,相比`vlm-transformers`后端,`vlm-mlx-engine`后端速度提升100%~200%。
- bug修复: #3849 #3859
- 2025/10/24 2.6.2 发布
- `pipline`后端优化
- 增加对中文公式的实验性支持,可通过配置环境变量`export MINERU_FORMULA_CH_SUPPORT=1`开启。该功能可能会导致MFR速率略微下降、部分长公式识别失败等问题,建议仅在需要解析中文公式的场景下开启。如需关闭该功能,可将环境变量设置为`0`
- `OCR`速度大幅提升200%~300%,感谢 [@cjsdurj](https://github.com/cjsdurj) 提供的优化方案
- `OCR`模型优化拉丁文识别的准度和广度,并更新西里尔文(cyrillic)、阿拉伯文(arabic)、天城文(devanagari)、泰卢固语(te)、泰米尔语(ta)语系至`ppocr-v5`版本,精度相比上代模型提升40%以上
- `vlm`后端优化
- `table_caption``table_footnote`匹配逻辑优化,提升页内多张连续表场景下的表格标题和脚注的匹配准确率和阅读顺序合理性
- 优化使用`vllm`后端时高并发时的cpu资源占用,降低服务端压力
- 适配`vllm`0.11.0版本
- 通用优化
- 跨页表格合并效果优化,新增跨页续表合并支持,提升在多列合并场景下的表格合并效果
- 为表格合并功能增加环境变量配置选项`MINERU_TABLE_MERGE_ENABLE`,表格合并功能默认开启,可通过设置该变量为`0`来关闭表格合并功能
- 2025/09/26 2.5.4 发布
- 🎉🎉 MinerU2.5[技术报告](https://arxiv.org/abs/2509.22186)现已发布,欢迎阅读全面了解其模型架构、训练策略、数据工程和评测结果。
- 修复部分`pdf`文件被识别成`ai`文件导致无法解析的问题
- 2025/09/20 2.5.3 发布
- 依赖版本范围调整,使得Turing及更早架构显卡可以使用vLLM加速推理MinerU2.5模型。
- `pipeline`后端对torch 2.8.0的一些兼容性修复。
- 降低vLLM异步后端默认的并发数,降低服务端压力以避免高压导致的链接关闭问题。
- 更多兼容性相关内容详见[公告](https://github.com/opendatalab/MinerU/discussions/3547)
- 2025/09/19 2.5.2 发布
我们正式发布 MinerU2.5,当前最强文档解析多模态大模型。仅凭 1.2B 参数,MinerU2.5 在 OmniDocBench 文档解析评测中,精度已全面超越 Gemini2.5-Pro、GPT-4o、Qwen2.5-VL-72B等顶级多模态大模型,并显著领先于主流文档解析专用模型(如 dots.ocr, MonkeyOCR, PP-StructureV3 等)。
模型已发布至[HuggingFace](https://huggingface.co/opendatalab/MinerU2.5-2509-1.2B)和[ModelScope](https://modelscope.cn/models/opendatalab/MinerU2.5-2509-1.2B)平台,欢迎大家下载使用!
- 核心亮点
- 极致能效,性能SOTA: 以 1.2B 的轻量化规模,实现了超越百亿乃至千亿级模型的SOTA性能,重新定义了文档解析的能效比。
- 先进架构,全面领先: 通过 “两阶段推理” (解耦布局分析与内容识别) 与 原生高分辨率架构 的结合,在布局分析、文本识别、公式识别、表格识别及阅读顺序五大方面均达到 SOTA 水平。
- 关键能力提升
- 布局检测: 结果更完整,精准覆盖页眉、页脚、页码等非正文内容;同时提供更精准的元素定位与更自然的格式还原(如列表、参考文献)。
- 表格解析: 大幅优化了对旋转表格、无线/少线表、以及长难表格的解析能力。
- 公式识别: 显著提升中英混合公式及复杂长公式的识别准确率,大幅改善数学类文档解析能力。
此外,伴随vlm 2.5的发布,我们对仓库做出一些调整:
- vlm后端升级至2.5版本,支持MinerU2.5模型,不再兼容MinerU2.0-2505-0.9B模型,最后一个支持2.0模型的版本为mineru-2.2.2。
- vlm推理相关代码已移至[mineru_vl_utils](https://github.com/opendatalab/mineru-vl-utils),降低与mineru主仓库的耦合度,便于后续独立迭代。
- vlm加速推理框架从`sglang`切换至`vllm`,并实现对vllm生态的完全兼容,使得用户可以在任何支持vllm框架的平台上使用MinerU2.5模型并加速推理。
- 由于vlm模型的重大升级,支持更多layout type,因此我们对解析的中间文件`middle.json`和结果文件`content_list.json`的结构做出一些调整,请参考[文档](https://opendatalab.github.io/MinerU/zh/reference/output_files/)了解详情。
其他仓库优化:
- 移除对输入文件的后缀名白名单校验,当输入文件为PDF文档或图片时,对文件的后缀名不再有要求,提升易用性。
<details>
<summary>历史日志</summary>
<details>
<summary>2025/09/10 2.2.2 发布</summary>
<ul>
<li>修复新的表格识别模型在部分表格解析失败时影响整体解析任务的问题</li>
</ul>
</details>
<details>
<summary>2025/09/08 2.2.1 发布</summary>
<ul>
<li>修复使用模型下载命令时,部分新增模型未下载的问题</li>
</ul>
</details>
<details>
<summary>2025/09/05 2.2.0 发布</summary>
<ul>
<li>
主要更新
<ul>
<li>在这个版本我们重点提升了表格的解析精度,通过引入新的<a href="https://github.com/RapidAI/TableStructureRec">有线表识别模型</a>和全新的混合表格结构解析算法,显著提升了<code>pipeline</code>后端的表格识别能力。</li>
<li>另外我们增加了对跨页表格合并的支持,这一功能同时支持<code>pipeline</code>和<code>vlm</code>后端,进一步提升了表格解析的完整性和准确性。</li>
</ul>
</li>
<li>
其他更新
<ul>
<li><code>pipeline</code>后端增加270度旋转的表格解析能力,现已支持0/90/270度三个方向的表格解析</li>
<li><code>pipeline</code>增加对泰文、希腊文的ocr能力支持,并更新了英文ocr模型至最新,英文识别精度提升11%,泰文识别模型精度 82.68%,希腊文识别模型精度 89.28%by PPOCRv5</li>
<li>在输出的<code>content_list.json</code>中增加了<code>bbox</code>字段(映射至0-1000范围内),方便用户直接获取每个内容块的位置信息</li>
<li>移除<code>pipeline_old_linux</code>安装可选项,不再支持老版本的Linux系统如<code>Centos 7</code>等,以便对<code>uv</code>的<code>sync</code>/<code>run</code>等命令进行更好的支持</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/08/01 2.1.10 发布</summary>
<ul>
<li>修复<code>pipeline</code>后端因block覆盖导致的解析结果与预期不符 #3232</li>
</ul>
</details>
<details>
<summary>2025/07/30 2.1.9 发布</summary>
<ul>
<li><code>transformers</code> 4.54.1 版本适配</li>
</ul>
</details>
<details>
<summary>2025/07/28 2.1.8 发布</summary>
<ul>
<li><code>sglang</code> 0.4.9.post5 版本适配</li>
</ul>
</details>
<details>
<summary>2025/07/27 2.1.7 发布</summary>
<ul>
<li><code>transformers</code> 4.54.0 版本适配</li>
</ul>
</details>
<details>
<summary>2025/07/26 2.1.6 发布</summary>
<ul>
<li>修复<code>vlm</code>后端解析部分手写文档时的表格异常问题</li>
<li>修复文档旋转时可视化框位置漂移问题 #3175</li>
</ul>
</details>
<details>
<summary>2025/07/24 2.1.5 发布</summary>
<ul>
<li><code>sglang</code> 0.4.9 版本适配,同步升级dockerfile基础镜像为sglang 0.4.9.post3</li>
</ul>
</details>
<details>
<summary>2025/07/23 2.1.4 发布</summary>
<ul>
<li><strong>bug修复</strong>
<ul>
<li>修复<code>pipeline</code>后端中<code>MFR</code>步骤在某些情况下显存消耗过大的问题 #2771</li>
<li>修复某些情况下<code>image</code>/<code>table</code>与<code>caption</code>/<code>footnote</code>匹配不准确的问题 #3129</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/07/16 2.1.1 发布</summary>
<ul>
<li><strong>bug修复</strong>
<ul>
<li>修复<code>pipeline</code>在某些情况可能发生的文本块内容丢失问题 #3005</li>
<li>修复<code>sglang-client</code>需要安装<code>torch</code>等不必要的包的问题 #2968</li>
<li>更新<code>dockerfile</code>以修复linux字体缺失导致的解析文本内容不完整问题 #2915</li>
</ul>
</li>
<li><strong>易用性更新</strong>
<ul>
<li>更新<code>compose.yaml</code>,便于用户直接启动<code>sglang-server</code>、<code>mineru-api</code>、<code>mineru-gradio</code>服务</li>
<li>启用全新的<a href="https://opendatalab.github.io/MinerU/zh/">在线文档站点</a>,简化readme,提供更好的文档体验</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/07/05 2.1.0 发布</summary>
<p>这是 MinerU 2 的第一个大版本更新,包含了大量新功能和改进,包含众多性能优化、体验优化和bug修复,具体更新内容如下:</p>
<ul>
<li><strong>性能优化:</strong>
<ul>
<li>大幅提升某些特定分辨率(长边2000像素左右)文档的预处理速度</li>
<li>大幅提升<code>pipeline</code>后端批量处理大量页数较少(&lt;10)文档时的后处理速度</li>
<li><code>pipeline</code>后端的layout分析速度提升约20%</li>
</ul>
</li>
<li><strong>体验优化:</strong>
<ul>
<li>内置开箱即用的<code>fastapi服务</code>和<code>gradio webui</code>,详细使用方法请参考<a href="https://opendatalab.github.io/MinerU/zh/usage/quick_usage/#apiwebuisglang-clientserver">文档</a></li>
<li><code>sglang</code>适配<code>0.4.8</code>版本,大幅降低<code>vlm-sglang</code>后端的显存要求,最低可在<code>8G显存</code>(Turing及以后架构)的显卡上运行</li>
<li>对所有命令增加<code>sglang</code>的参数透传,使得<code>sglang-engine</code>后端可以与<code>sglang-server</code>一致,接收<code>sglang</code>的所有参数</li>
<li>支持基于配置文件的功能扩展,包含<code>自定义公式标识符</code>、<code>开启标题分级功能</code>、<code>自定义本地模型目录</code>,详细使用方法请参考<a href="https://opendatalab.github.io/MinerU/zh/usage/quick_usage/#mineru_1">文档</a></li>
</ul>
</li>
<li><strong>新特性:</strong>
<ul>
<li><code>pipeline</code>后端更新 PP-OCRv5 多语种文本识别模型,支持法语、西班牙语、葡萄牙语、俄语、韩语等 37 种语言的文字识别,平均精度涨幅超30%。<a href="https://paddlepaddle.github.io/PaddleOCR/latest/version3.x/algorithm/PP-OCRv5/PP-OCRv5_multi_languages.html">详情</a></li>
<li><code>pipeline</code>后端增加对竖排文本的有限支持</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/06/20 2.0.6发布</summary>
<ul>
<li>修复<code>vlm</code>模式下,某些偶发的无效块内容导致解析中断问题</li>
<li>修复<code>vlm</code>模式下,某些不完整的表结构导致的解析中断问题</li>
</ul>
</details>
<details>
<summary>2025/06/17 2.0.5发布</summary>
<ul>
<li>修复了<code>sglang-client</code>模式下依然需要下载模型的问题</li>
<li>修复了<code>sglang-client</code>模式需要依赖<code>torch</code>等实际运行不需要的包的问题</li>
<li>修复了同一进程内尝试通过多个url启动多个<code>sglang-client</code>实例时,只有第一个生效的问题</li>
</ul>
</details>
<details>
<summary>2025/06/15 2.0.3发布</summary>
<ul>
<li>修复了当下载模型类型设置为<code>all</code>时,配置文件出现键值更新错误的问题</li>
<li>修复了命令行模式下公式和表格功能开关不生效导致功能无法关闭的问题</li>
<li>修复了<code>sglang-engine</code>模式下,0.4.7版本sglang的兼容性问题</li>
<li>更新了sglang环境下部署完整版MinerU的Dockerfile和相关安装文档</li>
</ul>
</details>
<details>
<summary>2025/06/13 2.0.0发布</summary>
<ul>
<li><strong>全新架构</strong>MinerU 2.0 在代码结构和交互方式上进行了深度重构,显著提升了系统的易用性、可维护性与扩展能力。
<ul>
<li><strong>去除第三方依赖限制</strong>:彻底移除对 <code>pymupdf</code> 的依赖,推动项目向更开放、合规的开源方向迈进。</li>
<li><strong>开箱即用,配置便捷</strong>:无需手动编辑 JSON 配置文件,绝大多数参数已支持命令行或 API 直接设置。</li>
<li><strong>模型自动管理</strong>:新增模型自动下载与更新机制,用户无需手动干预即可完成模型部署。</li>
<li><strong>离线部署友好</strong>:提供内置模型下载命令,支持完全断网环境下的部署需求。</li>
<li><strong>代码结构精简</strong>:移除数千行冗余代码,简化类继承逻辑,显著提升代码可读性与开发效率。</li>
<li><strong>统一中间格式输出</strong>:采用标准化的 <code>middle_json</code> 格式,兼容多数基于该格式的二次开发场景,确保生态业务无缝迁移。</li>
</ul>
</li>
<li><strong>全新模型</strong>MinerU 2.0 集成了我们最新研发的小参数量、高性能多模态文档解析模型,实现端到端的高速、高精度文档理解。
<ul>
<li><strong>小模型,大能力</strong>:模型参数不足 1B,却在解析精度上超越传统 72B 级别的视觉语言模型(VLM)。</li>
<li><strong>多功能合一</strong>:单模型覆盖多语言识别、手写识别、版面分析、表格解析、公式识别、阅读顺序排序等核心任务。</li>
<li><strong>极致推理速度</strong>:在单卡 NVIDIA 4090 上通过 <code>sglang</code> 加速,达到峰值吞吐量超过 10,000 token/s,轻松应对大规模文档处理需求。</li>
<li><strong>在线体验</strong>:您可以在<a href="https://mineru.net/OpenSourceTools/Extractor">MinerU.net</a>、<a href="https://huggingface.co/spaces/opendatalab/MinerU">Hugging Face</a>, 以及<a href="https://www.modelscope.cn/studios/OpenDataLab/MinerU">ModelScope</a>体验我们的全新VLM模型</li>
</ul>
</li>
<li><strong>不兼容变更说明</strong>:为提升整体架构合理性与长期可维护性,本版本包含部分不兼容的变更:
<ul>
<li>Python 包名从 <code>magic-pdf</code> 更改为 <code>mineru</code>,命令行工具也由 <code>magic-pdf</code> 改为 <code>mineru</code>,请同步更新脚本与调用命令。</li>
<li>出于对系统模块化设计与生态一致性的考虑,MinerU 2.0 已不再内置 LibreOffice 文档转换模块。如需处理 Office 文档,建议通过独立部署的 LibreOffice 服务先行转换为 PDF 格式,再进行后续解析操作。</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/05/24 1.3.12 发布</summary>
<ul>
<li>增加ppocrv5模型的支持,将<code>ch_server</code>模型更新为<code>PP-OCRv5_rec_server</code><code>ch_lite</code>模型更新为<code>PP-OCRv5_rec_mobile</code>(需更新模型)
<ul>
<li>在测试中,发现ppocrv5(server)对手写文档效果有一定提升,但在其余类别文档的精度略差于v4_server_doc,因此默认的ch模型保持不变,仍为<code>PP-OCRv4_server_rec_doc</code>。</li>
<li>由于ppocrv5强化了手写场景和特殊字符的识别能力,因此您可以在日繁混合场景以及手写文档场景下手动选择使用ppocrv5模型</li>
<li>您可通过lang参数<code>lang='ch_server'</code>(python api)或<code>--lang ch_server</code>(命令行)自行选择相应的模型:
<ul>
<li><code>ch</code> <code>PP-OCRv4_rec_server_doc</code>(默认)(中英日繁混合/1.5w字典)</li>
<li><code>ch_server</code> <code>PP-OCRv5_rec_server</code>(中英日繁混合+手写场景/1.8w字典)</li>
<li><code>ch_lite</code> <code>PP-OCRv5_rec_mobile</code>(中英日繁混合+手写场景/1.8w字典)</li>
<li><code>ch_server_v4</code> <code>PP-OCRv4_rec_server</code>(中英混合/6k字典)</li>
<li><code>ch_lite_v4</code> <code>PP-OCRv4_rec_mobile</code>(中英混合/6k字典)</li>
</ul>
</li>
</ul>
</li>
<li>增加手写文档的支持,通过优化layout对手写文本区域的识别,现已支持手写文档的解析
<ul>
<li>默认支持此功能,无需额外配置</li>
<li>可以参考上述说明,手动选择ppocrv5模型以获得更好的手写文档解析效果</li>
</ul>
</li>
<li><code>huggingface</code>和<code>modelscope</code>的demo已更新为支持手写识别和ppocrv5模型的版本,可自行在线体验</li>
</ul>
</details>
<details>
<summary>2025/04/29 1.3.10 发布</summary>
<ul>
<li>支持使用自定义公式标识符,可通过修改用户目录下的<code>magic-pdf.json</code>文件中的<code>latex-delimiter-config</code>项实现。</li>
</ul>
</details>
<details>
<summary>2025/04/27 1.3.9 发布</summary>
<ul>
<li>优化公式解析功能,提升公式渲染的成功率</li>
</ul>
</details>
<details>
<summary>2025/04/23 1.3.8 发布</summary>
<ul>
<li><code>ocr</code>默认模型(<code>ch</code>)更新为<code>PP-OCRv4_server_rec_doc</code>(需更新模型)
<ul>
<li><code>PP-OCRv4_server_rec_doc</code>是在<code>PP-OCRv4_server_rec</code>的基础上,在更多中文文档数据和PP-OCR训练数据的混合数据训练而成,增加了部分繁体字、日文、特殊字符的识别能力,可支持识别的字符为1.5万+,除文档相关的文字识别能力提升外,也同时提升了通用文字的识别能力。</li>
<li><a href="https://paddlepaddle.github.io/PaddleX/latest/module_usage/tutorials/ocr_modules/text_recognition.html#_3">PP-OCRv4_server_rec_doc/PP-OCRv4_server_rec/PP-OCRv4_mobile_rec 性能对比</a></li>
<li>经验证,<code>PP-OCRv4_server_rec_doc</code>模型在<code>中英日繁</code>单种语言或多种语言混合场景均有明显精度提升,且速度与<code>PP-OCRv4_server_rec</code>相当,适合绝大部分场景使用。</li>
<li><code>PP-OCRv4_server_rec_doc</code>在小部分纯英文场景可能会发生单词粘连问题,<code>PP-OCRv4_server_rec</code>则在此场景下表现更好,因此我们保留了<code>PP-OCRv4_server_rec</code>模型,用户可通过增加参数<code>lang='ch_server'</code>(python api)或<code>--lang ch_server</code>(命令行)调用。</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/04/22 1.3.7 发布</summary>
<ul>
<li>修复表格解析模型初始化时lang参数失效的问题</li>
<li>修复在<code>cpu</code>模式下ocr和表格解析速度大幅下降的问题</li>
</ul>
</details>
<details>
<summary>2025/04/16 1.3.4 发布</summary>
<ul>
<li>通过移除一些无用的块,小幅提升了ocr-det的速度</li>
<li>修复部分情况下由footnote导致的页面内排序错误</li>
</ul>
</details>
<details>
<summary>2025/04/12 1.3.2 发布</summary>
<ul>
<li>修复了windows系统下,在python3.13环境安装时一些依赖包版本不兼容的问题</li>
<li>优化批量推理时的内存占用</li>
<li>优化旋转90度表格的解析效果</li>
<li>优化财报样本中超大表格的解析效果</li>
<li>修复了在未指定OCR语言时,英文文本区域偶尔出现的单词黏连问题(需要更新模型)</li>
</ul>
</details>
<details>
<summary>2025/04/08 1.3.1 发布</summary>
<ul>
<li>修复了一些兼容问题
<ul>
<li>支持python 3.13</li>
<li>为部分过时的linux系统(如centos7)做出最后适配,并不再保证后续版本的继续支持,<a href="https://github.com/opendatalab/MinerU/issues/1004">安装说明</a></li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/04/03 1.3.0 发布</summary>
<ul>
<li>安装与兼容性优化
<ul>
<li>通过移除layout中<code>layoutlmv3</code>的使用,解决了由<code>detectron2</code>导致的兼容问题</li>
<li>torch版本兼容扩展到2.2~2.6(2.5除外)</li>
<li>cuda兼容支持11.8/12.4/12.6/12.8cuda版本由torch决定),解决部分用户50系显卡与H系显卡的兼容问题</li>
<li>python兼容版本扩展到3.10~3.12,解决了在非3.10环境下安装时自动降级到0.6.1的问题</li>
<li>优化离线部署流程,部署成功后不需要联网下载任何模型文件</li>
</ul>
</li>
<li>性能优化
<ul>
<li>通过支持多个pdf文件的batch处理(<a href="demo/batch_demo.py">脚本样例</a>),提升了批量小文件的解析速度 (与1.0.1版本相比,公式解析速度最高提升超过1400%,整体解析速度最高提升超过500%)</li>
<li>通过优化mfr模型的加载和使用,降低了显存占用并提升了解析速度(需重新执行<a href="docs/how_to_download_models_zh_cn.md">模型下载流程</a>以获得模型文件的增量更新)</li>
<li>优化显存占用,最低仅需6GB即可运行本项目</li>
<li>优化了在mps设备上的运行速度</li>
</ul>
</li>
<li>解析效果优化
<ul>
<li>mfr模型更新到<code>unimernet(2503)</code>,解决多行公式中换行丢失的问题</li>
</ul>
</li>
<li>易用性优化
<ul>
<li>通过使用<code>paddleocr2torch</code>,完全替代<code>paddle</code>框架以及<code>paddleocr</code>在项目中的使用,解决了<code>paddle</code>和<code>torch</code>的冲突问题,和由于<code>paddle</code>框架导致的线程不安全问题</li>
<li>解析过程增加实时进度条显示,精准把握解析进度,让等待不再痛苦</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/03/03 1.2.1 发布,修复了一些问题</summary>
<ul>
<li>修复在字母与数字的全角转半角操作时对标点符号的影响</li>
<li>修复在某些情况下caption的匹配不准确问题</li>
<li>修复在某些情况下的公式span丢失问题</li>
</ul>
</details>
<details>
<summary>2025/02/24 1.2.0 发布,这个版本我们修复了一些问题,提升了解析的效率与精度:</summary>
<ul>
<li>性能优化
<ul>
<li>auto模式下pdf文档的分类速度提升</li>
</ul>
</li>
<li>解析优化
<ul>
<li>优化对包含水印文档的解析逻辑,显著提升包含水印文档的解析效果</li>
<li>改进了单页内多个图像/表格与caption的匹配逻辑,提升了复杂布局下图文匹配的准确性</li>
</ul>
</li>
<li>问题修复
<ul>
<li>修复在某些情况下图片/表格span被填充进textblock导致的异常</li>
<li>修复在某些情况下标题block为空的问题</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/01/22 1.1.0 发布,在这个版本我们重点提升了解析的精度与效率:</summary>
<ul>
<li>模型能力升级(需重新执行 <a href="https://github.com/opendatalab/MinerU/docs/how_to_download_models_zh_cn.md">模型下载流程</a> 以获得模型文件的增量更新)
<ul>
<li>布局识别模型升级到最新的 `doclayout_yolo(2501)` 模型,提升了layout识别精度</li>
<li>公式解析模型升级到最新的 `unimernet(2501)` 模型,提升了公式识别精度</li>
</ul>
</li>
<li>性能优化
<ul>
<li>在配置满足一定条件(显存16GB+)的设备上,通过优化资源占用和重构处理流水线,整体解析速度提升50%以上</li>
</ul>
</li>
<li>解析效果优化
<ul>
<li>在线demo<a href="https://mineru.net/OpenSourceTools/Extractor">mineru.net</a> / <a href="https://huggingface.co/spaces/opendatalab/MinerU">huggingface</a> / <a href="https://www.modelscope.cn/studios/OpenDataLab/MinerU">modelscope</a>)上新增标题分级功能(测试版本,默认开启),支持对标题进行分级,提升文档结构化程度</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2025/01/10 1.0.1 发布,这是我们的第一个正式版本,在这个版本中,我们通过大量重构带来了全新的API接口和更广泛的兼容性,以及全新的自动语言识别功能:</summary>
<ul>
<li>全新API接口
<ul>
<li>对于数据侧API,我们引入了Dataset类,旨在提供一个强大而灵活的数据处理框架。该框架当前支持包括图像(.jpg及.png)、PDF、Word.doc及.docx)、以及PowerPoint.ppt及.pptx)在内的多种文档格式,确保了从简单到复杂的数据处理任务都能得到有效的支持。</li>
<li>针对用户侧API,我们将MinerU的处理流程精心设计为一系列可组合的Stage阶段。每个Stage代表了一个特定的处理步骤,用户可以根据自身需求自由地定义新的Stage,并通过创造性地组合这些阶段来定制专属的数据处理流程。</li>
</ul>
</li>
<li>更广泛的兼容性适配
<ul>
<li>通过优化依赖环境和配置项,确保在ARM架构的Linux系统上能够稳定高效运行。</li>
<li>深度适配华为昇腾NPU加速,积极响应信创要求,提供自主可控的高性能计算能力,助力人工智能应用平台的国产化应用与发展。 <a href="https://github.com/opendatalab/MinerU/docs/README_Ascend_NPU_Acceleration_zh_CN.md">NPU加速教程</a></li>
</ul>
</li>
<li>自动语言识别
<ul>
<li>通过引入全新的语言识别模型, 在文档解析中将 `lang` 配置为 `auto`,即可自动选择合适的OCR语言模型,提升扫描类文档解析的准确性。</li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2024/11/22 0.10.0发布,通过引入混合OCR文本提取能力,</summary>
<ul>
<li>在公式密集、span区域不规范、部分文本使用图像表现等复杂文本分布场景下获得解析效果的显著提升</li>
<li>同时具备文本模式内容提取准确、速度更快与OCR模式span/line区域识别更准的双重优势</li>
</ul>
</details>
<details>
<summary>2024/11/15 0.9.3发布,为表格识别功能接入了<a href="https://github.com/RapidAI/RapidTable">RapidTable</a>,单表解析速度提升10倍以上,准确率更高,显存占用更低</summary>
</details>
<details>
<summary>2024/11/06 0.9.2发布,为表格识别功能接入了<a href="https://huggingface.co/U4R/StructTable-InternVL2-1B">StructTable-InternVL2-1B</a>模型</summary>
</details>
<details>
<summary>2024/10/31 0.9.0发布,这是我们进行了大量代码重构的全新版本,解决了众多问题,提升了性能,降低了硬件需求,并提供了更丰富的易用性:</summary>
<ul>
<li>重构排序模块代码,使用 <a href="https://github.com/ppaanngggg/layoutreader">layoutreader</a> 进行阅读顺序排序,确保在各种排版下都能实现极高准确率</li>
<li>重构段落拼接模块,在跨栏、跨页、跨图、跨表情况下均能实现良好的段落拼接效果</li>
<li>重构列表和目录识别功能,极大提升列表块和目录块识别的准确率及对应文本段落的解析效果</li>
<li>重构图、表与描述性文本的匹配逻辑,大幅提升 caption 和 footnote 与图表的匹配准确率,并将描述性文本的丢失率降至接近0</li>
<li>增加 OCR 的多语言支持,支持 84 种语言的检测与识别,语言支持列表详见 <a href="https://paddlepaddle.github.io/PaddleOCR/latest/ppocr/blog/multi_languages.html#5">OCR 语言支持列表</a></li>
<li>增加显存回收逻辑及其他显存优化措施,大幅降低显存使用需求。开启除表格加速外的全部加速功能(layout/公式/OCR)的显存需求从16GB降至8GB,开启全部加速功能的显存需求从24GB降至10GB</li>
<li>优化配置文件的功能开关,增加独立的公式检测开关,无需公式检测时可大幅提升速度和解析效果</li>
<li>集成 <a href="https://github.com/opendatalab/PDF-Extract-Kit">PDF-Extract-Kit 1.0</a>
<ul>
<li>加入自研的 `doclayout_yolo` 模型,在相近解析效果情况下比原方案提速10倍以上,可通过配置文件与 `layoutlmv3` 自由切换</li>
<li>公式解析升级至 `unimernet 0.2.1`,在提升公式解析准确率的同时,大幅降低显存需求</li>
<li>因 `PDF-Extract-Kit 1.0` 更换仓库,需要重新下载模型,步骤详见 <a href="https://github.com/opendatalab/MinerU/docs/how_to_download_models_zh_cn.md">如何下载模型</a></li>
</ul>
</li>
</ul>
</details>
<details>
<summary>2024/09/27 0.8.1发布,修复了一些bug,同时提供了<a href="https://opendatalab.com/OpenSourceTools/Extractor/PDF/">在线demo</a>的<a href="https://github.com/opendatalab/MinerU/projects/web_demo/README_zh-CN.md">本地化部署版本</a>和<a href="https://github.com/opendatalab/MinerU/projects/web/README_zh-CN.md">前端界面</a></summary>
</details>
<details>
<summary>2024/09/09 0.8.0发布,支持Dockerfile快速部署,同时上线了huggingface、modelscope demo</summary>
</details>
<details>
<summary>2024/08/30 0.7.1发布,集成了paddle tablemaster表格识别功能</summary>
</details>
<details>
<summary>2024/08/09 0.7.0b1发布,简化安装步骤提升易用性,加入表格识别功能</summary>
</details>
<details>
<summary>2024/08/01 0.6.2b1发布,优化了依赖冲突问题和安装文档</summary>
</details>
<details>
<summary>2024/07/05 首次开源</summary>
</details>
</details>
# MinerU
## 项目简介
MinerU是一款将PDF转化为机器可读格式的工具(如markdown、json),可以很方便地抽取为任意格式。
MinerU诞生于[书生-浦语](https://github.com/InternLM/InternLM)的预训练过程中,我们将会集中精力解决科技文献中的符号转化问题,希望在大模型时代为科技发展做出贡献。
相比国内外知名商用产品MinerU还很年轻,如果遇到问题或者结果不及预期请到[issue](https://github.com/opendatalab/MinerU/issues)提交问题,同时**附上相关PDF**。
https://github.com/user-attachments/assets/4bea02c9-6d54-4cd6-97ed-dff14340982c
## 主要功能
- 删除页眉、页脚、脚注、页码等元素,确保语义连贯
- 输出符合人类阅读顺序的文本,适用于单栏、多栏及复杂排版
- 保留原文档的结构,包括标题、段落、列表等
- 提取图像、图片描述、表格、表格标题及脚注
- 自动识别并转换文档中的公式为LaTeX格式
- 自动识别并转换文档中的表格为HTML格式
- 自动检测扫描版PDF和乱码PDF,并启用OCR功能
- OCR支持109种语言的检测与识别
- 支持多种输出格式,如多模态与NLP的Markdown、按阅读顺序排序的JSON、含有丰富信息的中间格式等
- 支持多种可视化结果,包括layout可视化、span可视化等,便于高效确认输出效果与质检
- 支持纯CPU环境运行,并支持 GPU(CUDA)/NPU(CANN)/MPS 加速
- 兼容Windows、Linux和Mac平台
# 快速开始
如果安装或使用中遇到任何问题,请先查询 <a href="#faq">FAQ</a> </br>
如果遇到解析效果不及预期,参考 <a href="#known-issues">Known Issues</a></br>
## 在线体验
### 官网在线应用
官网在线版功能与客户端一致,界面美观,功能丰富,需要登录使用
- [![OpenDataLab](https://img.shields.io/badge/webapp_on_mineru.net-blue?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTM0IiBoZWlnaHQ9IjEzNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtMTIyLDljMCw1LTQsOS05LDlzLTktNC05LTksNC05LDktOSw5LDQsOSw5eiIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Im0xMjIsOWMwLDUtNCw5LTksOXMtOS00LTktOSw0LTksOS05LDksNCw5LDl6IiBmaWxsPSIjMDEwMTAxIi8+PHBhdGggZD0ibTkxLDE4YzAsNS00LDktOSw5cy05LTQtOS05LDQtOSw5LTksOSw0LDksOXoiIGZpbGw9InVybCgjYikiLz48cGF0aCBkPSJtOTEsMThjMCw1LTQsOS05LDlzLTktNC05LTksNC05LDktOSw5LDQsOSw5eiIgZmlsbD0iIzAxMDEwMSIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtMzksNjJjMCwxNiw4LDMwLDIwLDM4LDctNiwxMi0xNiwxMi0yNlY0OWMwLTQsMy03LDYtOGw0Ni0xMmM1LTEsMTEsMywxMSw4djMxYzAsMzctMzAsNjYtNjYsNjYtMzcsMC02Ni0zMC02Ni02NlY0NmMwLTQsMy03LDYtOGwyMC02YzUtMSwxMSwzLDExLDh2MjF6bS0yOSw2YzAsMTYsNiwzMCwxNyw0MCwzLDEsNSwxLDgsMSw1LDAsMTAtMSwxNS0zQzM3LDk1LDI5LDc5LDI5LDYyVjQybC0xOSw1djIweiIgZmlsbD0idXJsKCNjKSIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtMzksNjJjMCwxNiw4LDMwLDIwLDM4LDctNiwxMi0xNiwxMi0yNlY0OWMwLTQsMy03LDYtOGw0Ni0xMmM1LTEsMTEsMywxMSw4djMxYzAsMzctMzAsNjYtNjYsNjYtMzcsMC02Ni0zMC02Ni02NlY0NmMwLTQsMy03LDYtOGwyMC02YzUtMSwxMSwzLDExLDh2MjF6bS0yOSw2YzAsMTYsNiwzMCwxNyw0MCwzLDEsNSwxLDgsMSw1LDAsMTAtMSwxNS0zQzM3LDk1LDI5LDc5LDI5LDYyVjQybC0xOSw1djIweiIgZmlsbD0iIzAxMDEwMSIvPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iYSIgeDE9Ijg0IiB5MT0iNDEiIHgyPSI3NSIgeTI9IjEyMCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMyZTJlMmUiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9Ijg0IiB5MT0iNDEiIHgyPSI3NSIgeTI9IjEyMCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMyZTJlMmUiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYyIgeDE9Ijg0IiB5MT0iNDEiIHgyPSI3NSIgeTI9IjEyMCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMyZTJlMmUiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=&labelColor=white)](https://mineru.net/OpenSourceTools/Extractor?source=github)
### 基于Gradio的在线demo
基于gradio开发的webui,界面简洁,仅包含核心解析功能,免登录
- [![ModelScope](https://img.shields.io/badge/Demo_on_ModelScope-purple?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjIzIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggaWQ9InN2Z18xNCIgZmlsbD0iIzYyNGFmZiIgZD0ibTAsODkuODRsMjUuNjUsMGwwLDI1LjY0OTk5bC0yNS42NSwwbDAsLTI1LjY0OTk5eiIvPgogIDxwYXRoIGlkPSJzdmdfMTUiIGZpbGw9IiM2MjRhZmYiIGQ9Im05OS4xNCwxMTUuNDlsMjUuNjUsMGwwLDI1LjY1bC0yNS42NSwwbDAsLTI1LjY1eiIvPgogIDxwYXRoIGlkPSJzdmdfMTYiIGZpbGw9IiM2MjRhZmYiIGQ9Im0xNzYuMDksMTQxLjE0bC0yNS42NDk5OSwwbDAsMjIuMTlsNDcuODQsMGwwLC00Ny44NGwtMjIuMTksMGwwLDI1LjY1eiIvPgogIDxwYXRoIGlkPSJzdmdfMTciIGZpbGw9IiMzNmNmZDEiIGQ9Im0xMjQuNzksODkuODRsMjUuNjUsMGwwLDI1LjY0OTk5bC0yNS42NSwwbDAsLTI1LjY0OTk5eiIvPgogIDxwYXRoIGlkPSJzdmdfMTgiIGZpbGw9IiMzNmNmZDEiIGQ9Im0wLDY0LjE5bDI1LjY1LDBsMCwyNS42NWwtMjUuNjUsMGwwLC0yNS42NXoiLz4KICA8cGF0aCBpZD0ic3ZnXzE5IiBmaWxsPSIjNjI0YWZmIiBkPSJtMTk4LjI4LDg5Ljg0bDI1LjY0OTk5LDBsMCwyNS42NDk5OWwtMjUuNjQ5OTksMGwwLC0yNS42NDk5OXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIwIiBmaWxsPSIjMzZjZmQxIiBkPSJtMTk4LjI4LDY0LjE5bDI1LjY0OTk5LDBsMCwyNS42NWwtMjUuNjQ5OTksMGwwLC0yNS42NXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIxIiBmaWxsPSIjNjI0YWZmIiBkPSJtMTUwLjQ0LDQybDAsMjIuMTlsMjUuNjQ5OTksMGwwLDI1LjY1bDIyLjE5LDBsMCwtNDcuODRsLTQ3Ljg0LDB6Ii8+CiAgPHBhdGggaWQ9InN2Z18yMiIgZmlsbD0iIzM2Y2ZkMSIgZD0ibTczLjQ5LDg5Ljg0bDI1LjY1LDBsMCwyNS42NDk5OWwtMjUuNjUsMGwwLC0yNS42NDk5OXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIzIiBmaWxsPSIjNjI0YWZmIiBkPSJtNDcuODQsNjQuMTlsMjUuNjUsMGwwLC0yMi4xOWwtNDcuODQsMGwwLDQ3Ljg0bDIyLjE5LDBsMCwtMjUuNjV6Ii8+CiAgPHBhdGggaWQ9InN2Z18yNCIgZmlsbD0iIzYyNGFmZiIgZD0ibTQ3Ljg0LDExNS40OWwtMjIuMTksMGwwLDQ3Ljg0bDQ3Ljg0LDBsMCwtMjIuMTlsLTI1LjY1LDBsMCwtMjUuNjV6Ii8+CiA8L2c+Cjwvc3ZnPg==&labelColor=white)](https://www.modelscope.cn/studios/OpenDataLab/MinerU)
- [![HuggingFace](https://img.shields.io/badge/Demo_on_HuggingFace-yellow.svg?logo=data:image/png;base64,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&labelColor=white)](https://huggingface.co/spaces/opendatalab/MinerU)
## 本地部署
> [!WARNING]
> **安装前必看——软硬件环境支持说明**
>
> 为了确保项目的稳定性和可靠性,我们在开发过程中仅对特定的软硬件环境进行优化和测试。这样当用户在推荐的系统配置上部署和运行项目时,能够获得最佳的性能表现和最少的兼容性问题。
>
> 通过集中资源和精力于主线环境,我们团队能够更高效地解决潜在的BUG,及时开发新功能。
>
> 在非主线环境中,由于硬件、软件配置的多样性,以及第三方依赖项的兼容性问题,我们无法100%保证项目的完全可用性。因此,对于希望在非推荐环境中使用本项目的用户,我们建议先仔细阅读文档以及FAQ,大多数问题已经在FAQ中有对应的解决方案,除此之外我们鼓励社区反馈问题,以便我们能够逐步扩大支持范围。
<table>
<thead>
<tr>
<th rowspan="2">解析后端</th>
<th rowspan="2">pipeline <br> (精度<sup>1</sup> 82+)</th>
<th colspan="5">vlm (精度<sup>1</sup> 90+)</th>
</tr>
<tr>
<th>transformers</th>
<th>mlx-engine</th>
<th>vllm-engine / <br>vllm-async-engine</th>
<th>lmdeploy-engine</th>
<th>http-client</th>
</tr>
</thead>
<tbody>
<tr>
<th>后端特性</th>
<td>速度快, 无幻觉</td>
<td>兼容性好, 速度较慢</td>
<td>比transformers快</td>
<td>速度快, 兼容vllm生态</td>
<td>速度快, 兼容lmdeploy生态</td>
<td>适用于OpenAI兼容服务器<sup>6</sup></td>
</tr>
<tr>
<th>操作系统</th>
<td colspan="2" style="text-align:center;">Linux<sup>2</sup> / Windows / macOS</td>
<td style="text-align:center;">macOS<sup>3</sup></td>
<td style="text-align:center;">Linux<sup>2</sup> / Windows<sup>4</sup> </td>
<td style="text-align:center;">Linux<sup>2</sup> / Windows<sup>5</sup> </td>
<td>不限</td>
</tr>
<tr>
<th>CPU推理支持</th>
<td colspan="2" style="text-align:center;">✅</td>
<td colspan="3" style="text-align:center;">❌</td>
<td >不需要</td>
</tr>
<tr>
<th>GPU要求</th><td colspan="2" style="text-align:center;">Volta及以后架构, 6G显存以上或Apple Silicon</td>
<td>Apple Silicon</td>
<td colspan="2" style="text-align:center;">Volta及以后架构, 8G显存以上</td>
<td>不需要</td>
</tr>
<tr>
<th>内存要求</th>
<td colspan="5" style="text-align:center;">最低16GB以上, 推荐32GB以上</td>
<td>8GB</td>
</tr>
<tr>
<th>磁盘空间要求</th>
<td colspan="5" style="text-align:center;">20GB以上, 推荐使用SSD</td>
<td>2GB</td>
</tr>
<tr>
<th>python版本</th>
<td colspan="6" style="text-align:center;">3.10-3.13<sup>7</sup></td>
</tr>
</tbody>
</table>
<sup>1</sup> 精度指标为OmniDocBench (v1.5)的End-to-End Evaluation Overall分数,基于`MinerU`最新版本测试
<sup>2</sup> Linux仅支持2019年及以后发行版
<sup>3</sup> MLX需macOS 13.5及以上版本支持,推荐14.0以上版本使用
<sup>4</sup> Windows vLLM通过WSL2(适用于 Linux 的 Windows 子系统)实现支持
<sup>5</sup> Windows LMDeploy只能使用`turbomind`后端,速度比`pytorch`后端稍慢,如对速度有要求建议通过WSL2运行
<sup>6</sup> 兼容OpenAI API的服务器,如通过`vLLM`/`SGLang`/`LMDeploy`等推理框架部署的本地模型服务器或远程模型服务
<sup>7</sup> Windows + LMDeploy 由于关键依赖`ray`未能在windows平台支持Python 3.13,故仅支持至3.10~3.12版本
> [!TIP]
> 除以上主流环境与平台外,我们也收录了一些社区用户反馈的其他平台支持情况,详情请参考[其他加速卡适配](https://opendatalab.github.io/MinerU/zh/usage/)。
> 如果您有意将自己的环境适配经验分享给社区,欢迎通过[show-and-tell](https://github.com/opendatalab/MinerU/discussions/categories/show-and-tell)提交或提交PR至[其他加速卡适配](https://github.com/opendatalab/MinerU/tree/master/docs/zh/usage/acceleration_cards)文档。
### 安装 MinerU
#### 使用pip或uv安装MinerU
```bash
pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple
pip install uv -i https://mirrors.aliyun.com/pypi/simple
uv pip install -U "mineru[core]" -i https://mirrors.aliyun.com/pypi/simple
```
#### 通过源码安装MinerU
```bash
git clone https://github.com/opendatalab/MinerU.git
cd MinerU
uv pip install -e .[core] -i https://mirrors.aliyun.com/pypi/simple
```
> [!TIP]
> `mineru[core]`包含除`vLLM`/`LMDeploy`加速外的所有核心功能,兼容Windows / Linux / macOS系统,适合绝大多数用户。
> 如果您需要使用`vLLM`/`LMDeploy`加速VLM模型推理,或是有在边缘设备安装轻量版client端等需求,可以参考文档[扩展模块安装指南](https://opendatalab.github.io/MinerU/zh/quick_start/extension_modules/)。
---
#### 使用docker部署Mineru
MinerU提供了便捷的docker部署方式,这有助于快速搭建环境并解决一些棘手的环境兼容问题。
您可以在文档中获取[Docker部署说明](https://opendatalab.github.io/MinerU/zh/quick_start/docker_deployment/)。
---
### 使用 MinerU
最简单的命令行调用方式:
```bash
mineru -p <input_path> -o <output_path>
```
您可以通过命令行、API、WebUI等多种方式使用MinerU进行PDF解析,具体使用方法请参考[使用指南](https://opendatalab.github.io/MinerU/zh/usage/)。
# TODO
- [x] 基于模型的阅读顺序
- [x] 正文中目录、列表识别
- [x] 表格识别
- [x] 标题分级
- [x] 手写文本识别
- [x] 竖排文本识别
- [x] 拉丁字母重音符号识别
- [x] 正文中代码块识别
- [x] [化学式识别](docs/chemical_knowledge_introduction/introduction.pdf)(https://mineru.net)
- [ ] 图表内容识别
# Known Issues
- 阅读顺序基于模型对可阅读内容在空间中的分布进行排序,在极端复杂的排版下可能会部分区域乱序
- 对竖排文字的支持较为有限
- 目录和列表通过规则进行识别,少部分不常见的列表形式可能无法识别
- 代码块在layout模型里还没有支持
- 漫画书、艺术图册、小学教材、习题尚不能很好解析
- 表格识别在复杂表格上可能会出现行/列识别错误
- 在小语种PDF上,OCR识别可能会出现字符不准确的情况(如阿拉伯文易混淆字符等)
- 部分公式可能会无法在markdown中渲染
# FAQ
- 如果您在使用过程中遇到问题,可以先查看[常见问题](https://opendatalab.github.io/MinerU/zh/faq/)是否有解答。
- 如果未能解决您的问题,您也可以使用[DeepWiki](https://deepwiki.com/opendatalab/MinerU)与AI助手交流,这可以解决大部分常见问题。
- 如果您仍然无法解决问题,您可通过[Discord](https://discord.gg/Tdedn9GTXq)或[WeChat](https://mineru.net/community-portal/?aliasId=3c430f94)加入社区,与其他用户和开发者交流。
# All Thanks To Our Contributors
<a href="https://github.com/opendatalab/MinerU/graphs/contributors">
<img src="https://contrib.rocks/image?repo=opendatalab/MinerU" />
</a>
# License Information
[LICENSE.md](LICENSE.md)
本项目目前部分模型基于YOLO训练,但因其遵循AGPL协议,可能对某些使用场景构成限制。未来版本迭代中,我们计划探索并替换为许可条款更为宽松的模型,以提升用户友好度及灵活性。
# Acknowledgments
- [PDF-Extract-Kit](https://github.com/opendatalab/PDF-Extract-Kit)
- [DocLayout-YOLO](https://github.com/opendatalab/DocLayout-YOLO)
- [UniMERNet](https://github.com/opendatalab/UniMERNet)
- [RapidTable](https://github.com/RapidAI/RapidTable)
- [TableStructureRec](https://github.com/RapidAI/TableStructureRec)
- [PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)
- [PaddleOCR2Pytorch](https://github.com/frotms/PaddleOCR2Pytorch)
- [layoutreader](https://github.com/ppaanngggg/layoutreader)
- [xy-cut](https://github.com/Sanster/xy-cut)
- [fast-langdetect](https://github.com/LlmKira/fast-langdetect)
- [pypdfium2](https://github.com/pypdfium2-team/pypdfium2)
- [pdftext](https://github.com/datalab-to/pdftext)
- [pdfminer.six](https://github.com/pdfminer/pdfminer.six)
- [pypdf](https://github.com/py-pdf/pypdf)
- [magika](https://github.com/google/magika)
- [vLLM](https://github.com/vllm-project/vllm)
- [LMDeploy](https://github.com/InternLM/lmdeploy)
# Citation
```bibtex
@misc{niu2025mineru25decoupledvisionlanguagemodel,
title={MinerU2.5: A Decoupled Vision-Language Model for Efficient High-Resolution Document Parsing},
author={Junbo Niu and Zheng Liu and Zhuangcheng Gu and Bin Wang and Linke Ouyang and Zhiyuan Zhao and Tao Chu and Tianyao He and Fan Wu and Qintong Zhang and Zhenjiang Jin and Guang Liang and Rui Zhang and Wenzheng Zhang and Yuan Qu and Zhifei Ren and Yuefeng Sun and Yuanhong Zheng and Dongsheng Ma and Zirui Tang and Boyu Niu and Ziyang Miao and Hejun Dong and Siyi Qian and Junyuan Zhang and Jingzhou Chen and Fangdong Wang and Xiaomeng Zhao and Liqun Wei and Wei Li and Shasha Wang and Ruiliang Xu and Yuanyuan Cao and Lu Chen and Qianqian Wu and Huaiyu Gu and Lindong Lu and Keming Wang and Dechen Lin and Guanlin Shen and Xuanhe Zhou and Linfeng Zhang and Yuhang Zang and Xiaoyi Dong and Jiaqi Wang and Bo Zhang and Lei Bai and Pei Chu and Weijia Li and Jiang Wu and Lijun Wu and Zhenxiang Li and Guangyu Wang and Zhongying Tu and Chao Xu and Kai Chen and Yu Qiao and Bowen Zhou and Dahua Lin and Wentao Zhang and Conghui He},
year={2025},
eprint={2509.22186},
archivePrefix={arXiv},
primaryClass={cs.CV},
url={https://arxiv.org/abs/2509.22186},
}
@misc{wang2024mineruopensourcesolutionprecise,
title={MinerU: An Open-Source Solution for Precise Document Content Extraction},
author={Bin Wang and Chao Xu and Xiaomeng Zhao and Linke Ouyang and Fan Wu and Zhiyuan Zhao and Rui Xu and Kaiwen Liu and Yuan Qu and Fukai Shang and Bo Zhang and Liqun Wei and Zhihao Sui and Wei Li and Botian Shi and Yu Qiao and Dahua Lin and Conghui He},
year={2024},
eprint={2409.18839},
archivePrefix={arXiv},
primaryClass={cs.CV},
url={https://arxiv.org/abs/2409.18839},
}
@article{he2024opendatalab,
title={Opendatalab: Empowering general artificial intelligence with open datasets},
author={He, Conghui and Li, Wei and Jin, Zhenjiang and Xu, Chao and Wang, Bin and Lin, Dahua},
journal={arXiv preprint arXiv:2407.13773},
year={2024}
}
```
# Star History
<a>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=opendatalab/MinerU&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=opendatalab/MinerU&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=opendatalab/MinerU&type=Date" />
</picture>
</a>
# Links
- [Easy Data Preparation with latest LLMs-based Operators and Pipelines](https://github.com/OpenDCAI/DataFlow)
- [Vis3 (OSS browser based on s3)](https://github.com/opendatalab/Vis3)
- [LabelU (A Lightweight Multi-modal Data Annotation Tool)](https://github.com/opendatalab/labelU)
- [LabelLLM (An Open-source LLM Dialogue Annotation Platform)](https://github.com/opendatalab/LabelLLM)
- [PDF-Extract-Kit (A Comprehensive Toolkit for High-Quality PDF Content Extraction)](https://github.com/opendatalab/PDF-Extract-Kit)
- [OmniDocBench (A Comprehensive Benchmark for Document Parsing and Evaluation)](https://github.com/opendatalab/OmniDocBench)
- [Magic-HTML (Mixed web page extraction tool)](https://github.com/opendatalab/magic-html)
- [Magic-Doc (Fast speed ppt/pptx/doc/docx/pdf extraction tool)](https://github.com/InternLM/magic-doc)
- [Dingo: A Comprehensive AI Data Quality Evaluation Tool](https://github.com/MigoXLab/dingo)
+41
View File
@@ -0,0 +1,41 @@
INFO: Started server process [27428]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:18888 (Press CTRL+C to quit)
2025-12-06 09:33:35.916 | WARNING | mineru.utils.pdf_page_id:get_end_page_id:8 - end_page_id is out of range, use images length
2025-12-06 09:33:39.424 | INFO | mineru.backend.pipeline.pipeline_analyze:doc_analyze:128 - Batch 1/1: 1 pages/1 pages
2025-12-06 09:33:39.425 | INFO | mineru.backend.pipeline.model_init:__init__:208 - DocAnalysis init, this may take some times......
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 7 files: 0%| | 0/7 [00:00<?, ?it/s]␍Fetching 7 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 7/7 [00:00<00:00, 7002.18it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<00:00, 1906.50it/s]
2025-12-06 09:33:51.886 | INFO | mineru.backend.pipeline.model_init:__init__:270 - DocAnalysis init done!
2025-12-06 09:33:51.887 | INFO | mineru.backend.pipeline.pipeline_analyze:custom_model_init:65 - model init cost: 12.46150279045105
␍Layout Predict: 0%| | 0/1 [00:00<?, ?it/s]␍Layout Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<00:00, 1.38it/s]␍Layout Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<00:00, 1.38it/s]
␍MFD Predict: 0%| | 0/1 [00:00<?, ?it/s]␍MFD Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:01<00:00, 1.71s/it]␍MFD Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:01<00:00, 1.71s/it]
␍MFR Predict: 0%| | 0/2 [00:00<?, ?it/s]␍MFR Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 2/2 [00:02<00:00, 1.05s/it]␍MFR Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 2/2 [00:02<00:00, 1.05s/it]
␍Table-ocr det: 0%| | 0/2 [00:00<?, ?it/s]␍Table-ocr det: 50%|¨€¨€¨€¨€¨€ | 1/2 [00:00<00:00, 7.60it/s]␍Table-ocr det: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 2/2 [00:00<00:00, 9.29it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Table-ocr rec ch: 0%| | 0/57 [00:00<?, ?it/s]␍Table-ocr rec ch: 11%|¨€ | 6/57 [00:00<00:01, 47.73it/s]␍Table-ocr rec ch: 21%|¨€¨€ | 12/57 [00:00<00:00, 48.10it/s]␍Table-ocr rec ch: 32%|¨€¨€¨€¨‡ | 18/57 [00:00<00:00, 46.86it/s]␍Table-ocr rec ch: 42%|¨€¨€¨€¨€¨‡ | 24/57 [00:00<00:00, 48.78it/s]␍Table-ocr rec ch: 53%|¨€¨€¨€¨€¨€¨† | 30/57 [00:00<00:00, 48.16it/s]␍Table-ocr rec ch: 63%|¨€¨€¨€¨€¨€¨€¨† | 36/57 [00:00<00:00, 49.85it/s]␍Table-ocr rec ch: 74%|¨€¨€¨€¨€¨€¨€¨€¨† | 42/57 [00:00<00:00, 50.15it/s]␍Table-ocr rec ch: 84%|¨€¨€¨€¨€¨€¨€¨€¨€¨… | 48/57 [00:00<00:00, 49.20it/s]␍Table-ocr rec ch: 95%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨…| 54/57 [00:01<00:00, 46.71it/s]␍Table-ocr rec ch: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 57/57 [00:01<00:00, 47.25it/s]
␍Table-wireless Predict: 0%| | 0/2 [00:00<?, ?it/s]␍Table-wireless Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 2/2 [00:00<00:00, 3.91it/s]␍Table-wireless Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 2/2 [00:00<00:00, 3.91it/s]
␍Table-wired Predict: 0%| | 0/2 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<00:00, 1782.53it/s]
␍Table-wired Predict: 50%|¨€¨€¨€¨€¨€ | 1/2 [00:01<00:01, 1.33s/it]␍Table-wired Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 2/2 [00:01<00:00, 1.31it/s]␍Table-wired Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 2/2 [00:01<00:00, 1.18it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍Fetching 1 files: 0%| | 0/1 [00:00<?, ?it/s]␍Fetching 1 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<?, ?it/s]
␍OCR-det ch: 0%| | 0/8 [00:00<?, ?it/s]␍OCR-det ch: 25%|¨€¨€¨„ | 2/8 [00:00<00:00, 17.29it/s]␍OCR-det ch: 75%|¨€¨€¨€¨€¨€¨€¨€¨„ | 6/8 [00:00<00:00, 24.98it/s]␍OCR-det ch: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 8/8 [00:00<00:00, 24.41it/s]
␍OCR-rec Predict: 0%| | 0/11 [00:00<?, ?it/s]␍OCR-rec Predict: 55%|¨€¨€¨€¨€¨€¨… | 6/11 [00:00<00:00, 45.90it/s]␍OCR-rec Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 11/11 [00:00<00:00, 35.59it/s]␍OCR-rec Predict: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 11/11 [00:00<00:00, 36.95it/s]
␍Processing pages: 0%| | 0/1 [00:00<?, ?it/s]
␍Fetching 2 files: 0%| | 0/2 [00:00<?, ?it/s]␍Fetching 2 files: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 2/2 [00:00<?, ?it/s]
␍Processing pages: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<00:00, 1.19it/s]␍Processing pages: 100%|¨€¨€¨€¨€¨€¨€¨€¨€¨€¨€| 1/1 [00:00<00:00, 1.19it/s]
2025-12-06 09:34:04.803 | INFO | mineru.cli.common:_process_output:151 - local output dir is ./output\0a6339d1-6ad4-495e-b30b-903e47ec77a4\tmpy9fbu22j\auto
+1
View File
@@ -0,0 +1 @@
INFO: 127.0.0.1:55146 - "POST /file_parse HTTP/1.1" 200 OK
+5
View File
@@ -0,0 +1,5 @@
{
"models-dir": {
"vlm": "F:\\\\SOFT\\\\MNOTE\\\\services\\\\mineru\\\\models\\\\MinerU2.5-2509-1.2B"
}
}
@@ -0,0 +1,35 @@
*.7z filter=lfs diff=lfs merge=lfs -text
*.arrow filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text
*.bz2 filter=lfs diff=lfs merge=lfs -text
*.ckpt filter=lfs diff=lfs merge=lfs -text
*.ftz filter=lfs diff=lfs merge=lfs -text
*.gz filter=lfs diff=lfs merge=lfs -text
*.h5 filter=lfs diff=lfs merge=lfs -text
*.joblib filter=lfs diff=lfs merge=lfs -text
*.lfs.* filter=lfs diff=lfs merge=lfs -text
*.mlmodel filter=lfs diff=lfs merge=lfs -text
*.model filter=lfs diff=lfs merge=lfs -text
*.msgpack filter=lfs diff=lfs merge=lfs -text
*.npy filter=lfs diff=lfs merge=lfs -text
*.npz filter=lfs diff=lfs merge=lfs -text
*.onnx filter=lfs diff=lfs merge=lfs -text
*.ot filter=lfs diff=lfs merge=lfs -text
*.parquet filter=lfs diff=lfs merge=lfs -text
*.pb filter=lfs diff=lfs merge=lfs -text
*.pickle filter=lfs diff=lfs merge=lfs -text
*.pkl filter=lfs diff=lfs merge=lfs -text
*.pt filter=lfs diff=lfs merge=lfs -text
*.pth filter=lfs diff=lfs merge=lfs -text
*.rar filter=lfs diff=lfs merge=lfs -text
*.safetensors filter=lfs diff=lfs merge=lfs -text
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.tar.* filter=lfs diff=lfs merge=lfs -text
*.tar filter=lfs diff=lfs merge=lfs -text
*.tflite filter=lfs diff=lfs merge=lfs -text
*.tgz filter=lfs diff=lfs merge=lfs -text
*.wasm filter=lfs diff=lfs merge=lfs -text
*.xz filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
@@ -0,0 +1,228 @@
---
license: agpl-3.0
language:
- zh
- en
pipeline_tag: image-text-to-text
library_name: transformers
---
<div align="center">
<p align="center">
<img src="https://raw.githubusercontent.com/opendatalab/MinerU/master/docs/images/MinerU-logo.png" width="300"/>
<p>
<h1 align="center" style="font-size: 28px">
MinerU2.5: A Decoupled Vision-Language Model for Efficient High-Resolution Document Parsing
</h1>
[![Blog](https://img.shields.io/github/stars/opendatalab/mineru)](https://github.com/opendatalab/MinerU/)
[![HuggingFace](https://img.shields.io/badge/HuggingFace-black.svg?logo=data:image/png;base64,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&labelColor=white)](https://huggingface.co/opendatalab/MinerU2.5-2509-1.2B)
[![ModelScope](https://img.shields.io/badge/ModelScope-black?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjIzIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggaWQ9InN2Z18xNCIgZmlsbD0iIzYyNGFmZiIgZD0ibTAsODkuODRsMjUuNjUsMGwwLDI1LjY0OTk5bC0yNS42NSwwbDAsLTI1LjY0OTk5eiIvPgogIDxwYXRoIGlkPSJzdmdfMTUiIGZpbGw9IiM2MjRhZmYiIGQ9Im05OS4xNCwxMTUuNDlsMjUuNjUsMGwwLDI1LjY1bC0yNS42NSwwbDAsLTI1LjY1eiIvPgogIDxwYXRoIGlkPSJzdmdfMTYiIGZpbGw9IiM2MjRhZmYiIGQ9Im0xNzYuMDksMTQxLjE0bC0yNS42NDk5OSwwbDAsMjIuMTlsNDcuODQsMGwwLC00Ny44NGwtMjIuMTksMGwwLDI1LjY1eiIvPgogIDxwYXRoIGlkPSJzdmdfMTciIGZpbGw9IiMzNmNmZDEiIGQ9Im0xMjQuNzksODkuODRsMjUuNjUsMGwwLDI1LjY0OTk5bC0yNS42NSwwbDAsLTI1LjY0OTk5eiIvPgogIDxwYXRoIGlkPSJzdmdfMTgiIGZpbGw9IiMzNmNmZDEiIGQ9Im0wLDY0LjE5bDI1LjY1LDBsMCwyNS42NWwtMjUuNjUsMGwwLC0yNS42NXoiLz4KICA8cGF0aCBpZD0ic3ZnXzE5IiBmaWxsPSIjNjI0YWZmIiBkPSJtMTk4LjI4LDg5Ljg0bDI1LjY0OTk5LDBsMCwyNS42NDk5OWwtMjUuNjQ5OTksMGwwLC0yNS42NDk5OXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIwIiBmaWxsPSIjMzZjZmQxIiBkPSJtMTk4LjI4LDY0LjE5bDI1LjY0OTk5LDBsMCwyNS42NWwtMjUuNjQ5OTksMGwwLC0yNS42NXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIxIiBmaWxsPSIjNjI0YWZmIiBkPSJtMTUwLjQ0LDQybDAsMjIuMTlsMjUuNjQ5OTksMGwwLDI1LjY1bDIyLjE5LDBsMCwtNDcuODRsLTQ3Ljg0LDB6Ii8+CiAgPHBhdGggaWQ9InN2Z18yMiIgZmlsbD0iIzM2Y2ZkMSIgZD0ibTczLjQ5LDg5Ljg0bDI1LjY1LDBsMCwyNS42NDk5OWwtMjUuNjUsMGwwLC0yNS42NDk5OXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIzIiBmaWxsPSIjNjI0YWZmIiBkPSJtNDcuODQsNjQuMTlsMjUuNjUsMGwwLC0yMi4xOWwtNDcuODQsMGwwLDQ3Ljg0bDIyLjE5LDBsMCwtMjUuNjV6Ii8+CiAgPHBhdGggaWQ9InN2Z18yNCIgZmlsbD0iIzYyNGFmZiIgZD0ibTQ3Ljg0LDExNS40OWwtMjIuMTksMGwwLDQ3Ljg0bDQ3Ljg0LDBsMCwtMjIuMTlsLTI1LjY1LDBsMCwtMjUuNjV6Ii8+CiA8L2c+Cjwvc3ZnPg==&labelColor=white)](https://modelscope.cn/models/OpenDataLab/MinerU2.5-2509-1.2B)
[![HuggingFace](https://img.shields.io/badge/Demo_on_HuggingFace-black.svg?logo=data:image/png;base64,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&labelColor=white)](https://huggingface.co/spaces/opendatalab/MinerU)
[![ModelScope](https://img.shields.io/badge/Demo_on_ModelScope-black?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjIzIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggaWQ9InN2Z18xNCIgZmlsbD0iIzYyNGFmZiIgZD0ibTAsODkuODRsMjUuNjUsMGwwLDI1LjY0OTk5bC0yNS42NSwwbDAsLTI1LjY0OTk5eiIvPgogIDxwYXRoIGlkPSJzdmdfMTUiIGZpbGw9IiM2MjRhZmYiIGQ9Im05OS4xNCwxMTUuNDlsMjUuNjUsMGwwLDI1LjY1bC0yNS42NSwwbDAsLTI1LjY1eiIvPgogIDxwYXRoIGlkPSJzdmdfMTYiIGZpbGw9IiM2MjRhZmYiIGQ9Im0xNzYuMDksMTQxLjE0bC0yNS42NDk5OSwwbDAsMjIuMTlsNDcuODQsMGwwLC00Ny44NGwtMjIuMTksMGwwLDI1LjY1eiIvPgogIDxwYXRoIGlkPSJzdmdfMTciIGZpbGw9IiMzNmNmZDEiIGQ9Im0xMjQuNzksODkuODRsMjUuNjUsMGwwLDI1LjY0OTk5bC0yNS42NSwwbDAsLTI1LjY0OTk5eiIvPgogIDxwYXRoIGlkPSJzdmdfMTgiIGZpbGw9IiMzNmNmZDEiIGQ9Im0wLDY0LjE5bDI1LjY1LDBsMCwyNS42NWwtMjUuNjUsMGwwLC0yNS42NXoiLz4KICA8cGF0aCBpZD0ic3ZnXzE5IiBmaWxsPSIjNjI0YWZmIiBkPSJtMTk4LjI4LDg5Ljg0bDI1LjY0OTk5LDBsMCwyNS42NDk5OWwtMjUuNjQ5OTksMGwwLC0yNS42NDk5OXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIwIiBmaWxsPSIjMzZjZmQxIiBkPSJtMTk4LjI4LDY0LjE5bDI1LjY0OTk5LDBsMCwyNS42NWwtMjUuNjQ5OTksMGwwLC0yNS42NXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIxIiBmaWxsPSIjNjI0YWZmIiBkPSJtMTUwLjQ0LDQybDAsMjIuMTlsMjUuNjQ5OTksMGwwLDI1LjY1bDIyLjE5LDBsMCwtNDcuODRsLTQ3Ljg0LDB6Ii8+CiAgPHBhdGggaWQ9InN2Z18yMiIgZmlsbD0iIzM2Y2ZkMSIgZD0ibTczLjQ5LDg5Ljg0bDI1LjY1LDBsMCwyNS42NDk5OWwtMjUuNjUsMGwwLC0yNS42NDk5OXoiLz4KICA8cGF0aCBpZD0ic3ZnXzIzIiBmaWxsPSIjNjI0YWZmIiBkPSJtNDcuODQsNjQuMTlsMjUuNjUsMGwwLC0yMi4xOWwtNDcuODQsMGwwLDQ3Ljg0bDIyLjE5LDBsMCwtMjUuNjV6Ii8+CiAgPHBhdGggaWQ9InN2Z18yNCIgZmlsbD0iIzYyNGFmZiIgZD0ibTQ3Ljg0LDExNS40OWwtMjIuMTksMGwwLDQ3Ljg0bDQ3Ljg0LDBsMCwtMjIuMTlsLTI1LjY1LDBsMCwtMjUuNjV6Ii8+CiA8L2c+Cjwvc3ZnPg==&labelColor=white)](https://www.modelscope.cn/studios/OpenDataLab/MinerU)
<div align="center">
<a href="https://mineru.net/OpenSourceTools/Extractor" target="_blank" rel="noopener noreferrer"><strong>🚀 Official Demo</strong></a> |
<a href="https://arxiv.org/abs/2509.22186" target="_blank" rel="noopener noreferrer"><strong>📄 Technical Report</strong></a>
</div>
</div>
---
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/performance.jpeg"/>
<p>
# Introduction
<!-- We present **MinerU2.5**, a 1.2B-parameter VLM-based document parsing model that delivers state-of-the-art accuracy with high efficiency. It adopts a coarse-to-fine, two-stage parsing strategy. A large-scale, diverse data engine supports both pretraining and fine-tuning, enabling robust performance across document types. -->
**MinerU2.5** is a 1.2B-parameter vision-language model for document parsing that achieves state-of-the-art accuracy with high computational efficiency. It adopts a two-stage parsing strategy: first conducting efficient global layout analysis on downsampled images, then performing fine-grained content recognition on native-resolution crops for text, formulas, and tables. Supported by a large-scale, diverse data engine for pretraining and fine-tuning, MinerU2.5 consistently outperforms both general-purpose and domain-specific models across multiple benchmarks while maintaining low computational overhead.
## Key Improvements
<!-- - **More Precise Layout Detection:** Faithfully preserves non-body elements such as headers, footers, and page numbers, ensuring comprehensive content integrity.
- **Significantly Improved Body Text Recognition:** Produces more standardized text formatting with clearly discernible structures for lists, references, and other elements.
- **Breakthroughs in Formula Parsing:** Delivers high-quality parsing of complex, lengthy mathematical formulae and accurately recognizes mixed-language (Chinese-English) equations.
- **Enhanced Robustness in Table Parsing:** Effortlessly handles challenging cases, including rotated tables, borderless tables, and tables with partial borders. -->
- **Comprehensive and Granular Layout Analysis:** It not only preserves non-body elements like headers, footers, and page numbers to ensure full content integrity, but also employs a refined and standardized labeling schema. This enables a clearer, more structured representation of elements such as lists, references, and code blocks.
- **Breakthroughs in Formula Parsing:** Delivers high-quality parsing of complex, lengthy mathematical formulae and accurately recognizes mixed-language (Chinese-English) equations.
- **Enhanced Robustness in Table Parsing:** Effortlessly handles challenging cases, including rotated tables, borderless tables, and tables with partial borders.
# Quick Start
For convenience, we provide `mineru-vl-utils`, a Python package that simplifies the process of sending requests and handling responses from MinerU2.5 Vision-Language Model. Here we give some examples to use MinerU2.5. For more information and usages, please refer to [mineru-vl-utils](https://github.com/opendatalab/mineru-vl-utils/tree/main).
📌 We strongly recommend using vllm for inference, as the `vllm-async-engine` can achieve a concurrent inference speed of **2.12 fps** on one A100.
## Install packages
```bash
# For `transformers` backend
pip install "mineru-vl-utils[transformers]"
# For `vllm-engine` and `vllm-async-engine` backend
pip install "mineru-vl-utils[vllm]"
```
## `transformers` Example
```python
from transformers import AutoProcessor, Qwen2VLForConditionalGeneration
from PIL import Image
from mineru_vl_utils import MinerUClient
# for transformers>=4.56.0
model = Qwen2VLForConditionalGeneration.from_pretrained(
"opendatalab/MinerU2.5-2509-1.2B",
dtype="auto", # use `torch_dtype` instead of `dtype` for transformers<4.56.0
device_map="auto"
)
processor = AutoProcessor.from_pretrained(
"opendatalab/MinerU2.5-2509-1.2B",
use_fast=True
)
client = MinerUClient(
backend="transformers",
model=model,
processor=processor
)
image = Image.open("/path/to/the/test/image.png")
extracted_blocks = client.two_step_extract(image)
print(extracted_blocks)
```
## `vllm-engine` Example (Recommended!)
```python
from vllm import LLM
from PIL import Image
from mineru_vl_utils import MinerUClient
from mineru_vl_utils import MinerULogitsProcessor # if vllm>=0.10.1
llm = LLM(
model="opendatalab/MinerU2.5-2509-1.2B",
logits_processors=[MinerULogitsProcessor] # if vllm>=0.10.1
)
client = MinerUClient(
backend="vllm-engine",
vllm_llm=llm
)
image = Image.open("/path/to/the/test/image.png")
extracted_blocks = client.two_step_extract(image)
print(extracted_blocks)
```
## `vllm-async-engine` Example (Recommended!)
```python
import io
import asyncio
import aiofiles
from vllm.v1.engine.async_llm import AsyncLLM
from vllm.engine.arg_utils import AsyncEngineArgs
from PIL import Image
from mineru_vl_utils import MinerUClient
from mineru_vl_utils import MinerULogitsProcessor # if vllm>=0.10.1
async_llm = AsyncLLM.from_engine_args(
AsyncEngineArgs(
model="opendatalab/MinerU2.5-2509-1.2B",
logits_processors=[MinerULogitsProcessor] # if vllm>=0.10.1
)
)
client = MinerUClient(
backend="vllm-async-engine",
vllm_async_llm=async_llm,
)
async def main():
image_path = "/path/to/the/test/image.png"
async with aiofiles.open(image_path, "rb") as f:
image_data = await f.read()
image = Image.open(io.BytesIO(image_data))
extracted_blocks = await client.aio_two_step_extract(image)
print(extracted_blocks)
asyncio.run(main())
async_llm.shutdown()
```
# Model Architecture
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/Mineru25_framework.jpeg"/>
<p>
# Performance on OmniDocBench
## Across Different Elements
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/omnidocbench_element.jpeg"/>
<p>
## Across Various Document Types
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/omnidocbench_type.jpeg"/>
<p>
# Case Demonstration
## Full-Document Parsing across Various Doc-Types
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/PDF-Type-1_page_1.png"/>
<p>
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/PDF-Type-2_page_1.png"/>
<p>
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5//PDF-Type-3_page_1.png"/>
<p>
## Table Recognition
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/Table-Module-1_page_1.png"/>
<p>
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/Table-Module-2_page_1.png"/>
<p>
## Formula Recognition
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/Formula-Module-1_page_1.png"/>
<p>
<p align="center">
<img alt="Image" src="https://hotelll.github.io/MinerU2.5/Formula-Module-2_page_1.png"/>
<p>
# Acknowledgements
We would like to thank [Qwen Team](https://github.com/QwenLM), [vLLM](https://github.com/vllm-project/vllm), [OmniDocBench](https://github.com/opendatalab/OmniDocBench), [UniMERNet](https://github.com/opendatalab/UniMERNet), [PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR), [DocLayout-YOLO](https://github.com/opendatalab/DocLayout-YOLO) for providing valuable code and models. We also appreciate everyone's contribution to this open-source project!
# Citation
If you find our work useful in your research, please consider giving a star ⭐ and citation 📝 :
```BibTeX
@misc{niu2025mineru25decoupledvisionlanguagemodel,
title={MinerU2.5: A Decoupled Vision-Language Model for Efficient High-Resolution Document Parsing},
author={Junbo Niu and Zheng Liu and Zhuangcheng Gu and Bin Wang and Linke Ouyang and Zhiyuan Zhao and Tao Chu and Tianyao He and Fan Wu and Qintong Zhang and Zhenjiang Jin and others},
year={2025},
eprint={2509.22186},
archivePrefix={arXiv},
primaryClass={cs.CV},
url={https://arxiv.org/abs/2509.22186},
}
```
@@ -0,0 +1,33 @@
{
"<|endoftext|>": 151643,
"<|im_start|>": 151644,
"<|im_end|>": 151645,
"<|object_ref_start|>": 151646,
"<|object_ref_end|>": 151647,
"<|box_start|>": 151648,
"<|box_end|>": 151649,
"<|quad_start|>": 151650,
"<|quad_end|>": 151651,
"<|vision_start|>": 151652,
"<|vision_end|>": 151653,
"<|vision_pad|>": 151654,
"<|image_pad|>": 151655,
"<|video_pad|>": 151656,
"<|ref_start|>": 151657,
"<|ref_end|>": 151658,
"<|md_start|>": 151659,
"<|md_end|>": 151660,
"<ched>": 151661,
"<ecel>": 151662,
"<fcel>": 151663,
"<lcel>": 151664,
"<ucel>": 151665,
"<xcel>": 151666,
"<nl>": 151667,
"<|rotate_up|>": 151668,
"<|rotate_down|>": 151669,
"<|rotate_left|>": 151670,
"<|rotate_right|>": 151671,
"<|txt_contd|>": 151672,
"<|paratext|>": 151673
}
@@ -0,0 +1,3 @@
{
"chat_template": "{% set image_count = namespace(value=0) %}{% set video_count = namespace(value=0) %}{% for message in messages %}{% if loop.first and message['role'] != 'system' %}<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n{% endif %}<|im_start|>{{ message['role'] }}\n{% if message['content'] is string %}{{ message['content'] }}<|im_end|>\n{% else %}{% for content in message['content'] %}{% if content['type'] == 'image' or 'image' in content or 'image_url' in content %}{% set image_count.value = image_count.value + 1 %}{% if add_vision_id %}Picture {{ image_count.value }}: {% endif %}<|vision_start|><|image_pad|><|vision_end|>{% elif content['type'] == 'video' or 'video' in content %}{% set video_count.value = video_count.value + 1 %}{% if add_vision_id %}Video {{ video_count.value }}: {% endif %}<|vision_start|><|video_pad|><|vision_end|>{% elif 'text' in content %}{{ content['text'] }}{% endif %}{% endfor %}<|im_end|>\n{% endif %}{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}"
}
@@ -0,0 +1,52 @@
{
"architectures": [
"Qwen2VLForConditionalGeneration"
],
"attention_dropout": 0.0,
"bos_token_id": 151643,
"eos_token_id": 151645,
"vision_start_token_id": 151652,
"vision_end_token_id": 151653,
"vision_token_id": 151654,
"image_token_id": 151655,
"video_token_id": 151656,
"hidden_act": "silu",
"hidden_size": 896,
"initializer_range": 0.02,
"intermediate_size": 4864,
"max_position_embeddings": 16384,
"max_window_layers": 24,
"model_type": "qwen2_vl",
"num_attention_heads": 14,
"num_hidden_layers": 24,
"num_key_value_heads": 2,
"rms_norm_eps": 1e-06,
"rope_theta": 1000000.0,
"sliding_window": 16384,
"tie_word_embeddings": true,
"torch_dtype": "bfloat16",
"transformers_version": "4.41.2",
"use_cache": true,
"use_sliding_window": false,
"vision_config": {
"depth": 32,
"embed_dim": 1280,
"mlp_ratio": 4,
"num_heads": 16,
"in_chans": 3,
"hidden_size": 896,
"patch_size": 14,
"spatial_merge_size": 2,
"spatial_patch_size": 14,
"temporal_patch_size": 2
},
"rope_scaling": {
"type": "mrope",
"mrope_section": [
8,
12,
12
]
},
"vocab_size": 151936
}
@@ -0,0 +1,15 @@
{
"bos_token_id": 151643,
"pad_token_id": 151643,
"do_sample": true,
"eos_token_id": [
151645,
151643
],
"repetition_penalty": 1.0,
"temperature": 0.01,
"top_p": 0.001,
"top_k": 1,
"transformers_version": "4.37.0"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
{
"min_pixels": 3136,
"max_pixels": 1605632,
"patch_size": 14,
"temporal_patch_size": 2,
"merge_size": 2,
"image_mean": [
0.48145466,
0.4578275,
0.40821073
],
"image_std": [
0.26862954,
0.26130258,
0.27577711
],
"image_processor_type": "Qwen2VLImageProcessor",
"processor_class": "Qwen2VLProcessor"
}
@@ -0,0 +1,228 @@
{
"additional_special_tokens": [
{
"content": "<|im_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|im_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|object_ref_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|object_ref_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|box_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|box_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|quad_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|quad_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|vision_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|vision_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|vision_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|image_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|video_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|ref_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|ref_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|md_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|md_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<ched>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<ecel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<fcel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<lcel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<ucel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<xcel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<nl>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|rotate_up|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|rotate_down|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|rotate_left|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|rotate_right|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|txt_contd|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
{
"content": "<|paratext|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
}
],
"eos_token": {
"content": "<|im_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"pad_token": {
"content": "<|endoftext|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
{
"add_prefix_space": false,
"added_tokens_decoder": {
"151643": {
"content": "<|endoftext|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151644": {
"content": "<|im_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151645": {
"content": "<|im_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151646": {
"content": "<|object_ref_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151647": {
"content": "<|object_ref_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151648": {
"content": "<|box_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151649": {
"content": "<|box_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151650": {
"content": "<|quad_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151651": {
"content": "<|quad_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151652": {
"content": "<|vision_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151653": {
"content": "<|vision_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151654": {
"content": "<|vision_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151655": {
"content": "<|image_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151656": {
"content": "<|video_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151657": {
"content": "<|ref_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151658": {
"content": "<|ref_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151659": {
"content": "<|md_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151660": {
"content": "<|md_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151661": {
"content": "<ched>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151662": {
"content": "<ecel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151663": {
"content": "<fcel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151664": {
"content": "<lcel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151665": {
"content": "<ucel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151666": {
"content": "<xcel>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151667": {
"content": "<nl>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151668": {
"content": "<|rotate_up|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151669": {
"content": "<|rotate_down|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151670": {
"content": "<|rotate_left|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151671": {
"content": "<|rotate_right|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151672": {
"content": "<|txt_contd|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151673": {
"content": "<|paratext|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
}
},
"additional_special_tokens": [
"<|im_start|>",
"<|im_end|>",
"<|object_ref_start|>",
"<|object_ref_end|>",
"<|box_start|>",
"<|box_end|>",
"<|quad_start|>",
"<|quad_end|>",
"<|vision_start|>",
"<|vision_end|>",
"<|vision_pad|>",
"<|image_pad|>",
"<|video_pad|>",
"<|ref_start|>",
"<|ref_end|>",
"<|md_start|>",
"<|md_end|>",
"<ched>",
"<ecel>",
"<fcel>",
"<lcel>",
"<ucel>",
"<xcel>",
"<nl>",
"<|rotate_up|>",
"<|rotate_down|>",
"<|rotate_left|>",
"<|rotate_right|>",
"<|txt_contd|>",
"<|paratext|>"
],
"bos_token": null,
"chat_template": "{% set image_count = namespace(value=0) %}{% set video_count = namespace(value=0) %}{% for message in messages %}{% if loop.first and message['role'] != 'system' %}<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n{% endif %}<|im_start|>{{ message['role'] }}\n{% if message['content'] is string %}{{ message['content'] }}<|im_end|>\n{% else %}{% for content in message['content'] %}{% if content['type'] == 'image' or 'image' in content or 'image_url' in content %}{% set image_count.value = image_count.value + 1 %}{% if add_vision_id %}Picture {{ image_count.value }}: {% endif %}<|vision_start|><|image_pad|><|vision_end|>{% elif content['type'] == 'video' or 'video' in content %}{% set video_count.value = video_count.value + 1 %}{% if add_vision_id %}Video {{ video_count.value }}: {% endif %}<|vision_start|><|video_pad|><|vision_end|>{% elif 'text' in content %}{{ content['text'] }}{% endif %}{% endfor %}<|im_end|>\n{% endif %}{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}",
"clean_up_tokenization_spaces": false,
"eos_token": "<|im_end|>",
"padding_side": "left",
"errors": "replace",
"model_max_length": 16384,
"pad_token": "<|endoftext|>",
"split_special_tokens": false,
"tokenizer_class": "Qwen2Tokenizer",
"unk_token": null
}
@@ -0,0 +1,19 @@
{
"min_pixels": 3136,
"max_pixels": 1605632,
"patch_size": 14,
"temporal_patch_size": 2,
"merge_size": 2,
"image_mean": [
0.48145466,
0.4578275,
0.40821073
],
"image_std": [
0.26862954,
0.26130258,
0.27577711
],
"image_processor_type": "Qwen2VLImageProcessor",
"processor_class": "Qwen2VLProcessor"
}
File diff suppressed because one or more lines are too long
+52
View File
@@ -0,0 +1,52 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$InputPath,
[Parameter(Mandatory = $true)]
[string]$OutputDir,
[string[]]$ExtraArgs
)
$ErrorActionPreference = "Stop"
# 预设路径
$repoRoot = Split-Path $PSScriptRoot -Parent
$modelRoot = Join-Path $PSScriptRoot "models\\MinerU2.5-2509-1.2B"
$configPath = Join-Path $PSScriptRoot "mineru.json"
# 优先使用带 ROCm 的 3.12 环境,回退到本地 .venv
$pythonBins = @(
"F:\\rocm\\.venv-rocm312\\Scripts\\python.exe",
(Join-Path $PSScriptRoot ".venv\\Scripts\\python.exe")
)
$pythonBin = $pythonBins | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $pythonBin) {
throw "未找到可用的 Python,可在 F:\\rocm\\.venv-rocm312 或 services\\mineru\\.venv 创建后再试。"
}
# 环境变量:默认使用本地模型配置
$env:MINERU_TOOLS_CONFIG_JSON = $configPath
$env:MINERU_MODEL_SOURCE = "local"
$env:PYTHONPATH = Join-Path $PSScriptRoot "src"
# 确保输出目录存在
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
# 构造命令
$argsList = @(
"-m", "mineru.cli.client",
"-p", $InputPath,
"-o", $OutputDir,
"-b", "vlm-transformers",
"--model-path", $modelRoot
)
if ($ExtraArgs) {
$argsList += $ExtraArgs
}
Write-Host ">>> 使用 VLM 解析: $InputPath -> $OutputDir"
Write-Host ">>> Python: $pythonBin"
Write-Host ">>> 模型: $modelRoot"
Write-Host ">>> 额外参数: $ExtraArgs"
& $pythonBin @argsList
+9 -1
View File
@@ -70,7 +70,7 @@ async def parse_pdf(
files: List[UploadFile] = File(...), files: List[UploadFile] = File(...),
output_dir: str = Form("./output"), output_dir: str = Form("./output"),
lang_list: List[str] = Form(["ch"]), lang_list: List[str] = Form(["ch"]),
backend: str = Form("pipeline"), backend: str = Form("vlm-transformers"),
parse_method: str = Form("auto"), parse_method: str = Form("auto"),
formula_enable: bool = Form(True), formula_enable: bool = Form(True),
table_enable: bool = Form(True), table_enable: bool = Form(True),
@@ -261,6 +261,14 @@ def main(ctx, host, port, reload, **kwargs):
kwargs.update(arg_parse(ctx)) kwargs.update(arg_parse(ctx))
# 从环境变量注入默认 model_path / backend,便于服务端无需请求方传参也能使用 VLM
env_model_path = os.getenv("MINERU_MODEL_PATH")
if env_model_path and "model_path" not in kwargs:
kwargs["model_path"] = env_model_path
env_default_backend = os.getenv("MINERU_DEFAULT_BACKEND", "vlm-transformers")
if env_default_backend and "backend" not in kwargs:
kwargs["backend"] = env_default_backend
# 将配置参数存储到应用状态中 # 将配置参数存储到应用状态中
app.state.config = kwargs app.state.config = kwargs
+43
View File
@@ -9,6 +9,49 @@ export type Json =
export type Database = { export type Database = {
public: { public: {
Tables: { Tables: {
background_tasks: {
Row: {
id: string;
user_id: string;
document_id: string | null;
task_type: string;
status: string;
progress: number;
message: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
user_id: string;
document_id?: string | null;
task_type?: string;
status?: string;
progress?: number;
message?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
user_id?: string;
document_id?: string | null;
task_type?: string;
status?: string;
progress?: number;
message?: string | null;
created_at?: string;
updated_at?: string;
};
Relationships: [
{
foreignKeyName: "background_tasks_document_id_fkey";
columns: ["document_id"];
referencedRelation: "documents";
referencedColumns: ["id"];
},
];
};
documents: { documents: {
Row: { Row: {
access_scope: "private" | "shared" | "public"; access_scope: "private" | "shared" | "public";
@@ -0,0 +1,43 @@
create table if not exists public.background_tasks (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
document_id uuid references public.documents (id) on delete cascade,
task_type text not null default 'ocr',
status text not null default 'pending',
progress integer not null default 0 check (progress between 0 and 100),
message text,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now())
);
create index if not exists background_tasks_user_idx
on public.background_tasks (user_id, created_at desc);
create index if not exists background_tasks_document_idx
on public.background_tasks (document_id, created_at desc);
create or replace function public.set_background_tasks_updated_at()
returns trigger
language plpgsql
security invoker
as $$
begin
new.updated_at := timezone('utc', now());
return new;
end;
$$;
drop trigger if exists set_background_tasks_updated_at on public.background_tasks;
create trigger set_background_tasks_updated_at
before update on public.background_tasks
for each row
execute procedure public.set_background_tasks_updated_at();
alter table public.background_tasks enable row level security;
drop policy if exists "Own background tasks" on public.background_tasks;
create policy "Own background tasks"
on public.background_tasks
for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
@@ -0,0 +1,15 @@
-- 允许工作空间成员删除媒体资源,保持与插入/更新策略一致
alter table public.media_assets enable row level security;
drop policy if exists "Delete assets within workspace" on public.media_assets;
create policy "Delete assets within workspace"
on public.media_assets
for delete
using (
exists (
select 1
from public.workspace_members wm
where wm.workspace_id = media_assets.workspace_id
and wm.user_id = auth.uid()
)
);
@@ -0,0 +1,36 @@
alter table public.documents
add column if not exists mindmap_data jsonb,
add column if not exists raw_text text,
add column if not exists index_status text not null default 'pending';
alter table public.documents
alter column index_status set default 'pending';
alter table public.documents
alter column updated_at set default timezone('utc', now());
alter table public.documents
alter column created_at set default timezone('utc', now());
create index if not exists documents_user_updated_idx
on public.documents (user_id, updated_at desc);
create index if not exists documents_index_status_idx
on public.documents (index_status);
create or replace function public.set_documents_updated_at()
returns trigger
language plpgsql
security invoker
as $$
begin
new.updated_at := timezone('utc', now());
return new;
end;
$$;
drop trigger if exists set_documents_updated_at on public.documents;
create trigger set_documents_updated_at
before update on public.documents
for each row
execute procedure public.set_documents_updated_at();
+4
View File
@@ -1 +1,5 @@
"""FastAPI 应用初始化模块。""" """FastAPI 应用初始化模块。"""
# 导入 Celery 应用以确保 FastAPI 进程也加载 task_always_eager 配置。
# pylint: disable=unused-import
from app.workers.celery_app import celery_app as _celery_app # noqa: F401
+34
View File
@@ -1,13 +1,47 @@
from __future__ import annotations
from functools import lru_cache from functools import lru_cache
import os
from pathlib import Path
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
def _apply_local_supabase_env() -> None:
"""
优先加载本地 .env 中的 Supabase 配置,避免宿主环境遗留的线上变量导致鉴权失败。
仅覆盖 SUPABASE_* 相关键。
"""
env_path = Path(__file__).resolve().parents[1] / ".env"
if not env_path.exists():
return
content = env_path.read_text(encoding="utf-8").splitlines()
kv: dict[str, str] = {}
for line in content:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
kv[key.strip()] = value.strip()
for key in ("SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_ANON_KEY"):
file_value = kv.get(key)
if not file_value:
continue
os.environ[key] = file_value
_apply_local_supabase_env()
class Settings(BaseSettings): class Settings(BaseSettings):
"""集中管理项目配置,来源于 .env / 环境变量。""" """集中管理项目配置,来源于 .env / 环境变量。"""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
supabase_url: str supabase_url: str
supabase_anon_key: Optional[str] = None
supabase_service_role_key: str supabase_service_role_key: str
redis_url: str = "redis://localhost:6379/0" redis_url: str = "redis://localhost:6379/0"
frontend_url: str = "http://localhost:3000" frontend_url: str = "http://localhost:3000"
+9 -3
View File
@@ -23,22 +23,28 @@ class AuthContext:
async def get_current_user( async def get_current_user(
authorization: Annotated[Optional[str], Header(convert_underscores=False)] = None, authorization: Annotated[Optional[str], Header(convert_underscores=False)] = None,
x_supabase_access_token: Annotated[
Optional[str], Header(convert_underscores=False, alias="x-supabase-access-token")
] = None,
) -> AuthContext: ) -> AuthContext:
""" """
验证 Supabase JWTstage0 直接依赖 service_role 解析 token。 验证 Supabase JWTstage0 直接依赖 service_role 解析 token。
生产环境应通过 API Gateway 注入 user。 生产环境应通过 API Gateway 注入 user。
""" """
if not authorization or not authorization.startswith("Bearer "): header_token = authorization or (f"Bearer {x_supabase_access_token}" if x_supabase_access_token else None)
if not header_token or not header_token.startswith("Bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
token = authorization.replace("Bearer ", "", 1).strip() token = header_token.replace("Bearer ", "", 1).strip()
if not token: if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Empty token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Empty token")
print(f"[auth] Validating Supabase token prefix={token[:8]}")
auth_url = f"{settings.supabase_url.rstrip('/')}/auth/v1/user" auth_url = f"{settings.supabase_url.rstrip('/')}/auth/v1/user"
apikey = settings.supabase_anon_key or settings.supabase_service_role_key
headers = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"apikey": settings.supabase_service_role_key, "apikey": apikey,
} }
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
try: try:
+2 -1
View File
@@ -1,10 +1,11 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import chat, health, luckysheet_ws, tasks from . import chat, health, luckysheet_ws, tasks, lightrag
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(tasks.router, tags=["tasks"]) api_router.include_router(tasks.router, tags=["tasks"])
api_router.include_router(chat.router, tags=["chat"]) api_router.include_router(chat.router, tags=["chat"])
api_router.include_router(lightrag.router, tags=["lightrag"])
root_router = APIRouter() root_router = APIRouter()
root_router.include_router(health.router, tags=["health"]) root_router.include_router(health.router, tags=["health"])
+52 -11
View File
@@ -1,26 +1,67 @@
import asyncio from __future__ import annotations
from fastapi import APIRouter
import json
from typing import AsyncIterator, Optional
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.deps import AuthDep from app.deps import AuthDep
from app.services.lightrag_service import lightrag_service from app.services.lightrag_service import lightrag_service
from app.services.supabase_rest import supabase_rest
router = APIRouter(prefix="/chat") router = APIRouter(prefix="/chat")
@router.get("") @router.get("")
async def chat(query: str, document_id: str, auth: AuthDep) -> StreamingResponse: # noqa: ARG001 async def chat(
query: str,
auth: AuthDep,
document_id: Optional[str] = None,
) -> StreamingResponse:
""" """
SSE 流式占位。后续会调用 LightRAG + OpenAI 基于 LightRAG 的 SSE 流式回答
当前直接返回 mock 文字,确保前端链路可用。 query: 必填问题
document_id: 可选,指定所属 workspace
""" """
if not query or not query.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="query 不能为空")
async def event_stream() -> asyncio.AsyncGenerator[str, None]: workspace_id: Optional[str] = None
reply = await lightrag_service.query(query_text=query, user_id="placeholder-user") if document_id:
chunks = [reply[: len(reply) // 2 or 1], reply[len(reply) // 2 or 1 :]] document = supabase_rest.select_one(
for chunk in chunks: "documents", {"id": document_id, "user_id": auth.user_id}
yield f"data: {chunk}\n\n" )
await asyncio.sleep(0.1) if not document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
workspace_id = (
str(document.get("workspace_id")) if document.get("workspace_id") else None
)
health = await lightrag_service.run_healthcheck()
if not health.get("ok"):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=health.get("error", "LightRAG 未就绪"),
)
lightrag_result = await lightrag_service.stream_answer(
query_text=query, user_id=auth.user_id, workspace_id=workspace_id
)
async def event_stream() -> AsyncIterator[str]:
if lightrag_result.get("is_streaming") and lightrag_result.get("iterator"):
iterator = lightrag_result["iterator"]
async for chunk in iterator:
payload = json.dumps({"type": "chunk", "content": chunk})
yield f"data: {payload}\n\n"
else:
payload = json.dumps(
{"type": "chunk", "content": lightrag_result.get("content", "")}
)
yield f"data: {payload}\n\n"
references = lightrag_result.get("references", [])
yield f"data: {json.dumps({'type': 'references', 'data': references})}\n\n"
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream") return StreamingResponse(event_stream(), media_type="text/event-stream")
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from app.deps import AuthDep
from app.schemas.tasks import TaskStatusResponse
from app.services.lightrag_service import lightrag_service
from app.services.supabase_rest import supabase_rest
from app.services.task_tracker import task_tracker
from app.workers.tasks import lightrag_index_pipeline
router = APIRouter(prefix="/lightrag")
class IndexRequest(BaseModel):
document_id: str
@router.post("/index", response_model=TaskStatusResponse)
async def enqueue_lightrag_index(payload: IndexRequest, auth: AuthDep) -> TaskStatusResponse:
document = supabase_rest.select_one(
"documents", {"id": payload.document_id, "user_id": auth.user_id}
)
if not document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
raw_text = str(document.get("raw_text") or "").strip()
if not raw_text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文档尚未生成 raw_text,无法索引",
)
supabase_rest.update(
"documents", {"id": payload.document_id}, {"index_status": "pending"}
)
task = task_tracker.create_task(
user_id=auth.user_id, document_id=payload.document_id, task_type="index"
)
lightrag_index_pipeline.delay(task.task_id, payload.document_id, auth.user_id)
return task
@router.get("/health")
async def lightrag_health():
result = await lightrag_service.run_healthcheck()
if not result.get("ok"):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=result.get("error", "LightRAG 未就绪"),
)
return result
+2 -1
View File
@@ -102,8 +102,9 @@ def _decode_ws_payload(raw_message: str) -> Optional[dict]:
async def _fetch_supabase_user(access_token: str) -> Optional[dict]: async def _fetch_supabase_user(access_token: str) -> Optional[dict]:
base_url = settings.supabase_url.rstrip("/") base_url = settings.supabase_url.rstrip("/")
apikey = settings.supabase_anon_key or settings.supabase_service_role_key
headers = { headers = {
"apikey": settings.supabase_service_role_key, "apikey": apikey,
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
} }
try: try:
+31 -3
View File
@@ -1,9 +1,11 @@
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from app.deps import AuthDep from app.deps import AuthDep
from app.schemas.tasks import OcrTaskRequest, TaskStatusResponse from app.schemas.tasks import MediaOcrTaskRequest, OcrTaskRequest, TaskStatusResponse
from app.services.supabase_rest import supabase_rest
from app.services.task_tracker import task_tracker from app.services.task_tracker import task_tracker
from app.workers.tasks import ocr_pipeline from app.workers.tasks import media_ocr_pipeline, ocr_pipeline
from app.workers.utils import dispatch_task
router = APIRouter(prefix="/tasks") router = APIRouter(prefix="/tasks")
@@ -12,7 +14,8 @@ router = APIRouter(prefix="/tasks")
async def enqueue_ocr_task(payload: OcrTaskRequest, auth: AuthDep) -> TaskStatusResponse: async def enqueue_ocr_task(payload: OcrTaskRequest, auth: AuthDep) -> TaskStatusResponse:
"""记录任务并投递 Celery,阶段 0 返回占位任务。""" """记录任务并投递 Celery,阶段 0 返回占位任务。"""
task = task_tracker.create_task(user_id=auth.user_id, document_id=payload.document_id, task_type="ocr") task = task_tracker.create_task(user_id=auth.user_id, document_id=payload.document_id, task_type="ocr")
ocr_pipeline.delay( dispatch_task(
ocr_pipeline,
task_id=task.task_id, task_id=task.task_id,
document_id=payload.document_id, document_id=payload.document_id,
file_url=str(payload.file_url), file_url=str(payload.file_url),
@@ -21,6 +24,31 @@ async def enqueue_ocr_task(payload: OcrTaskRequest, auth: AuthDep) -> TaskStatus
return task return task
@router.post("/media-ocr")
async def enqueue_media_ocr(payload: MediaOcrTaskRequest, auth: AuthDep) -> dict:
"""触发媒体资产 OCR,占位实现先写入模拟文本。"""
asset = supabase_rest.select_one("media_assets", {"id": payload.asset_id})
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media asset not found")
document_id = asset.get("document_id")
if not document_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Media asset missing document")
document = supabase_rest.select_one("documents", {"id": document_id})
if not document or document.get("user_id") != auth.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
supabase_rest.update("media_assets", {"id": payload.asset_id}, {"ocr_status": "processing"})
dispatch_task(
media_ocr_pipeline,
asset_id=payload.asset_id,
user_id=auth.user_id,
document_id=document_id,
)
return {"asset_id": payload.asset_id, "status": "queued"}
@router.get("/{task_id}", response_model=TaskStatusResponse) @router.get("/{task_id}", response_model=TaskStatusResponse)
async def get_task_status(task_id: str, auth: AuthDep) -> TaskStatusResponse: async def get_task_status(task_id: str, auth: AuthDep) -> TaskStatusResponse:
task = task_tracker.get_task(task_id=task_id, user_id=auth.user_id) task = task_tracker.get_task(task_id=task_id, user_id=auth.user_id)
+4
View File
@@ -10,6 +10,10 @@ class OcrTaskRequest(BaseModel):
file_url: AnyHttpUrl file_url: AnyHttpUrl
class MediaOcrTaskRequest(BaseModel):
asset_id: str
class TaskStatusResponse(BaseModel): class TaskStatusResponse(BaseModel):
task_id: str task_id: str
status: Literal["pending", "processing", "completed", "failed"] status: Literal["pending", "processing", "completed", "failed"]
+265 -7
View File
@@ -1,19 +1,277 @@
"""LightRAG 集成占位。阶段 1 会在这里封装真正的查询与索引""" """LightRAG 集成。负责管理实例、增量索引与问答"""
from __future__ import annotations
import asyncio
import logging
import os
from pathlib import Path
import sys
from typing import Any, AsyncIterator, Dict, List, Optional
from urllib.parse import urlparse
def _ensure_lightrag_available() -> None:
"""确保本地 LightRAG 代码可被 Python 找到."""
repo_root = Path(__file__).resolve().parents[3]
local_pkg = repo_root / "LightRAG"
if local_pkg.exists():
path_str = str(local_pkg)
if path_str not in sys.path:
sys.path.insert(0, path_str)
logger = logging.getLogger(__name__)
try:
from lightrag import LightRAG, QueryParam
from lightrag.kg.shared_storage import initialize_pipeline_status
from lightrag.llm.openai import gpt_4o_mini_complete, openai_embed
from lightrag.utils import logger as lightrag_logger
_LIGHTRAG_AVAILABLE = True
_IMPORT_ERROR: Optional[Exception] = None
except Exception as exc: # pragma: no cover - 本地缺失或版本不兼容时使用占位实现
_LIGHTRAG_AVAILABLE = False
_IMPORT_ERROR = exc
lightrag_logger = logging.getLogger("lightrag_stub")
class QueryParam: # type: ignore[override]
def __init__(self, mode: str = "mix") -> None:
self.mode = mode
self.stream = True
class LightRAG: # type: ignore[override]
async def initialize_storages(self) -> None:
return None
async def ainsert(self, *_: Any, **__: Any) -> str:
return "lightrag-skipped"
async def aquery_llm(self, *_: Any, **__: Any) -> Dict[str, Any]:
return {
"llm_response": {
"response_iterator": iter(()),
"content": "LightRAG 已跳过",
"is_streaming": False,
},
"data": {"references": []},
"metadata": {"skipped": True},
}
async def initialize_pipeline_status() -> None: # type: ignore[override]
return None
def gpt_4o_mini_complete(*_: Any, **__: Any) -> str: # type: ignore[override]
return ""
def openai_embed(*_: Any, **__: Any) -> List[float]: # type: ignore[override]
return []
_ensure_lightrag_available()
from app.config import settings from app.config import settings
class LightRAGService: class LightRAGService:
"""
管理 LightRAG 单例、工作空间隔离与增量索引。
- 每个 workspace 映射为一个 LightRAG 实例(共享 Postgres
- 支持在同步/异步上下文中调用
"""
def __init__(self) -> None: def __init__(self) -> None:
self.collection = settings.lightrag_collection self.collection = settings.lightrag_collection
self._instances: Dict[str, LightRAG] = {}
self._locks: Dict[str, asyncio.Lock] = {}
self._pipeline_ready = False
self._configure_pg_env()
if not _LIGHTRAG_AVAILABLE and _IMPORT_ERROR:
logger.warning("LightRAG 不可用,使用占位实现:%s", _IMPORT_ERROR)
self._working_dir = (
Path(__file__).resolve().parent.parent / "runtime" / "lightrag_cache"
)
self._working_dir.mkdir(parents=True, exist_ok=True)
self._availability_error: Optional[str] = None
async def queue_index(self, document_id: str, raw_text: str) -> None: def _configure_pg_env(self) -> None:
"""预留方法:后续调用 LightRAG.update_index""" """根据配置将 pgvector 连接信息注入 LightRAG 需要的环境变量"""
return None parsed = urlparse(settings.lightrag_db_url)
if parsed.scheme not in {"postgresql", "postgres"}:
self._availability_error = "LIGHTRAG_DB_URL 必须是 Postgres 连接串"
return
async def query(self, query_text: str, user_id: str) -> str: if parsed.username:
"""预留方法:后续调用 LightRAG.query,当前返回占位回答。""" os.environ.setdefault("POSTGRES_USER", parsed.username)
return f"[mock] {query_text}" if parsed.password:
os.environ.setdefault("POSTGRES_PASSWORD", parsed.password)
if parsed.hostname:
os.environ.setdefault("POSTGRES_HOST", parsed.hostname)
if parsed.port:
os.environ.setdefault("POSTGRES_PORT", str(parsed.port))
if parsed.path and len(parsed.path) > 1:
os.environ.setdefault("POSTGRES_DATABASE", parsed.path.lstrip("/"))
# OpenAI key 也交给环境变量,缺失时依旧由外部控制
if settings.openai_api_key:
os.environ.setdefault("OPENAI_API_KEY", settings.openai_api_key)
elif self._availability_error is None:
# 允许无 key 但标记提示,便于健康检查返回可诊断信息
self._availability_error = "缺少 OPENAI_API_KEYLightRAG 将使用占位实现"
def _namespace(self, workspace_id: Optional[str], user_id: str) -> str:
"""生成 LightRAG workspace 名称,优先 workspace,其次 user。"""
if workspace_id:
return f"workspace_{workspace_id}"
return f"user_{user_id}"
def _is_available(self) -> tuple[bool, Optional[str]]:
"""
判断 LightRAG 是否具备运行条件。
- import 失败或配置错误时返回 False 和原因
"""
if not _LIGHTRAG_AVAILABLE:
return False, f"LightRAG 导入失败: {self._availability_error or _IMPORT_ERROR}"
if self._availability_error:
# 缺少关键配置时也视为不可用
return False, self._availability_error
return True, None
async def _wait_with_timeout(self, coro: Any, *, timeout: float = 6.0) -> Any:
"""为外部调用包一层超时,避免卡住 worker / healthcheck。"""
return await asyncio.wait_for(coro, timeout=timeout)
async def _get_instance(self, workspace: str) -> LightRAG:
if workspace in self._instances:
return self._instances[workspace]
lock = self._locks.setdefault(workspace, asyncio.Lock())
async with lock:
if workspace in self._instances:
return self._instances[workspace]
rag = LightRAG(
working_dir=str(self._working_dir),
workspace=workspace,
kv_storage="PGKVStorage",
vector_storage="PGVectorStorage",
graph_storage="PGGraphStorage",
doc_status_storage="PGDocStatusStorage",
llm_model_func=gpt_4o_mini_complete,
embedding_func=openai_embed,
)
await self._wait_with_timeout(rag.initialize_storages())
if not self._pipeline_ready:
await self._wait_with_timeout(initialize_pipeline_status())
self._pipeline_ready = True
self._instances[workspace] = rag
lightrag_logger.info("LightRAG workspace %s ready", workspace)
return rag
async def index_document_async(
self,
*,
document_id: str,
user_id: str,
workspace_id: Optional[str],
text: str,
title: Optional[str] = None,
) -> str:
workspace = self._namespace(workspace_id, user_id)
rag = await self._get_instance(workspace)
clean_text = text.strip()
if not clean_text:
raise ValueError("空文本无法建立 LightRAG 索引")
file_path = f"doc://{document_id}"
if title:
file_path = f"{file_path}?title={title}"
track_id = await rag.ainsert(
clean_text,
ids=[document_id],
file_paths=[file_path],
)
return track_id
def index_document(
self,
*,
document_id: str,
user_id: str,
workspace_id: Optional[str],
text: str,
title: Optional[str] = None,
) -> str:
"""同步环境(如 Celery)调用的封装。"""
return asyncio.run(
self.index_document_async(
document_id=document_id,
user_id=user_id,
workspace_id=workspace_id,
text=text,
title=title,
)
)
async def query_async(
self,
*,
query_text: str,
user_id: str,
workspace_id: Optional[str],
stream: bool = True,
mode: str = "mix",
) -> Dict[str, Any]:
workspace = self._namespace(workspace_id, user_id)
rag = await self._get_instance(workspace)
param = QueryParam(mode=mode)
param.stream = stream
result = await self._wait_with_timeout(rag.aquery_llm(query_text, param))
return result
async def stream_answer(
self,
*,
query_text: str,
user_id: str,
workspace_id: Optional[str],
) -> Dict[str, Any]:
"""统一返回结构,包含 SSE 需要的 iterator 与引用。"""
ok, reason = self._is_available()
if not ok:
return {
"references": [],
"iterator": iter(()),
"content": f"LightRAG 未就绪:{reason}",
"is_streaming": False,
"metadata": {"skipped": True, "reason": reason},
}
result = await self.query_async(
query_text=query_text,
user_id=user_id,
workspace_id=workspace_id,
stream=True,
)
llm_resp = result.get("llm_response", {})
references: List[Dict[str, Any]] = (
result.get("data", {}).get("references", []) or []
)
return {
"references": references,
"iterator": llm_resp.get("response_iterator"),
"content": llm_resp.get("content"),
"is_streaming": llm_resp.get("is_streaming", False),
"metadata": result.get("metadata", {}),
}
async def run_healthcheck(self) -> Dict[str, Any]:
workspace = "__healthcheck__"
ok, reason = self._is_available()
if not ok:
return {"ok": False, "workspace": workspace, "error": reason}
try:
rag = await self._get_instance(workspace)
await self._wait_with_timeout(rag.doc_status.initialize(), timeout=5.0)
return {"ok": True, "workspace": workspace}
except Exception as exc: # pragma: no cover - 调试辅助
return {"ok": False, "workspace": workspace, "error": str(exc)}
lightrag_service = LightRAGService() lightrag_service = LightRAGService()
+68 -1
View File
@@ -1,7 +1,60 @@
"""MinerU OCR 占位服务。""" """MinerU OCR 服务封装,支持调用本地 MinerU HTTP 接口"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional, Tuple
import httpx
class MinerUServiceError(RuntimeError):
"""MinerU 调用失败时抛出的异常。"""
class MinerUService: class MinerUService:
def __init__(self) -> None:
self.endpoint = os.getenv("MINERU_ENDPOINT", "http://127.0.0.1:18888").rstrip("/")
timeout = float(os.getenv("MINERU_TIMEOUT_SECONDS", "300"))
# MinerU 首次加载模型可能耗时较长,这里提高超时时间避免大文件 OCR 直接失败
self._client = httpx.Client(timeout=timeout)
def _request_markdown(self, file_path: str) -> Tuple[Optional[str], dict]:
"""
调用 MinerU /file_parse,返回 (markdown, raw_response)。
若接口未返回内容则返回 (None, response_dict)。
"""
if not self.endpoint:
raise MinerUServiceError("未配置 MINERU_ENDPOINT,无法调用 MinerU")
url = f"{self.endpoint}/file_parse"
file_name = Path(file_path).name
backend = os.getenv("MINERU_DEFAULT_BACKEND", "vlm-transformers")
model_path = os.getenv("MINERU_MODEL_PATH")
with open(file_path, "rb") as fp:
response = self._client.post(
url,
files={"files": (file_name, fp, "application/octet-stream")},
data={
"return_md": "true",
"return_content_list": "false",
"return_middle_json": "false",
"response_format_zip": "false",
"backend": backend,
# fast_api main 会从环境注入 model_path;这里双保险随请求传递
**({"model_path": model_path} if model_path else {}),
},
)
response.raise_for_status()
payload = response.json()
results = payload.get("results") or {}
if not isinstance(results, dict) or not results:
return None, payload
first_key = next(iter(results.keys()))
md_content = results.get(first_key, {}).get("md_content")
return md_content, payload
async def extract_markdown(self, file_url: str) -> str: async def extract_markdown(self, file_url: str) -> str:
""" """
阶段 0:直接返回固定内容,保证前端流程贯通。 阶段 0:直接返回固定内容,保证前端流程贯通。
@@ -9,5 +62,19 @@ class MinerUService:
""" """
return f"# OCR Placeholder\n\n源文件:{file_url}" return f"# OCR Placeholder\n\n源文件:{file_url}"
def extract_markdown_sync(self, file_path: str) -> str:
"""
Celery 任务使用的同步封装。
- 若 MinerU 服务可用:调用 HTTP 接口返回 markdown
- 若失败:抛出 MinerUServiceError 让上层标记失败
"""
try:
markdown, raw = self._request_markdown(file_path)
if markdown:
return markdown
raise MinerUServiceError(f"MinerU 未返回 md_content,响应片段:{str(raw)[:300]}")
except Exception as exc: # pragma: no cover - IO/网络异常
raise MinerUServiceError(f"MinerU 调用失败:{exc}") from exc
mineru_service = MinerUService() mineru_service = MinerUService()
@@ -0,0 +1,37 @@
"""简单的文件下载器,负责将 Supabase Storage 签名 URL 暂存到临时目录。"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import httpx
class StorageFetcher:
def __init__(self) -> None:
self._client = httpx.Client(timeout=30.0)
def download(self, file_url: str) -> str:
response = self._client.get(file_url)
response.raise_for_status()
suffix = Path(urlparse(file_url).path).suffix or ".bin"
fd, path = tempfile.mkstemp(suffix=suffix)
try:
os.write(fd, response.content)
finally:
os.close(fd)
return path
def cleanup(self, path: Optional[str]) -> None:
if path and os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
storage_fetcher = StorageFetcher()
+28 -1
View File
@@ -12,10 +12,11 @@ class SupabaseRestClient:
def __init__(self) -> None: def __init__(self) -> None:
base_url = settings.supabase_url.rstrip("/") base_url = settings.supabase_url.rstrip("/")
apikey = settings.supabase_anon_key or settings.supabase_service_role_key
self.client = httpx.Client( self.client = httpx.Client(
base_url=f"{base_url}/rest/v1", base_url=f"{base_url}/rest/v1",
headers={ headers={
"apikey": settings.supabase_service_role_key, "apikey": apikey,
"Authorization": f"Bearer {settings.supabase_service_role_key}", "Authorization": f"Bearer {settings.supabase_service_role_key}",
}, },
timeout=10.0, timeout=10.0,
@@ -43,6 +44,32 @@ class SupabaseRestClient:
return data[0] return data[0]
return None return None
def select(
self,
table: str,
filters: Optional[Dict[str, Any]] = None,
*,
columns: str = "*",
order: Optional[str] = None,
limit: Optional[int] = None,
) -> list[Dict[str, Any]]:
params: Dict[str, Any] = {"select": columns}
if filters:
for key, value in filters.items():
params[key] = f"eq.{value}"
if order:
params["order"] = order
if limit is not None:
params["limit"] = limit
response = self.client.get(f"/{table}", params=params)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
if data:
return [data]
return []
def update(self, table: str, filters: Dict[str, Any], payload: Dict[str, Any]) -> None: def update(self, table: str, filters: Dict[str, Any], payload: Dict[str, Any]) -> None:
params = {key: f"eq.{value}" for key, value in filters.items()} params = {key: f"eq.{value}" for key, value in filters.items()}
response = self.client.patch( response = self.client.patch(
+39 -1
View File
@@ -1,15 +1,53 @@
import os
from celery import Celery from celery import Celery
from kombu import Queue
from app.config import settings from app.config import settings
def _env_flag(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def create_celery_app() -> Celery: def create_celery_app() -> Celery:
app = Celery("wolai-backend") app = Celery("wolai-backend")
app.conf.broker_url = settings.redis_url app.conf.broker_url = settings.redis_url
app.conf.result_backend = settings.redis_url app.conf.result_backend = settings.redis_url
app.conf.task_routes = {"app.workers.tasks.*": {"queue": "wolai-tasks"}} app.conf.task_default_queue = "wolai-index"
app.conf.task_queues = (
Queue("wolai-ocr", routing_key="tasks.ocr", max_priority=10),
Queue("wolai-index", routing_key="tasks.index", max_priority=10),
)
app.conf.task_routes = {
"app.workers.tasks.ocr_pipeline": {
"queue": "wolai-ocr",
"routing_key": "tasks.ocr",
"priority": 0,
},
"app.workers.tasks.media_ocr_pipeline": {
"queue": "wolai-ocr",
"routing_key": "tasks.ocr",
"priority": 1,
},
"app.workers.tasks.lightrag_index_pipeline": {
"queue": "wolai-index",
"routing_key": "tasks.index",
"priority": 5,
},
}
app.conf.broker_transport_options = {"priority_steps": list(range(10))}
app.conf.worker_prefetch_multiplier = 1
app.conf.task_acks_late = True
app.conf.worker_concurrency = 3
app.conf.task_always_eager = _env_flag("CELERY_TASK_ALWAYS_EAGER", False)
app.conf.task_eager_propagates = _env_flag("CELERY_TASK_EAGER_PROPAGATES", True)
app.autodiscover_tasks(["app.workers"]) app.autodiscover_tasks(["app.workers"])
return app return app
celery_app = create_celery_app() celery_app = create_celery_app()
celery_app.set_default()
+158 -8
View File
@@ -1,21 +1,34 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict import logging
from typing import Dict, Optional
from celery import shared_task from celery import shared_task
from app.services.lightrag_service import lightrag_service
from app.services.mineru_service import mineru_service
from app.services.storage_fetcher import storage_fetcher
from app.services.supabase_rest import supabase_rest from app.services.supabase_rest import supabase_rest
from app.services.task_tracker import task_tracker
from app.workers.utils import dispatch_task
logger = logging.getLogger(__name__)
@shared_task(name="app.workers.tasks.ocr_pipeline") @shared_task(name="app.workers.tasks.ocr_pipeline")
def ocr_pipeline(task_id: str, document_id: str, file_url: str, user_id: str) -> Dict[str, str]: def ocr_pipeline(task_id: str, document_id: str, file_url: str, user_id: str) -> Dict[str, str]:
""" """
阶段 0 Celery 任务:模拟 OCR,向 documents+background_tasks 回写占位结果 阶段 0 Celery 任务:调用 MinerU 占位实现并写回 documents/background_tasks。
阶段 1 在此处串联 MinerU OCR 与 LightRAG 索引 阶段 1 在此处替换为真实的 MinerU CLI / LightRAG 流程
""" """
supabase_rest.update("background_tasks", {"id": task_id}, {"status": "processing", "progress": 30}) supabase_rest.update("background_tasks", {"id": task_id}, {"status": "processing", "progress": 30})
status = "completed"
message = "OCR 完成"
markdown = f"# OCR 结果占位\\n\\n文件地址:{file_url}\\n\\n> 阶段 1 将替换为 MinerU 输出。" file_path = None
try:
file_path = storage_fetcher.download(file_url)
markdown = mineru_service.extract_markdown_sync(file_path)
supabase_rest.update( supabase_rest.update(
"documents", "documents",
{"id": document_id, "user_id": user_id}, {"id": document_id, "user_id": user_id},
@@ -29,17 +42,27 @@ def ocr_pipeline(task_id: str, document_id: str, file_url: str, user_id: str) ->
] ]
}, },
"raw_text": markdown, "raw_text": markdown,
"index_status": "completed", "index_status": "pending",
}, },
) )
supabase_rest.update("background_tasks", {"id": task_id}, {"progress": 80})
index_task = task_tracker.create_task(
user_id=user_id, document_id=document_id, task_type="index"
)
dispatch_task(lightrag_index_pipeline, index_task.task_id, document_id, user_id)
except Exception as exc: # pragma: no cover - 网络/IO异常
status = "failed"
message = f"OCR 失败:{exc}"
finally:
storage_fetcher.cleanup(file_path)
supabase_rest.update( supabase_rest.update(
"background_tasks", "background_tasks",
{"id": task_id}, {"id": task_id},
{ {
"status": "completed", "status": status,
"progress": 100, "progress": 100,
"message": "OCR 模拟完成", "message": message,
}, },
) )
@@ -47,5 +70,132 @@ def ocr_pipeline(task_id: str, document_id: str, file_url: str, user_id: str) ->
"task_id": task_id, "task_id": task_id,
"document_id": document_id, "document_id": document_id,
"file_url": file_url, "file_url": file_url,
"status": "completed", "status": status,
} }
@shared_task(name="app.workers.tasks.media_ocr_pipeline")
def media_ocr_pipeline(asset_id: str, user_id: str, document_id: Optional[str] = None) -> Dict[str, str]:
"""媒体 OCR:下载资源 → 调用 MinerU → 写回 ocr_text/ocr_payload。"""
logger.info("媒体 OCR 任务执行 asset_id=%s user_id=%s", asset_id, user_id)
asset = supabase_rest.select_one("media_assets", {"id": asset_id})
if not asset:
logger.warning("媒体 OCR 任务失败:资源不存在 %s", asset_id)
return {"asset_id": asset_id, "status": "failed"}
file_url = asset.get("file_url")
ocr_status = "completed"
ocr_text: Optional[str] = None
message = "媒体 OCR 完成"
file_path = None
try:
file_path = storage_fetcher.download(str(file_url))
ocr_text = mineru_service.extract_markdown_sync(file_path)
except Exception as exc: # pragma: no cover - 网络/IO异常
logger.warning("媒体 OCR 失败 asset=%s err=%s", asset_id, exc)
ocr_status = "failed"
message = f"OCR 失败:{exc}"
finally:
storage_fetcher.cleanup(file_path)
supabase_rest.update(
"media_assets",
{"id": asset_id},
{
"ocr_status": ocr_status,
"ocr_text": ocr_text,
"ocr_payload": {"pages": [], "summary": ocr_text} if ocr_text else None,
},
)
return {
"asset_id": asset_id,
"document_id": document_id,
"user_id": user_id,
"status": ocr_status,
"message": message,
}
@shared_task(name="app.workers.tasks.lightrag_index_pipeline")
def lightrag_index_pipeline(task_id: str, document_id: str, user_id: str) -> Dict[str, str]:
"""执行 LightRAG 索引构建,独立于 OCR。"""
supabase_rest.update(
"background_tasks",
{"id": task_id},
{"status": "processing", "progress": 10, "message": "LightRAG 索引排队中"},
)
document = supabase_rest.select_one("documents", {"id": document_id, "user_id": user_id})
if not document:
supabase_rest.update(
"background_tasks",
{"id": task_id},
{"status": "failed", "progress": 100, "message": "文档不存在"},
)
return {"status": "failed", "document_id": document_id}
# 先做健康检查,避免长时间阻塞或无谓重试
try:
health = asyncio.run(lightrag_service.run_healthcheck())
except Exception as exc: # pragma: no cover - 调试辅助
health = {"ok": False, "error": str(exc)}
if not health.get("ok"):
reason = health.get("error", "LightRAG 未就绪")
supabase_rest.update(
"background_tasks",
{"id": task_id},
{"status": "failed", "progress": 100, "message": reason},
)
supabase_rest.update(
"documents", {"id": document_id}, {"index_status": "failed"}
)
return {"status": "failed", "document_id": document_id, "error": reason}
raw_text = document.get("raw_text") or ""
text_to_index = str(raw_text).strip()
if not text_to_index:
supabase_rest.update(
"background_tasks",
{"id": task_id},
{"status": "failed", "progress": 100, "message": "raw_text 为空,无法索引"},
)
return {"status": "failed", "document_id": document_id}
workspace_id = document.get("workspace_id")
title = document.get("title")
supabase_rest.update(
"documents", {"id": document_id}, {"index_status": "processing"}
)
try:
supabase_rest.update(
"background_tasks",
{"id": task_id},
{"progress": 40, "message": "LightRAG 建立中"},
)
lightrag_service.index_document(
document_id=document_id,
user_id=user_id,
workspace_id=str(workspace_id) if workspace_id else None,
text=text_to_index,
title=title if isinstance(title, str) else None,
)
supabase_rest.update(
"documents", {"id": document_id}, {"index_status": "completed"}
)
supabase_rest.update(
"background_tasks",
{"id": task_id},
{"progress": 100, "status": "completed", "message": "LightRAG 完成"},
)
return {"status": "completed", "document_id": document_id}
except Exception as exc: # pragma: no cover - 调试错误
supabase_rest.update(
"documents", {"id": document_id}, {"index_status": "failed"}
)
supabase_rest.update(
"background_tasks",
{"id": task_id},
{
"progress": 100,
"status": "failed",
"message": f"LightRAG 失败:{exc}",
},
)
return {"status": "failed", "document_id": document_id, "error": str(exc)}
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
import logging
import os
from typing import Any, Callable
logger = logging.getLogger(__name__)
def _env_flag(name: str) -> bool:
value = os.getenv(name)
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def dispatch_task(task: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""
根据环境变量决定直接执行任务还是通过 Celery delay。
本地开发默认走 eager,同步执行便于调试。
"""
eager = _env_flag("CELERY_TASK_ALWAYS_EAGER")
logger.info("dispatch_task task=%s eager=%s", getattr(task, "name", task.__name__), eager)
if _env_flag("CELERY_TASK_ALWAYS_EAGER"):
return task.apply(args=args, kwargs=kwargs)
return task.delay(*args, **kwargs)
+1
View File
@@ -9,3 +9,4 @@ openai==1.59.5
python-multipart==0.0.17 python-multipart==0.0.17
pydantic-settings==2.6.1 pydantic-settings==2.6.1
typing_extensions==4.12.2 typing_extensions==4.12.2
-e ../LightRAG
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import argparse
import asyncio
from typing import Iterable
from app.services.lightrag_service import lightrag_service
from app.services.supabase_rest import supabase_rest
def fetch_documents(
workspace_id: str | None,
limit: int | None,
) -> Iterable[dict]:
filters = {}
if workspace_id:
filters["workspace_id"] = workspace_id
docs = supabase_rest.select(
"documents",
filters,
columns="id,raw_text,user_id,workspace_id,title",
order="updated_at.desc",
limit=limit,
)
return docs
async def rebuild_indexes(workspace_id: str | None, limit: int | None) -> None:
documents = fetch_documents(workspace_id, limit)
for doc in documents:
text = str(doc.get("raw_text") or "").strip()
if not text:
continue
document_id = str(doc.get("id"))
user_id = str(doc.get("user_id"))
workspace = str(doc.get("workspace_id")) if doc.get("workspace_id") else None
title = doc.get("title") if isinstance(doc.get("title"), str) else None
print(f"[LightRAG:init] indexing {document_id} ({workspace or 'user'})") # noqa: T201
supabase_rest.update(
"documents", {"id": document_id}, {"index_status": "pending"}
)
await lightrag_service.index_document_async(
document_id=document_id,
user_id=user_id,
workspace_id=workspace,
text=text,
title=title,
)
supabase_rest.update(
"documents", {"id": document_id}, {"index_status": "completed"}
)
def main() -> None:
parser = argparse.ArgumentParser(description="初始化 LightRAG 索引")
parser.add_argument("--workspace", help="仅处理指定 workspace", default=None)
parser.add_argument("--limit", type=int, help="限制处理文档数量", default=None)
args = parser.parse_args()
asyncio.run(rebuild_indexes(args.workspace, args.limit))
if __name__ == "__main__":
main()
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { blocksToPlainText, normalizeBlocksFromContent } from "@/lib/documents/plain-text";
interface SavePayload { interface SavePayload {
documentId: string; documentId: string;
@@ -17,10 +18,17 @@ export async function POST(request: Request) {
} }
const { documentId, content }: SavePayload = await request.json(); const { documentId, content }: SavePayload = await request.json();
const blocks = normalizeBlocksFromContent(content);
const rawText = blocksToPlainText(blocks);
const updatePayload: Record<string, unknown> = { content, index_status: "pending" };
if (rawText) {
updatePayload.raw_text = rawText;
}
const { error } = await supabase const { error } = await supabase
.from("documents") .from("documents")
.update({ content }) .update(updatePayload)
.eq("id", documentId) .eq("id", documentId)
.eq("user_id", session.user.id); .eq("user_id", session.user.id);
@@ -28,5 +36,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: error.message }, { status: 400 }); return NextResponse.json({ error: error.message }, { status: 400 });
} }
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (backendUrl && rawText) {
void fetch(`${backendUrl}/api/v1/lightrag/index`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ document_id: documentId }),
}).catch((err) => {
console.warn("[lightrag-index] trigger failed", err);
});
}
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -90,3 +90,26 @@ export async function POST(request: Request) {
return NextResponse.json({ asset: data as MediaAsset }); return NextResponse.json({ asset: data as MediaAsset });
} }
export async function DELETE(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId } = (await request.json().catch(() => ({}))) as { assetId?: string };
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const { error } = await supabase.from("media_assets").delete().eq("id", assetId).limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -15,6 +15,7 @@ export async function POST(request: Request) {
if (!assetId) { if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
} }
console.log("media ocr api called", assetId, Boolean(session?.access_token));
const { error } = await supabase const { error } = await supabase
.from("media_assets") .from("media_assets")
@@ -28,10 +29,13 @@ export async function POST(request: Request) {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL; const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (backendUrl) { if (backendUrl) {
console.log("media ocr session token prefix", session.access_token?.slice(0, 8));
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, { void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
"X-Supabase-Access-Token": session.access_token,
}, },
body: JSON.stringify({ asset_id: assetId }), body: JSON.stringify({ asset_id: assetId }),
}).catch((err) => { }).catch((err) => {
@@ -1,15 +1,21 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useSessionContext } from "@supabase/auth-helpers-react"; import { useSessionContext, useSupabaseClient } from "@supabase/auth-helpers-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import type { Database } from "@/types/supabase";
type BackgroundTaskRow = Database["public"]["Tables"]["background_tasks"]["Row"];
interface TaskResponse { interface TaskResponse {
task_id: string; task_id: string;
status: string; status: string;
progress: number; progress: number;
message?: string | null; message?: string | null;
task_type?: string | null;
created_at?: string | null;
} }
interface Props { interface Props {
@@ -18,12 +24,62 @@ interface Props {
export function DocumentTaskPanel({ documentId }: Props) { export function DocumentTaskPanel({ documentId }: Props) {
const { session } = useSessionContext(); const { session } = useSessionContext();
const [task, setTask] = useState<TaskResponse | null>(null); const supabase = useSupabaseClient<Database>();
const [activeTask, setActiveTask] = useState<TaskResponse | null>(null);
const [recentTasks, setRecentTasks] = useState<TaskResponse[]>([]);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [fileUrl, setFileUrl] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const backendUrl = useMemo(() => process.env.NEXT_PUBLIC_BACKEND_URL, []); const backendUrl = useMemo(() => process.env.NEXT_PUBLIC_BACKEND_URL, []);
const toTaskResponse = useCallback((record: BackgroundTaskRow | null): TaskResponse | null => {
if (!record) return null;
return {
task_id: String(record.id ?? ""),
status: record.status ?? "pending",
progress: record.progress ?? 0,
message: record.message,
task_type: record.task_type ?? "ocr",
created_at: record.created_at,
};
}, []);
const upsertTask = useCallback(
(record: BackgroundTaskRow | null) => {
const parsed = toTaskResponse(record);
if (!parsed) return;
setRecentTasks((prev) => {
const next = prev.filter((item) => item.task_id !== parsed.task_id);
next.unshift(parsed);
return next.slice(0, 5);
});
setActiveTask((current) => {
if (current?.task_id === parsed.task_id) {
return parsed;
}
return current;
});
},
[toTaskResponse],
);
const fetchRecentTasks = useCallback(async () => {
const { data, error } = await supabase
.from("background_tasks")
.select("*")
.eq("document_id", documentId)
.order("created_at", { ascending: false })
.limit(5);
if (error) {
console.warn("加载任务失败", error);
return;
}
const next = (data ?? []).map((row) => toTaskResponse(row)).filter((row): row is TaskResponse => Boolean(row));
setRecentTasks(next);
}, [documentId, supabase, toTaskResponse]);
const triggerTask = async () => { const triggerTask = async () => {
if (!backendUrl || !session?.access_token) return; if (!backendUrl || !session?.access_token || !fileUrl) return;
setPending(true); setPending(true);
try { try {
const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, { const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, {
@@ -34,12 +90,23 @@ export function DocumentTaskPanel({ documentId }: Props) {
}, },
body: JSON.stringify({ body: JSON.stringify({
document_id: documentId, document_id: documentId,
file_url: "https://example.com/sample.pdf", file_url: fileUrl,
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
setTask(data); const nextTask: TaskResponse = {
task_id: data.task_id,
status: data.status,
progress: data.progress,
message: data.message,
task_type: "ocr",
};
setActiveTask(nextTask);
setRecentTasks((prev) => [nextTask, ...prev.filter((item) => item.task_id !== nextTask.task_id)].slice(0, 5));
setErrorMessage(null);
} else {
setErrorMessage(data?.detail ?? data?.error ?? "触发 OCR 失败");
} }
} finally { } finally {
setPending(false); setPending(false);
@@ -47,40 +114,76 @@ export function DocumentTaskPanel({ documentId }: Props) {
}; };
useEffect(() => { useEffect(() => {
if (!backendUrl || !session?.access_token || !task?.task_id) { void fetchRecentTasks();
return; }, [fetchRecentTasks]);
}
const timer = setInterval(async () => { useEffect(() => {
const response = await fetch(`${backendUrl}/api/v1/tasks/${task.task_id}`, { const channel = supabase
headers: { .channel(`bg-task-doc-${documentId}`)
Authorization: `Bearer ${session.access_token}`, .on(
"postgres_changes",
{ event: "*", schema: "public", table: "background_tasks", filter: `document_id=eq.${documentId}` },
(payload) => {
upsertTask((payload.new as BackgroundTaskRow) ?? null);
}, },
}); )
if (!response.ok) { .subscribe();
return;
} return () => {
const data = (await response.json()) as TaskResponse; void supabase.removeChannel(channel);
setTask(data); };
if (data.status === "completed") { }, [documentId, supabase, upsertTask]);
clearInterval(timer);
} const taskTypeLabel = (taskType?: string | null) => {
}, 2000); if (taskType === "index") return "LightRAG 索引";
return () => clearInterval(timer); if (taskType === "ocr") return "OCR 解析";
}, [backendUrl, session?.access_token, task?.task_id]); return taskType ?? "任务";
};
return ( return (
<Card className="mt-4 bg-white shadow-sm"> <Card className="mt-4 bg-white shadow-sm">
<CardContent className="flex items-center justify-between py-3 text-sm text-gray-600"> <CardContent className="flex flex-col gap-4 py-3 text-sm text-gray-600">
<div> <div>
<div className="font-medium text-gray-900"> OCR </div> <div className="font-medium text-gray-900"></div>
<div className="mt-2 space-y-2 text-xs text-gray-500">
{recentTasks.length === 0 ? (
<div></div>
) : (
recentTasks.map((task) => (
<div
key={task.task_id}
className="rounded border border-gray-100 bg-gray-50 px-3 py-2 text-gray-700 dark:bg-gray-900/30"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-900">{taskTypeLabel(task.task_type)}</span>
<span>{task.progress}%</span>
</div>
<div className="text-xs text-gray-500">{task.status}</div>
{task.message && <div className="text-xs text-gray-500">{task.message}</div>}
</div>
))
)}
</div>
</div>
<div className="flex items-center justify-between gap-6 text-sm text-gray-600">
<div className="flex flex-col gap-2">
<div className="font-medium text-gray-900"> OCR</div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{task ? task.status : "未开始"} · {task ? `${task.progress}%` : "0%"} {activeTask ? activeTask.status : "未开始"} · {activeTask ? `${activeTask.progress}%` : "0%"}
</div> </div>
{task?.message && <div className="text-xs text-gray-500">{task.message}</div>} {activeTask?.message && <div className="text-xs text-gray-500">{activeTask.message}</div>}
<Input
placeholder="输入 Supabase Storage 签名 URL"
value={fileUrl}
onChange={(event) => setFileUrl(event.target.value)}
className="h-8 text-xs"
/>
{errorMessage && <div className="text-xs text-red-500">{errorMessage}</div>}
</div> </div>
<Button onClick={triggerTask} disabled={pending} variant="outline"> <Button onClick={triggerTask} disabled={pending || !fileUrl} variant="outline">
{pending ? "触发中..." : "触发 OCR"} {pending ? "触发中..." : "触发 OCR"}
</Button> </Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -4,7 +4,7 @@ import "@blocknote/core/style.css";
import "@blocknote/react/style.css"; import "@blocknote/react/style.css";
import "@blocknote/mantine/style.css"; import "@blocknote/mantine/style.css";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine"; import { BlockNoteView } from "@blocknote/mantine";
import { import {
SideMenuController, SideMenuController,
@@ -212,6 +212,8 @@ export function BlockNoteEditor({
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]); const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null); const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
const isFullScreenTableOpen = fullScreenTableId !== null; const isFullScreenTableOpen = fullScreenTableId !== null;
const mediaAssetIdsRef = useRef<Set<string>>(new Set());
const mediaAssetIdsInitializedRef = useRef(false);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference); const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge); const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
@@ -290,6 +292,28 @@ export function BlockNoteEditor({
const blocks = editor.topLevelBlocks; const blocks = editor.topLevelBlocks;
debouncedSave(blocks as Json); debouncedSave(blocks as Json);
const typedBlocks = blocks as Block<CustomBlockSchema>[]; const typedBlocks = blocks as Block<CustomBlockSchema>[];
const currentAssetIds = collectMediaAssetIds(typedBlocks);
if (mediaAssetIdsInitializedRef.current) {
const removedAssetIds: string[] = [];
mediaAssetIdsRef.current.forEach((id) => {
if (!currentAssetIds.has(id)) {
removedAssetIds.push(id);
}
});
if (removedAssetIds.length > 0) {
void Promise.allSettled(
removedAssetIds.map((assetId) =>
fetch("/api/media/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId }),
}),
),
);
}
}
mediaAssetIdsRef.current = currentAssetIds;
mediaAssetIdsInitializedRef.current = true;
setTocEntries(buildHeadingToc(typedBlocks)); setTocEntries(buildHeadingToc(typedBlocks));
syncProgressMeters(editor); syncProgressMeters(editor);
const stats = computeDocumentStats(typedBlocks); const stats = computeDocumentStats(typedBlocks);
@@ -399,6 +423,25 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
}; };
}; };
const collectMediaAssetIds = (blocks: Block<CustomBlockSchema>[]): Set<string> => {
const ids = new Set<string>();
const traverse = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "media") {
const assetId = block.props.assetId as string | undefined;
if (assetId) {
ids.add(assetId);
}
}
if (block.children && block.children.length > 0) {
traverse(block.children as Block<CustomBlockSchema>[]);
}
});
};
traverse(blocks);
return ids;
};
const insertMediaAssetBlock = useCallback( const insertMediaAssetBlock = useCallback(
(asset: MediaAsset) => { (asset: MediaAsset) => {
if (!editor) { if (!editor) {
@@ -33,6 +33,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { CustomBlockSchema } from "../schema"; import type { CustomBlockSchema } from "../schema";
import { supabaseBrowser } from "@/lib/supabase/client";
type MediaAlign = "left" | "center" | "right"; type MediaAlign = "left" | "center" | "right";
@@ -260,6 +261,47 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
} }
}; };
useEffect(() => {
// 监听 media_assets 变化,实时同步 OCR 状态
const assetId = block.props.assetId as string | undefined;
if (!assetId) return undefined;
let active = true;
const syncStatus = async () => {
const { data } = await supabaseBrowser
.from("media_assets")
.select("ocr_status")
.eq("id", assetId)
.maybeSingle();
if (!active || !data?.ocr_status) return;
if (data.ocr_status !== block.props.ocrStatus) {
editor.updateBlock(block, { props: { ocrStatus: data.ocr_status } });
}
};
void syncStatus();
const channel = supabaseBrowser
.channel(`media-asset-${assetId}`)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "media_assets", filter: `id=eq.${assetId}` },
(payload) => {
const nextStatus = (payload.new as { ocr_status?: string } | null)?.ocr_status;
if (nextStatus && nextStatus !== block.props.ocrStatus) {
editor.updateBlock(block, { props: { ocrStatus: nextStatus } });
}
},
)
.subscribe();
return () => {
active = false;
void supabaseBrowser.removeChannel(channel);
};
}, [block, editor]);
if (!fileUrl) { if (!fileUrl) {
return ( return (
<div className="wolai-media wolai-media--empty"> <div className="wolai-media wolai-media--empty">
@@ -460,7 +502,9 @@ const figure = (
</> </>
)} )}
</div> </div>
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div> <div className="wolai-media__hint">
{block.props.ocrStatus === "processing" ? "OCR 处理中..." : block.props.ocrStatus === "completed" ? "OCR 已完成" : ""}
</div>
</div> </div>
); );
}; };
@@ -73,6 +73,16 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
void removePageReference(); void removePageReference();
return; return;
} }
if (block.type === "media") {
const assetId = block.props.assetId as string | undefined;
if (assetId) {
void fetch("/api/media/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId }),
}).catch((error) => console.warn("删除媒体资源失败", error));
}
}
if (block.type === "onlineTable") { if (block.type === "onlineTable") {
const tableId = block.props.tableId as string | undefined; const tableId = block.props.tableId as string | undefined;
if (tableId) { if (tableId) {
@@ -0,0 +1,74 @@
import type { Json } from "@/types/supabase";
type BlockNode = {
id?: string;
type?: string;
content?: unknown;
children?: unknown;
props?: Record<string, unknown>;
};
const toBlockArray = (value: unknown): BlockNode[] => {
if (Array.isArray(value)) {
return value.filter((item): item is BlockNode => typeof item === "object" && item !== null);
}
if (value && typeof value === "object" && Array.isArray((value as Record<string, unknown>).blocks)) {
return (value as { blocks: Json[] }).blocks.filter(
(item): item is BlockNode => typeof item === "object" && item !== null,
);
}
return [];
};
const extractInlineText = (value: unknown): string => {
if (!Array.isArray(value)) {
return "";
}
return value
.map((node) => {
if (!node || typeof node !== "object") {
return "";
}
const candidate = node as { text?: unknown; content?: unknown; children?: unknown };
if (typeof candidate.text === "string") {
return candidate.text;
}
if (Array.isArray(candidate.children)) {
return extractInlineText(candidate.children);
}
if (Array.isArray(candidate.content)) {
return extractInlineText(candidate.content);
}
return "";
})
.join(" ")
.replace(/\s+/g, " ")
.trim();
};
const flattenBlocks = (blocks: BlockNode[], lines: string[]) => {
blocks.forEach((block) => {
const inlineText = extractInlineText(block.content);
if (inlineText) {
lines.push(inlineText);
}
if (Array.isArray(block.children)) {
const nextChildren = block.children.filter(
(item): item is BlockNode => typeof item === "object" && item !== null,
);
if (nextChildren.length > 0) {
flattenBlocks(nextChildren, lines);
}
}
});
};
export const normalizeBlocksFromContent = (content: unknown): BlockNode[] => {
return toBlockArray(content);
};
export const blocksToPlainText = (blocks: BlockNode[]): string => {
const lines: string[] = [];
flattenBlocks(blocks, lines);
return lines.join("\n").trim();
};
+43
View File
@@ -9,6 +9,49 @@ export type Json =
export type Database = { export type Database = {
public: { public: {
Tables: { Tables: {
background_tasks: {
Row: {
id: string;
user_id: string;
document_id: string | null;
task_type: string;
status: string;
progress: number;
message: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
user_id: string;
document_id?: string | null;
task_type?: string;
status?: string;
progress?: number;
message?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
user_id?: string;
document_id?: string | null;
task_type?: string;
status?: string;
progress?: number;
message?: string | null;
created_at?: string;
updated_at?: string;
};
Relationships: [
{
foreignKeyName: "background_tasks_document_id_fkey";
columns: ["document_id"];
referencedRelation: "documents";
referencedColumns: ["id"];
},
];
};
documents: { documents: {
Row: { Row: {
access_scope: "private" | "shared" | "public"; access_scope: "private" | "shared" | "public";