@youweichen/pi-harness 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +14 -0
- package/dist/server/agent-service.js +87 -7
- package/dist/server/control-socket.js +1 -0
- package/dist/server/document-conversion/extension.js +164 -0
- package/dist/server/document-conversion/python/bridge.py +424 -0
- package/dist/server/document-conversion/python/fixtures.py +201 -0
- package/dist/server/document-conversion/python/native_code.py +149 -0
- package/dist/server/document-conversion/runtime.js +321 -0
- package/dist/server/document-conversion/service.js +340 -0
- package/dist/server/document-conversion/settings.js +63 -0
- package/dist/server/extensions-worker.js +3 -2
- package/dist/server/files-service.js +8 -16
- package/dist/server/index.js +9 -3
- package/dist/server/native-tools.js +4 -0
- package/dist/server/node-agent.js +262 -0
- package/dist/server/node-workbench.js +62 -1
- package/dist/server/okf/extension.js +118 -0
- package/dist/server/okf/markdown-sources.js +58 -0
- package/dist/server/okf/service.js +713 -0
- package/dist/server/okf/storage.js +165 -0
- package/dist/server/okf/types.js +1 -0
- package/dist/server/plan/extension.js +4 -4
- package/dist/server/plan/state.js +11 -1
- package/dist/server/slash-commands.js +6 -0
- package/dist/server/wiki-links.js +24 -0
- package/dist/server/wiki-routes.js +8 -1
- package/dist/server/wiki-service.js +50 -5
- package/package.json +2 -2
- package/web/dist/assets/NodeWorkbench-DM6YTS5e.js +3 -0
- package/web/dist/assets/{TerminalPanel-CWh6vw_N.js → TerminalPanel-aFJylyxg.js} +1 -1
- package/web/dist/assets/{abnfDiagram-VCTEODGH-D5Ti0Kpa.js → abnfDiagram-VCTEODGH-CcM7f7oX.js} +1 -1
- package/web/dist/assets/{arc-DKJilLd6.js → arc-Bv7vS5l5.js} +1 -1
- package/web/dist/assets/{architectureDiagram-5GKGNRK7-Cf0w38g-.js → architectureDiagram-5GKGNRK7-Cl8QcB3w.js} +1 -1
- package/web/dist/assets/{blockDiagram-I7D4REHJ-BOTLWLIL.js → blockDiagram-I7D4REHJ-DarS2-4G.js} +1 -1
- package/web/dist/assets/{c4Diagram-7LVT6UL2-CLCCoJs3.js → c4Diagram-7LVT6UL2-BY0_ttBt.js} +1 -1
- package/web/dist/assets/channel-DJGMgVTh.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-BHjga2Ge.js → chunk-2Q5K7J3B-Cz-zS7_X.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-CQJ5VfDZ.js → chunk-5VM5RSS4-B7AyqkmI.js} +1 -1
- package/web/dist/assets/{chunk-F27PBJKO-C67qoPJH.js → chunk-F27PBJKO-B7hVduzL.js} +1 -1
- package/web/dist/assets/{chunk-IMKFNOWR-CgXXGwI8.js → chunk-IMKFNOWR-DNGJqYn5.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-D0RPyByP.js → chunk-JWPE2WC7-B5iJgXvl.js} +1 -1
- package/web/dist/assets/{chunk-POPQ4Y6H-BeYC9s9x.js → chunk-POPQ4Y6H-BvWCkriR.js} +1 -1
- package/web/dist/assets/{chunk-SVP7TREG-eQPl4UBQ.js → chunk-SVP7TREG-BcdlBuOQ.js} +1 -1
- package/web/dist/assets/{chunk-TICWLB2K-DT73arPU.js → chunk-TICWLB2K-B1igqjQd.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-B4FwQcP-.js → chunk-XXDRQBXY-DKGDquaw.js} +1 -1
- package/web/dist/assets/classDiagram-ZZMXUADV-D9nxgF0M.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-D9nxgF0M.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-Bnn5nlQN.js → cose-bilkent-JH36ORCC-BU_9nu88.js} +1 -1
- package/web/dist/assets/{cynefin-OW5HDTMX-CStWX9kE.js → cynefin-OW5HDTMX-DY0BDLjc.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-5FMLGOSQ-BMVHC03f.js → cynefinDiagram-5FMLGOSQ-B-zM25Fk.js} +1 -1
- package/web/dist/assets/{dagre-GXQ25YYZ-ClSaO4ml.js → dagre-GXQ25YYZ-BxooDyBc.js} +1 -1
- package/web/dist/assets/{diagram-S7CK7UJ4-C43LL08C.js → diagram-S7CK7UJ4-CCO-8de9.js} +1 -1
- package/web/dist/assets/{diagram-UQ7AKVKN-BPVn_ySW.js → diagram-UQ7AKVKN-CmGsH525.js} +1 -1
- package/web/dist/assets/{diagram-VSXAHHWV-C34FFhRi.js → diagram-VSXAHHWV-CSQJYo3m.js} +1 -1
- package/web/dist/assets/{diagram-VX7I27RA-BRk4bncf.js → diagram-VX7I27RA-CwuJsF66.js} +1 -1
- package/web/dist/assets/{diagram-Z3DM3KII-O5hYPLEd.js → diagram-Z3DM3KII-BF13T_lj.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-PWID7BFC-DVGEBm99.js → ebnfDiagram-PWID7BFC-ZtoDLro6.js} +1 -1
- package/web/dist/assets/{erDiagram-RLTQ6QDP-M-BLpTEV.js → erDiagram-RLTQ6QDP-BBo-qmNH.js} +1 -1
- package/web/dist/assets/{flowDiagram-HODETNUW-BwdLopEx.js → flowDiagram-HODETNUW-DAq9qdP8.js} +1 -1
- package/web/dist/assets/{ganttDiagram-EL5Y4UJY-DYxx2ppA.js → ganttDiagram-EL5Y4UJY-C53eRlMu.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-WWUBYQGX-DUU0wMYB.js → gitGraphDiagram-WWUBYQGX-BviBMJxj.js} +1 -1
- package/web/dist/assets/index-Bab44wX8.js +276 -0
- package/web/dist/assets/index-C3vDFSEV.css +10 -0
- package/web/dist/assets/{infoDiagram-27XIBGKW-CFaW5C0C.js → infoDiagram-27XIBGKW-DI95SO3j.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-5VMMS53U--ENP1MTB.js → ishikawaDiagram-5VMMS53U-Cl8IDXwf.js} +1 -1
- package/web/dist/assets/{journeyDiagram-3NMN7TZE-DgnAzSTj.js → journeyDiagram-3NMN7TZE-Co6aJeTZ.js} +1 -1
- package/web/dist/assets/{kanban-definition-UXKFOSKX-_STZN1vU.js → kanban-definition-UXKFOSKX-CknQQpwW.js} +1 -1
- package/web/dist/assets/{layout-CkUClkXt.js → layout-CFtUmtTW.js} +1 -1
- package/web/dist/assets/{linear-sYHiUOxa.js → linear-mRlGUAKy.js} +1 -1
- package/web/dist/assets/{markdown-vNTCZJ6d.js → markdown-BhJ_LInD.js} +1 -1
- package/web/dist/assets/{mermaid.core-B8fKGWsc.js → mermaid.core-CvQEO3ue.js} +5 -5
- package/web/dist/assets/{mindmap-definition-YA3MSWOX-DP0BYOEx.js → mindmap-definition-YA3MSWOX-DpqhsxUl.js} +1 -1
- package/web/dist/assets/{pegDiagram-XKGWAZYB-DkWTmZre.js → pegDiagram-XKGWAZYB-C9eawB5u.js} +1 -1
- package/web/dist/assets/{pieDiagram-E7YTZNPT-DfZgMV4u.js → pieDiagram-E7YTZNPT-8vaqXVD6.js} +1 -1
- package/web/dist/assets/{quadrantDiagram-AXDQQJYC-Djl4TT7q.js → quadrantDiagram-AXDQQJYC-Diwr8ynm.js} +1 -1
- package/web/dist/assets/{railroadDiagram-O6MQD6OU-DbKF6zXn.js → railroadDiagram-O6MQD6OU-krW50Cgi.js} +1 -1
- package/web/dist/assets/{react-BNj3n7jX.js → react-BddUxV-9.js} +1 -1
- package/web/dist/assets/{requirementDiagram-BXWQKSXE-idola5qb.js → requirementDiagram-BXWQKSXE-BPLI6eMG.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-P5KCCOFB-KA0hQOle.js → sankeyDiagram-P5KCCOFB-Bix_elmH.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-WJ2MYXX4-BXSKvxzP.js → sequenceDiagram-WJ2MYXX4-0m0xaUKi.js} +1 -1
- package/web/dist/assets/{sizeCapture-INFHLROL-C_Ewv3S5.js → sizeCapture-INFHLROL-BtzJ_oOR.js} +1 -1
- package/web/dist/assets/{stateDiagram-D77RDMKH-DIf5X8oo.js → stateDiagram-D77RDMKH-Clq5bN0v.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-Bn1oW4gp.js +1 -0
- package/web/dist/assets/{swimlanes-42K2YHIH-Cx_Rc8M2.js → swimlanes-42K2YHIH-11MDGnYA.js} +1 -1
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-BW96Edgo.js +8 -0
- package/web/dist/assets/{timeline-definition-24CTP7MA-BRrDhWVz.js → timeline-definition-24CTP7MA-BeQfWP95.js} +1 -1
- package/web/dist/assets/{vennDiagram-4TSXK5OY-Ad6G0YwD.js → vennDiagram-4TSXK5OY-BMYkX0KL.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-VM6X3IG4-X6-ENpSi.js → wardleyDiagram-VM6X3IG4-BV3fTUkU.js} +1 -1
- package/web/dist/assets/{xychartDiagram-S5SC5T6Z-DhegNS6d.js → xychartDiagram-S5SC5T6Z-Ct7ezLnc.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/dist/assets/NodeWorkbench-C4U1PldE.js +0 -3
- package/web/dist/assets/channel-D8Hd7T4P.js +0 -1
- package/web/dist/assets/classDiagram-ZZMXUADV-B_btgYRB.js +0 -1
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-B_btgYRB.js +0 -1
- package/web/dist/assets/index-CI2KfVSr.css +0 -10
- package/web/dist/assets/index-DQcpggFU.js +0 -275
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DYwe_2mB.js +0 -1
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-52Y8tgRV.js +0 -8
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Recover visible PDF code spacing only when native glyphs prove a fixed grid.
|
|
2
|
+
|
|
3
|
+
PDF text APIs can collapse explicit spaces. We retain native Unicode characters
|
|
4
|
+
and reconstruct their visible columns from glyph origins, never from code syntax.
|
|
5
|
+
Ambiguous geometry remains unchanged and is reported for review.
|
|
6
|
+
"""
|
|
7
|
+
import ctypes
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_SIGNATURE = re.compile(r"^\s*(?:(?:async\s+)?def\s+[A-Za-z_]\w*\s*\(|(?:export\s+)?(?:async\s+)?function(?:\s+[A-Za-z_$][\w$]*)?\s*\(|(?:public\s+|private\s+|static\s+)*(?:void|int|bool|float|double|char|long)\s+[A-Za-z_]\w*\s*\()")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _compact(value):
|
|
15
|
+
return re.sub(r"\s+", "", value)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _native_region(page, textpage, bbox):
|
|
19
|
+
import pypdfium2 as pdfium
|
|
20
|
+
if page.get_rotation() != 0:
|
|
21
|
+
raise ValueError("rotated page")
|
|
22
|
+
if textpage.count_chars() > 100_000:
|
|
23
|
+
raise ValueError("native page exceeds the character inspection limit")
|
|
24
|
+
bounds = bbox.to_bottom_left_origin(page.get_height())
|
|
25
|
+
chars, pitches, font_heights = [], [], []
|
|
26
|
+
font_cache = {}
|
|
27
|
+
for index in range(textpage.count_chars()):
|
|
28
|
+
codepoint = pdfium.raw.FPDFText_GetUnicode(textpage.raw, index)
|
|
29
|
+
if codepoint in (10, 13):
|
|
30
|
+
continue
|
|
31
|
+
x, y = ctypes.c_double(), ctypes.c_double()
|
|
32
|
+
if not pdfium.raw.FPDFText_GetCharOrigin(textpage.raw, index, ctypes.byref(x), ctypes.byref(y)):
|
|
33
|
+
continue
|
|
34
|
+
left, bottom, right, top = textpage.get_charbox(index)
|
|
35
|
+
if not (bounds.l - 0.25 <= (left + right) / 2 <= bounds.r + 0.25 and bounds.b - 0.25 <= y.value <= bounds.t + 0.25):
|
|
36
|
+
continue
|
|
37
|
+
if pdfium.raw.FPDFText_IsGenerated(textpage.raw, index):
|
|
38
|
+
if codepoint == 32:
|
|
39
|
+
continue
|
|
40
|
+
raise ValueError("generated non-whitespace character")
|
|
41
|
+
if codepoint < 32 or codepoint > 0x10ffff or 0xd800 <= codepoint <= 0xdfff or pdfium.raw.FPDFText_HasUnicodeMapError(textpage.raw, index):
|
|
42
|
+
raise ValueError("unmapped or control character")
|
|
43
|
+
obj = textpage.get_textobj(index)
|
|
44
|
+
if obj is None:
|
|
45
|
+
raise ValueError("character has no native text object")
|
|
46
|
+
matrix = obj.get_matrix()
|
|
47
|
+
if abs(matrix.b) > 1e-5 or abs(matrix.c) > 1e-5 or matrix.a <= 0 or matrix.d <= 0 or abs(pdfium.raw.FPDFText_GetCharAngle(textpage.raw, index)) > 1e-5:
|
|
48
|
+
raise ValueError("rotated or skewed text")
|
|
49
|
+
if pdfium.raw.FPDFTextObj_GetTextRenderMode(obj.raw) in (3, 7):
|
|
50
|
+
raise ValueError("invisible text layer")
|
|
51
|
+
font, size = obj.get_font(), obj.get_font_size()
|
|
52
|
+
font_key = (ctypes.cast(font.raw, ctypes.c_void_p).value, size, matrix.a)
|
|
53
|
+
def width(character):
|
|
54
|
+
value = ctypes.c_float()
|
|
55
|
+
if not pdfium.raw.FPDFFont_GetGlyphWidth(font.raw, ord(character), size, ctypes.byref(value)):
|
|
56
|
+
raise ValueError("unavailable glyph advance")
|
|
57
|
+
return value.value * matrix.a
|
|
58
|
+
if font_key not in font_cache:
|
|
59
|
+
advances = [width(character) for character in " iMW0"]
|
|
60
|
+
if min(advances) <= 0 or max(advances) - min(advances) > max(0.01, advances[0] * 0.002):
|
|
61
|
+
raise ValueError("proportional font")
|
|
62
|
+
font_cache[font_key] = advances[0]
|
|
63
|
+
pitch = font_cache[font_key]
|
|
64
|
+
advance = width(chr(codepoint))
|
|
65
|
+
cells = round(advance / pitch)
|
|
66
|
+
if cells not in (1, 2) or abs(advance / pitch - cells) > 0.02:
|
|
67
|
+
raise ValueError("character advance is not an integral grid width")
|
|
68
|
+
chars.append({"index": index, "character": chr(codepoint), "x": x.value, "y": y.value, "cells": cells})
|
|
69
|
+
pitches.append(pitch)
|
|
70
|
+
font_heights.append(size * matrix.d)
|
|
71
|
+
if not chars:
|
|
72
|
+
raise ValueError("no visible native text in this region")
|
|
73
|
+
pitch = sum(pitches) / len(pitches)
|
|
74
|
+
if max(pitches) - min(pitches) > max(0.01, pitch * 0.002):
|
|
75
|
+
raise ValueError("mixed glyph advances")
|
|
76
|
+
rows = []
|
|
77
|
+
for char in sorted(chars, key=lambda value: (-value["y"], value["x"])):
|
|
78
|
+
if not rows or abs(rows[-1][0]["y"] - char["y"]) > 0.15:
|
|
79
|
+
rows.append([])
|
|
80
|
+
rows[-1].append(char)
|
|
81
|
+
gaps = [rows[i][0]["y"] - rows[i + 1][0]["y"] for i in range(len(rows) - 1)]
|
|
82
|
+
line_advance = min(gaps) if gaps else None
|
|
83
|
+
gap_cells = []
|
|
84
|
+
if line_advance is not None:
|
|
85
|
+
if line_advance < pitch or line_advance > 2 * min(font_heights):
|
|
86
|
+
raise ValueError("baseline spacing cannot establish a continuous line grid")
|
|
87
|
+
for gap in gaps:
|
|
88
|
+
cells = round(gap / line_advance)
|
|
89
|
+
if cells < 1 or cells > 8 or abs(gap / line_advance - cells) > 0.025:
|
|
90
|
+
raise ValueError("non-integral baseline gaps may contain significant blank lines")
|
|
91
|
+
gap_cells.append(cells)
|
|
92
|
+
origin = min(char["x"] for char in chars)
|
|
93
|
+
lines = []
|
|
94
|
+
for row_index, row in enumerate(rows):
|
|
95
|
+
if row_index:
|
|
96
|
+
# Empty rows preserve measured visual gaps, without interpreting syntax.
|
|
97
|
+
lines.extend([""] * (gap_cells[row_index - 1] - 1))
|
|
98
|
+
line, cursor = "", 0
|
|
99
|
+
for char in sorted(row, key=lambda value: value["x"]):
|
|
100
|
+
column = (char["x"] - origin) / pitch
|
|
101
|
+
cell = round(column)
|
|
102
|
+
if cell < cursor or cell > 2000 or abs(column - cell) > 0.025:
|
|
103
|
+
raise ValueError("overlapping or non-integral character positions")
|
|
104
|
+
line += " " * (cell - cursor) + char["character"]
|
|
105
|
+
cursor = cell + char["cells"]
|
|
106
|
+
lines.append(line.rstrip())
|
|
107
|
+
return "\n".join(lines), {"advance": pitch, "lineAdvance": line_advance, "rowPitch": line_advance, "baselineGaps": gaps, "gapRows": gap_cells, "originX": origin, "baselines": [row[0]["y"] for row in rows], "characters": chars}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def recover_native_code(document, source):
|
|
111
|
+
"""Mutate qualified Docling text/code items; return auditable recovery metadata."""
|
|
112
|
+
import pypdfium2 as pdfium
|
|
113
|
+
from docling_core.types.doc import CodeItem
|
|
114
|
+
items = [item for item, _level in document.iterate_items() if str(getattr(item, "label", "")) in ("code", "text") and (str(item.label) == "code" or _SIGNATURE.match(getattr(item, "text", "")))]
|
|
115
|
+
result = {"recovered": 0, "warnings": [], "needs_review": False, "recoveries": []}
|
|
116
|
+
if not items:
|
|
117
|
+
return result
|
|
118
|
+
with pdfium.PdfDocument(source) as pdf:
|
|
119
|
+
for item in items:
|
|
120
|
+
try:
|
|
121
|
+
if len(item.prov) != 1:
|
|
122
|
+
raise ValueError("code spans multiple or unspecified regions")
|
|
123
|
+
prov = item.prov[0]
|
|
124
|
+
page = pdf[prov.page_no - 1]
|
|
125
|
+
try:
|
|
126
|
+
textpage = page.get_textpage()
|
|
127
|
+
try:
|
|
128
|
+
text, evidence = _native_region(page, textpage, prov.bbox)
|
|
129
|
+
finally:
|
|
130
|
+
textpage.close()
|
|
131
|
+
finally:
|
|
132
|
+
page.close()
|
|
133
|
+
if _compact(text) not in {_compact(item.text), _compact(item.orig)}:
|
|
134
|
+
raise ValueError("native characters differ from the recognized region")
|
|
135
|
+
if any(delimiter in text or delimiter in item.text for delimiter in ('"""', "'''", "`")):
|
|
136
|
+
raise ValueError("multiline string whitespace requires explicit review")
|
|
137
|
+
if str(item.label) != "code" and "\n" not in text:
|
|
138
|
+
continue
|
|
139
|
+
data = item.model_dump()
|
|
140
|
+
data.update(label="code", text=text, orig=text)
|
|
141
|
+
new_item = CodeItem(**data)
|
|
142
|
+
new_item.prov[0].charspan = (0, len(text))
|
|
143
|
+
document.replace_item(old_item=item, new_item=new_item)
|
|
144
|
+
result["recovered"] += 1
|
|
145
|
+
result["recoveries"].append({"id": new_item.self_ref, "page": prov.page_no, "bbox": prov.bbox.model_dump(mode="json"), "originalText": item.text, "text": text, **evidence})
|
|
146
|
+
except Exception as error:
|
|
147
|
+
result["needs_review"] = True
|
|
148
|
+
result["warnings"].append(f"Native code layout could not be verified for {item.self_ref}: {error}; recognized text was preserved for review.")
|
|
149
|
+
return result
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, access, writeFile, stat, readdir } from "node:fs/promises";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import lockfile from "proper-lockfile";
|
|
6
|
+
import { killPidTree } from "../process-utils.js";
|
|
7
|
+
import { documentDataDir, getDocumentSettings } from "./settings.js";
|
|
8
|
+
export const DOCLING_VERSION = "2.136.0";
|
|
9
|
+
export const CONVERSION_PROFILE = "docling-cpu-zh-v1";
|
|
10
|
+
const ownedChildren = new Set();
|
|
11
|
+
let shuttingDown = false;
|
|
12
|
+
function trackChild(child) { ownedChildren.add(child); child.once("close", () => ownedChildren.delete(child)); }
|
|
13
|
+
export async function shutdownDocumentRuntime() {
|
|
14
|
+
shuttingDown = true;
|
|
15
|
+
if (worker)
|
|
16
|
+
closeWorker(worker);
|
|
17
|
+
await Promise.all([...ownedChildren].map(child => new Promise(resolve => {
|
|
18
|
+
const done = () => { clearTimeout(timer); resolve(); };
|
|
19
|
+
const timer = setTimeout(done, 5000);
|
|
20
|
+
child.once("close", done);
|
|
21
|
+
if (child.pid)
|
|
22
|
+
killPidTree(child.pid);
|
|
23
|
+
else
|
|
24
|
+
done();
|
|
25
|
+
})));
|
|
26
|
+
}
|
|
27
|
+
export async function waitDocumentQueue(previous, signal) {
|
|
28
|
+
signal?.throwIfAborted();
|
|
29
|
+
if (!signal)
|
|
30
|
+
return previous;
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
const abort = () => reject(new DOMException("Document operation canceled", "AbortError"));
|
|
33
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
34
|
+
previous.then(() => { signal.removeEventListener("abort", abort); resolve(); }, error => { signal.removeEventListener("abort", abort); reject(error); });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function runtimeDirectory() {
|
|
38
|
+
return getDocumentSettings().runtimePath ?? join(documentDataDir(), "runtimes", CONVERSION_PROFILE);
|
|
39
|
+
}
|
|
40
|
+
export function runtimePython() {
|
|
41
|
+
return join(runtimeDirectory(), "venv", process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
|
|
42
|
+
}
|
|
43
|
+
export function bridgePath() { return fileURLToPath(new URL("./python/bridge.py", import.meta.url)); }
|
|
44
|
+
/** A separate process group lets cancellation remove only this conversion's children. */
|
|
45
|
+
export async function runDocumentProcess(command, args, options = {}) {
|
|
46
|
+
if (shuttingDown)
|
|
47
|
+
throw new Error("Document runtime is shutting down");
|
|
48
|
+
options.signal?.throwIfAborted();
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const child = spawn(command, args, {
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide: true,
|
|
52
|
+
env: { ...process.env, PYTHONUNBUFFERED: "1", PYTHONUTF8: "1", ...(options.offline ? { HF_HUB_OFFLINE: "1", TRANSFORMERS_OFFLINE: "1", HF_DATASETS_OFFLINE: "1", HF_HUB_DISABLE_TELEMETRY: "1" } : {}) },
|
|
53
|
+
});
|
|
54
|
+
trackChild(child);
|
|
55
|
+
let stdout = "", stderr = "", pending = "", failure, lastProgress = 0;
|
|
56
|
+
const stop = (error) => { failure = error; if (child.pid)
|
|
57
|
+
killPidTree(child.pid); };
|
|
58
|
+
const abort = () => stop(new DOMException("Document operation canceled", "AbortError"));
|
|
59
|
+
const timer = setTimeout(() => stop(new Error("Document operation timed out")), options.timeout ?? 30 * 60_000);
|
|
60
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
61
|
+
child.stdout.on("data", (chunk) => {
|
|
62
|
+
stdout += chunk.toString("utf8");
|
|
63
|
+
if (stdout.length > 4 * 1024 * 1024)
|
|
64
|
+
stop(new Error("Parser response exceeds 4 MiB"));
|
|
65
|
+
});
|
|
66
|
+
child.stderr.on("data", (chunk) => {
|
|
67
|
+
const text = chunk.toString("utf8");
|
|
68
|
+
stderr = (stderr + text).slice(-32_768);
|
|
69
|
+
pending += text;
|
|
70
|
+
const lines = pending.split(/\r\n|\r|\n/);
|
|
71
|
+
pending = lines.pop() ?? "";
|
|
72
|
+
const latest = lines.map(line => line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").trim()).filter(Boolean).at(-1);
|
|
73
|
+
if (latest && Date.now() - lastProgress >= 400) {
|
|
74
|
+
options.onProgress?.(latest.slice(0, 1000));
|
|
75
|
+
lastProgress = Date.now();
|
|
76
|
+
}
|
|
77
|
+
if (pending.length > 2000)
|
|
78
|
+
pending = pending.slice(-2000);
|
|
79
|
+
});
|
|
80
|
+
child.on("error", error => { failure = error; });
|
|
81
|
+
child.on("close", code => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
options.signal?.removeEventListener("abort", abort);
|
|
84
|
+
if (failure)
|
|
85
|
+
reject(failure);
|
|
86
|
+
else if (code !== 0)
|
|
87
|
+
reject(new Error(`Document process failed (${code}): ${stderr.trim().slice(-6000)}`));
|
|
88
|
+
else
|
|
89
|
+
resolve(stdout.trim());
|
|
90
|
+
});
|
|
91
|
+
child.stdin.on("error", () => { });
|
|
92
|
+
child.stdin.end(options.input === undefined ? undefined : JSON.stringify(options.input));
|
|
93
|
+
if (options.signal?.aborted)
|
|
94
|
+
abort();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export async function doctorRuntime(options = {}) {
|
|
98
|
+
const pythonPath = runtimePython();
|
|
99
|
+
try {
|
|
100
|
+
await access(pythonPath);
|
|
101
|
+
const result = JSON.parse(await runDocumentProcess(pythonPath, [bridgePath(), "doctor", runtimeDirectory()], { ...options, timeout: 60_000, offline: true }));
|
|
102
|
+
return { ...result, pythonPath };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
options.signal?.throwIfAborted();
|
|
106
|
+
return { ready: false, pythonPath, missing: [error instanceof Error ? error.message : String(error)], warnings: [] };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
let qualification;
|
|
110
|
+
async function qualificationSignature(root) {
|
|
111
|
+
const receipt = await readFile(join(root, "ready.json"), "utf8");
|
|
112
|
+
const parsed = JSON.parse(receipt);
|
|
113
|
+
const assets = (await readdir(dirname(bridgePath()))).filter(name => name.endsWith(".py")).sort().map(name => join(dirname(bridgePath()), name));
|
|
114
|
+
const paths = [join(root, "ready.json"), join(root, "requirements.lock"), runtimePython(), ...assets, ...Object.keys(parsed.models ?? {}).sort().map(path => join(root, "models", path))];
|
|
115
|
+
const identities = await Promise.all(paths.map(async (path) => { const value = await stat(path); return [path, value.dev, value.ino, value.size, value.mtimeMs, value.ctimeMs]; }));
|
|
116
|
+
return JSON.stringify([receipt, identities]);
|
|
117
|
+
}
|
|
118
|
+
/** Explicit doctor always hashes models; conversions reuse a successful check
|
|
119
|
+
* until the receipt, dependency lock, Python executable or model files change. */
|
|
120
|
+
export async function ensureRuntimeReady(options = {}) {
|
|
121
|
+
options.signal?.throwIfAborted();
|
|
122
|
+
const root = runtimeDirectory();
|
|
123
|
+
let signature;
|
|
124
|
+
try {
|
|
125
|
+
signature = await qualificationSignature(root);
|
|
126
|
+
}
|
|
127
|
+
catch { /* Doctor gives actionable missing-runtime details. */ }
|
|
128
|
+
if (signature !== undefined && qualification?.root === root && qualification.signature === signature)
|
|
129
|
+
return qualification.result;
|
|
130
|
+
const result = await doctorRuntime(options);
|
|
131
|
+
if (result.ready && signature !== undefined)
|
|
132
|
+
qualification = { root, signature, result };
|
|
133
|
+
else
|
|
134
|
+
qualification = undefined;
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
let setupPromise;
|
|
138
|
+
export async function setupRuntime(options = {}) {
|
|
139
|
+
if (setupPromise)
|
|
140
|
+
throw new Error("Document runtime setup is already running");
|
|
141
|
+
setupPromise = performSetup(options);
|
|
142
|
+
try {
|
|
143
|
+
return await setupPromise;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
setupPromise = undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function performSetup(options) {
|
|
150
|
+
if (process.platform === "darwin" && process.arch === "x64")
|
|
151
|
+
throw new Error("The current local Docling profile has not been qualified on Intel macOS. A separately tested runtime is required.");
|
|
152
|
+
const root = runtimeDirectory(), pythonPath = runtimePython();
|
|
153
|
+
await mkdir(root, { recursive: true });
|
|
154
|
+
const release = await lockfile.lock(root, { realpath: false, stale: 120_000, retries: 0 });
|
|
155
|
+
try {
|
|
156
|
+
const existing = await doctorRuntime(options);
|
|
157
|
+
if (existing.ready) {
|
|
158
|
+
options.onProgress?.("The isolated document runtime is already qualified");
|
|
159
|
+
return existing;
|
|
160
|
+
}
|
|
161
|
+
if (worker?.root === root)
|
|
162
|
+
closeWorker(worker);
|
|
163
|
+
try {
|
|
164
|
+
await access(pythonPath);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
const configured = getDocumentSettings().pythonPath;
|
|
168
|
+
if (configured) {
|
|
169
|
+
const version = await runDocumentProcess(configured, ["-c", "import sys; print('.'.join(map(str,sys.version_info[:2])))"], options);
|
|
170
|
+
if (version !== "3.12")
|
|
171
|
+
throw new Error(`Python 3.12 is required for this profile; found ${version}`);
|
|
172
|
+
await runDocumentProcess(configured, ["-m", "venv", join(root, "venv")], options);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
options.onProgress?.("Creating the isolated Python 3.12 runtime with uv");
|
|
176
|
+
try {
|
|
177
|
+
await runDocumentProcess("uv", ["venv", "--seed", "--python", "3.12", join(root, "venv")], options);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
throw new Error(`Install uv or configure an absolute Python 3.12 executable path. ${error instanceof Error ? error.message : error}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
options.onProgress?.(`Installing Docling ${DOCLING_VERSION} in the isolated runtime`);
|
|
185
|
+
const lockPath = join(root, "requirements.lock");
|
|
186
|
+
// The CPU Transformers backend is already a standard dependency. The broad
|
|
187
|
+
// vlm extra additionally installs unrelated MLX/audio backends on Apple Silicon.
|
|
188
|
+
let install = [`docling[rapidocr]==${DOCLING_VERSION}`, "transformers==5.19.0", "rapidocr==3.9.1"];
|
|
189
|
+
try {
|
|
190
|
+
const locked = await readFile(lockPath, "utf8");
|
|
191
|
+
if (!locked.trim().split(/\r?\n/).every(line => /^[A-Za-z0-9_.-]+==[A-Za-z0-9_.+!-]+$/.test(line)))
|
|
192
|
+
throw new Error("Invalid document runtime dependency lock");
|
|
193
|
+
install = ["-r", lockPath];
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error.code !== "ENOENT")
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await runDocumentProcess("uv", ["pip", "install", "--python", pythonPath, "--index-url", "https://pypi.org/simple", ...install], { ...options, timeout: 45 * 60_000 });
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error.code !== "ENOENT")
|
|
204
|
+
throw error;
|
|
205
|
+
await runDocumentProcess(pythonPath, ["-m", "pip", "install", "--index-url", "https://pypi.org/simple", ...install], { ...options, timeout: 45 * 60_000 });
|
|
206
|
+
}
|
|
207
|
+
const freeze = await runDocumentProcess(pythonPath, ["-m", "pip", "freeze"], options);
|
|
208
|
+
if (!freeze.split(/\r?\n/).every(line => /^[A-Za-z0-9_.-]+==[A-Za-z0-9_.+!-]+$/.test(line)))
|
|
209
|
+
throw new Error("Installed dependencies contain unexpected non-registry references");
|
|
210
|
+
await writeFile(lockPath, freeze + "\n");
|
|
211
|
+
options.onProgress?.("Downloading local models and verifying the offline conversion profile");
|
|
212
|
+
await runDocumentProcess(pythonPath, [bridgePath(), "setup", root], { ...options, timeout: 60 * 60_000 });
|
|
213
|
+
return doctorRuntime(options);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await release();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export async function runtimeFingerprint() {
|
|
220
|
+
return readFile(join(runtimeDirectory(), "ready.json"), "utf8");
|
|
221
|
+
}
|
|
222
|
+
let worker;
|
|
223
|
+
let workerQueue = Promise.resolve();
|
|
224
|
+
function closeWorker(active) {
|
|
225
|
+
if (active.idle)
|
|
226
|
+
clearTimeout(active.idle);
|
|
227
|
+
if (active.child.pid)
|
|
228
|
+
killPidTree(active.child.pid);
|
|
229
|
+
if (worker === active)
|
|
230
|
+
worker = undefined;
|
|
231
|
+
}
|
|
232
|
+
function parserWorker() {
|
|
233
|
+
if (shuttingDown)
|
|
234
|
+
throw new Error("Document runtime is shutting down");
|
|
235
|
+
const root = runtimeDirectory();
|
|
236
|
+
if (worker?.root === root) {
|
|
237
|
+
if (worker.idle)
|
|
238
|
+
clearTimeout(worker.idle);
|
|
239
|
+
return worker;
|
|
240
|
+
}
|
|
241
|
+
if (worker)
|
|
242
|
+
closeWorker(worker);
|
|
243
|
+
const child = spawn(runtimePython(), [bridgePath(), "serve", root], { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide: true, env: { ...process.env, PYTHONUNBUFFERED: "1", PYTHONUTF8: "1", HF_HUB_OFFLINE: "1", TRANSFORMERS_OFFLINE: "1", HF_HUB_DISABLE_TELEMETRY: "1" } });
|
|
244
|
+
trackChild(child);
|
|
245
|
+
const active = { child, root, stdout: "" };
|
|
246
|
+
worker = active;
|
|
247
|
+
child.stdout.on("data", (chunk) => {
|
|
248
|
+
active.stdout += chunk.toString("utf8");
|
|
249
|
+
if (active.stdout.length > 4 * 1024 * 1024) {
|
|
250
|
+
active.pending?.reject(new Error("Parser response exceeds 4 MiB"));
|
|
251
|
+
closeWorker(active);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
let end;
|
|
255
|
+
while ((end = active.stdout.indexOf("\n")) >= 0) {
|
|
256
|
+
const line = active.stdout.slice(0, end);
|
|
257
|
+
active.stdout = active.stdout.slice(end + 1);
|
|
258
|
+
try {
|
|
259
|
+
const response = JSON.parse(line);
|
|
260
|
+
if (response.error)
|
|
261
|
+
active.pending?.reject(new Error(response.error));
|
|
262
|
+
else
|
|
263
|
+
active.pending?.resolve(response);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
active.pending?.reject(new Error("Invalid parser response"));
|
|
267
|
+
closeWorker(active);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
let errorTail = "";
|
|
272
|
+
child.stderr.on("data", (chunk) => { const text = chunk.toString("utf8"); errorTail = (errorTail + text).slice(-6000); for (const line of text.split(/\r?\n/))
|
|
273
|
+
if (line.trim())
|
|
274
|
+
active.pending?.progress?.(line.slice(0, 1000)); });
|
|
275
|
+
child.on("error", error => { active.pending?.reject(error); if (worker === active)
|
|
276
|
+
worker = undefined; });
|
|
277
|
+
child.on("close", code => { active.pending?.reject(new Error(`Document worker exited (${code}): ${errorTail}`)); if (worker === active)
|
|
278
|
+
worker = undefined; });
|
|
279
|
+
child.stdin.on("error", error => active.pending?.reject(error));
|
|
280
|
+
return active;
|
|
281
|
+
}
|
|
282
|
+
/** One queued local worker keeps model weights warm without parallel RAM spikes. */
|
|
283
|
+
export async function runConversionWorker(input, options) {
|
|
284
|
+
const previous = workerQueue;
|
|
285
|
+
let release;
|
|
286
|
+
const gate = new Promise(done => { release = done; });
|
|
287
|
+
workerQueue = previous.then(() => gate);
|
|
288
|
+
try {
|
|
289
|
+
await waitDocumentQueue(previous, options.signal);
|
|
290
|
+
options.signal?.throwIfAborted();
|
|
291
|
+
const active = parserWorker();
|
|
292
|
+
return await new Promise((resolve, reject) => {
|
|
293
|
+
const finish = (error, response) => {
|
|
294
|
+
if (!active.pending)
|
|
295
|
+
return;
|
|
296
|
+
active.pending = undefined;
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
options.signal?.removeEventListener("abort", abort);
|
|
299
|
+
if (error) {
|
|
300
|
+
closeWorker(active);
|
|
301
|
+
reject(error);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
active.idle = setTimeout(() => closeWorker(active), 10_000);
|
|
305
|
+
active.idle.unref();
|
|
306
|
+
resolve(response);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
const abort = () => finish(new DOMException("Document operation canceled", "AbortError"));
|
|
310
|
+
const timer = setTimeout(() => finish(new Error("Document conversion exceeded 30 minutes")), 30 * 60_000);
|
|
311
|
+
active.pending = { resolve: response => finish(undefined, response), reject: error => finish(error), progress: options.onProgress };
|
|
312
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
313
|
+
active.child.stdin.write(JSON.stringify(input) + "\n");
|
|
314
|
+
if (options.signal?.aborted)
|
|
315
|
+
abort();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
release();
|
|
320
|
+
}
|
|
321
|
+
}
|