Merge branch 'codex/ppt-refactor'
This commit is contained in:
commit
e693fb6ef8
22 files changed
+2031
-403
No files matched your search
@@ -80,6 +80,7 @@
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"@node-rs/jieba": "^2.0.1",
|
||||
"@react-pdf/renderer": "^4.1.0",
|
||||
"@resvg/resvg-js": "2.6.2",
|
||||
"@xmldom/xmldom": "^0.8.11",
|
||||
"antd": "5.22.1",
|
||||
"apache-arrow": "^17.0.0",
|
||||
|
||||
Generated
+11
-62
@@ -56,6 +56,9 @@ importers:
|
||||
'@react-pdf/renderer':
|
||||
specifier: ^4.1.0
|
||||
version: 4.3.2(react@19.2.3)
|
||||
'@resvg/resvg-js':
|
||||
specifier: 2.6.2
|
||||
version: 2.6.2
|
||||
'@xmldom/xmldom':
|
||||
specifier: ^0.8.11
|
||||
version: 0.8.11
|
||||
@@ -95,12 +98,12 @@ importers:
|
||||
mark.js:
|
||||
specifier: ^8.11.1
|
||||
version: 8.11.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
mathjax-full:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
pdf-parse:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -1101,105 +1104,89 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -1278,28 +1265,24 @@ packages:
|
||||
engines: {node: '>= 18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@lancedb/lancedb-linux-arm64-musl@0.22.3':
|
||||
resolution: {integrity: sha512-jVRMtXxxYaDlZSaclCIHB2N+NJvQ1Fj9EaPeBx+HxG2VqUg0vXKef+yiaD2aGo9sAH6mMmkKJsrPhwABpUC4rQ==}
|
||||
engines: {node: '>= 18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@lancedb/lancedb-linux-x64-gnu@0.22.3':
|
||||
resolution: {integrity: sha512-co7idTwvNAtbFoFHojhHlTpKsydOm5sZfbtAsQRdoa7g6a61yIrqrMm8D7Ngh756JfzZLFQBMkDUZEW3X4vP/g==}
|
||||
engines: {node: '>= 18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@lancedb/lancedb-linux-x64-musl@0.22.3':
|
||||
resolution: {integrity: sha512-+ipFsn5PCODK7mOMq1gZ5OAZWks5YlgmjAlnYMmU8XxvaK0b6lZdA3s1hTmBaBO9+wv+31ulO55oBN4/U8Yldg==}
|
||||
engines: {node: '>= 18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@lancedb/lancedb-win32-arm64-msvc@0.22.3':
|
||||
resolution: {integrity: sha512-E0XywJYnelIe4pzOlvog+aMHKt5ChW27tgmT2V80Z6PXcX6eN9I69Fj0Q6DK6z1YCTPIPu6Na1Hd6d4GqUNKPw==}
|
||||
@@ -1316,7 +1299,6 @@ packages:
|
||||
'@lancedb/lancedb@0.22.3':
|
||||
resolution: {integrity: sha512-nRC0fkg+d7dzCtudKHT+VH7znk6KUXRZyuS6HJYNnIrbvXBxaT6wAPjEbf70KTuqvP2znj48Zg+kiwRqkRnAJw==}
|
||||
engines: {node: '>= 18'}
|
||||
cpu: [x64, arm64]
|
||||
os: [darwin, linux, win32]
|
||||
peerDependencies:
|
||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||
@@ -1809,35 +1791,30 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
|
||||
@@ -1893,28 +1870,24 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@node-rs/jieba-linux-arm64-musl@2.0.1':
|
||||
resolution: {integrity: sha512-K4EDyNixSLVdTNYnHwD+7I/ytvzpo7tt+vdCLqwQViiek2PMpL/FFRvA39uU2tk99jXIxvkczdxARG20BRZppg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@node-rs/jieba-linux-x64-gnu@2.0.1':
|
||||
resolution: {integrity: sha512-sq3J6L2ANTE25I9eVFq/nb57OtXcvUIeUD1CTKJxwgTKIVmcB2LyOZpWf20AjHRUfbMER9Klqg5dgyyO+Six+w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@node-rs/jieba-linux-x64-musl@2.0.1':
|
||||
resolution: {integrity: sha512-0zfP9Qy68yEXrhBFknfhF6WUJDPU/8eRuyIrkMGdMjfRpxhpSbr2fMfnsqhOQLvhuK4w3iDFvTy4t5d0s6JKMA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@node-rs/jieba-wasm32-wasi@2.0.1':
|
||||
resolution: {integrity: sha512-7I5rJya5rlQNJIhv8PvPzIVT1/gVc0vFzHmlfRGwCPGDJ3tHVxkSPW34dDx3OgDmbIeadNpmgIyC1RaS9djPJg==}
|
||||
@@ -2136,28 +2109,24 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
|
||||
@@ -2218,79 +2187,66 @@ packages:
|
||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||
@@ -2402,28 +2358,24 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
@@ -4047,7 +3999,7 @@ packages:
|
||||
glob@8.1.0:
|
||||
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
|
||||
engines: {node: '>=12'}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
@@ -4703,28 +4655,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
@@ -11257,7 +11205,7 @@ snapshots:
|
||||
isstream: 0.1.2
|
||||
jsonwebtoken: 9.0.3
|
||||
mime-types: 2.1.35
|
||||
retry-axios: 2.6.0(axios@1.13.2(debug@4.4.3))
|
||||
retry-axios: 2.6.0(axios@1.13.2)
|
||||
tough-cookie: 4.1.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12383,7 +12331,8 @@ snapshots:
|
||||
|
||||
mustache@4.2.0: {}
|
||||
|
||||
nan@2.24.0: {}
|
||||
nan@2.24.0:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
@@ -13527,7 +13476,7 @@ snapshots:
|
||||
|
||||
restructure@3.0.2: {}
|
||||
|
||||
retry-axios@2.6.0(axios@1.13.2(debug@4.4.3)):
|
||||
retry-axios@2.6.0(axios@1.13.2):
|
||||
dependencies:
|
||||
axios: 1.13.2(debug@4.4.3)
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { AiRunContext } from '../../../types'
|
||||
import { HUMAN_MAX_ACTIVE_TOOLS } from '../../constants'
|
||||
import type { HumanLearningOutcome, HumanRuntimeCache, HumanState, HumanDecisionHint } from '../../types'
|
||||
import type {
|
||||
HumanLearningOutcome,
|
||||
HumanRuntimeCache,
|
||||
HumanState,
|
||||
HumanDecisionHint
|
||||
} from '../../types'
|
||||
import {
|
||||
assertExternalLearnedSkillIds,
|
||||
expandSkillIdsWithRequirements,
|
||||
getEffectiveLearnedSkillIds,
|
||||
getSkillDefinition,
|
||||
hasSkillDefinition,
|
||||
@@ -11,7 +17,11 @@ import {
|
||||
import { appendDecisionRound, buildLearningExecution } from '../../memory/rounds'
|
||||
import { ensureSkillTools } from './skillLoader'
|
||||
import { countActiveToolNamesForSkillIds, fitSkillIdsToToolBudget } from './toolBudget'
|
||||
import { returnToBrain, returnToBrainAfterTrace, returnToBrainWithDecisionHint } from '../utils/stateTransitions'
|
||||
import {
|
||||
returnToBrain,
|
||||
returnToBrainAfterTrace,
|
||||
returnToBrainWithDecisionHint
|
||||
} from '../utils/stateTransitions'
|
||||
import { emitLearningCompleted, emitLearningStarted } from '../../../events/processEvents'
|
||||
|
||||
const dedupeInOrder = <TValue extends string>(values: readonly TValue[]): TValue[] => {
|
||||
@@ -79,7 +89,9 @@ const renderLearningResult = (outcome: HumanLearningOutcome): string => {
|
||||
duplicates.length > 0
|
||||
? `已存在技能 ${duplicates.map((item) => `\`${item}\``).join('、')}`
|
||||
: undefined,
|
||||
unknown.length > 0 ? `未识别技能 ${unknown.map((item) => `\`${item}\``).join('、')}` : undefined,
|
||||
unknown.length > 0
|
||||
? `未识别技能 ${unknown.map((item) => `\`${item}\``).join('、')}`
|
||||
: undefined,
|
||||
outcome.evictedSkillIds.length > 0
|
||||
? `为满足活动工具数量上限,已移除技能 ${outcome.evictedSkillIds.map((item) => `\`${item}\``).join('、')}`
|
||||
: undefined
|
||||
@@ -104,16 +116,26 @@ export const createLearningNode = (input: { ctx: AiRunContext; cache: HumanRunti
|
||||
assertExternalLearnedSkillIds(state.skills.learnedSkillIds)
|
||||
input.cache.currentState = state
|
||||
|
||||
if (!state.execution.currentThought || state.execution.currentThought.nextAction.action !== 'learning') {
|
||||
if (
|
||||
!state.execution.currentThought ||
|
||||
state.execution.currentThought.nextAction.action !== 'learning'
|
||||
) {
|
||||
return returnToBrain(state)
|
||||
}
|
||||
|
||||
const rawRequestedSkillIds = state.execution.currentThought.nextAction.wantLearnSkillIds
|
||||
const requestedSkillIds = dedupeInOrder(
|
||||
const directRequestedSkillIds = dedupeInOrder(
|
||||
Array.isArray(rawRequestedSkillIds)
|
||||
? rawRequestedSkillIds.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
? rawRequestedSkillIds.filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
||||
)
|
||||
: []
|
||||
)
|
||||
const knownRequestedSkillIds = directRequestedSkillIds.filter(hasSkillDefinition)
|
||||
const requestedSkillIds = dedupeInOrder([
|
||||
...expandSkillIdsWithRequirements(knownRequestedSkillIds),
|
||||
...directRequestedSkillIds.filter((skillId) => !hasSkillDefinition(skillId))
|
||||
])
|
||||
const effectiveLearnedSkillIds = getEffectiveLearnedSkillIds(state.skills.learnedSkillIds)
|
||||
const duplicateSkillIds = requestedSkillIds.filter(
|
||||
(skillId): skillId is HumanLearningOutcome['skippedDuplicateSkillIds'][number] =>
|
||||
|
||||
@@ -34,7 +34,8 @@ export const skillDefinitionBaseSchema = z.object({
|
||||
countsTowardToolBudget: z.boolean(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
toolNames: z.array(z.string().min(1))
|
||||
toolNames: z.array(z.string().min(1)),
|
||||
requiredSkillIds: z.array(runtimeSkillIdSchema).optional()
|
||||
})
|
||||
|
||||
export const toolCallParamSchema = z.object({
|
||||
|
||||
@@ -13,7 +13,8 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'builtin',
|
||||
countsTowardToolBudget: false,
|
||||
title: 'terminal',
|
||||
description: '使用持久终端会话执行、读取和停止命令,并返回终端状态、当前路径、增量输出、退出状态和工作区文件产物。',
|
||||
description:
|
||||
'使用持久终端会话执行、读取和停止命令,并返回终端状态、当前路径、增量输出、退出状态和工作区文件产物。',
|
||||
toolNames: ['terminal_run', 'terminal_read', 'terminal_stop']
|
||||
},
|
||||
'text-file': {
|
||||
@@ -22,7 +23,8 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'builtin',
|
||||
countsTowardToolBudget: false,
|
||||
title: 'text-file',
|
||||
description: '读取和写入 Markdown、TXT、代码等 UTF-8 普通文本文件,并返回文本、行范围、执行结果和文件修改记录。',
|
||||
description:
|
||||
'读取和写入 Markdown、TXT、代码等 UTF-8 普通文本文件,并返回文本、行范围、执行结果和文件修改记录。',
|
||||
toolNames: ['text_file_read', 'text_file_edit']
|
||||
},
|
||||
'docx-read': {
|
||||
@@ -31,7 +33,8 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'external',
|
||||
countsTowardToolBudget: true,
|
||||
title: 'docx-read',
|
||||
description: '读取 docx 的结构、内容块、检索结果和任务内修订历史,并返回块级索引、正文、匹配信息或 revision 信息。',
|
||||
description:
|
||||
'读取 docx 的结构、内容块、检索结果和任务内修订历史,并返回块级索引、正文、匹配信息或 revision 信息。',
|
||||
toolNames: ['docx_read_outline', 'docx_search', 'docx_read_blocks']
|
||||
},
|
||||
'docx-write': {
|
||||
@@ -40,7 +43,8 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'external',
|
||||
countsTowardToolBudget: true,
|
||||
title: 'docx-write',
|
||||
description: '创建或修改 Word 文档中的段落、标题、表格、文本和样式,并返回写入状态和相邻区域的详细回读信息。',
|
||||
description:
|
||||
'创建或修改 Word 文档中的段落、标题、表格、文本和样式,并返回写入状态和相邻区域的详细回读信息。',
|
||||
toolNames: ['docx_edit']
|
||||
},
|
||||
'pdf-read': {
|
||||
@@ -49,7 +53,8 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'external',
|
||||
countsTowardToolBudget: true,
|
||||
title: 'pdf-read',
|
||||
description: '读取 PDF 文档信息、页级文本和检索结果或将页面渲染为图片,并返回元数据、正文、匹配位置或图片路径。',
|
||||
description:
|
||||
'读取 PDF 文档信息、页级文本和检索结果或将页面渲染为图片,并返回元数据、正文、匹配位置或图片路径。',
|
||||
toolNames: ['pdf_inspect', 'pdf_search', 'pdf_read_pages', 'pdf_render_pages']
|
||||
},
|
||||
'ppt-read': {
|
||||
@@ -58,7 +63,8 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'external',
|
||||
countsTowardToolBudget: true,
|
||||
title: 'ppt-read',
|
||||
description: '读取 PPTX 的页面结构、内容和搜索结果或将页面渲染为图片,并返回页面提纲、元素、匹配位置或图片路径。',
|
||||
description:
|
||||
'读取 PPTX 的页面结构、内容和搜索结果或将页面渲染为图片,并返回页面提纲、元素、匹配位置或图片路径。',
|
||||
toolNames: ['ppt_read_outline', 'ppt_read_slides', 'ppt_search', 'ppt_render_slides']
|
||||
},
|
||||
'ppt-write': {
|
||||
@@ -67,8 +73,9 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'external',
|
||||
countsTowardToolBudget: true,
|
||||
title: 'ppt-write',
|
||||
description: '创建或编辑 PPTX 页面和元素,并返回写入结果、变化页面回读和布局警告。',
|
||||
toolNames: ['ppt_edit']
|
||||
description: '使用受限 HTML 创建或编辑 PPTX 页面和元素,并返回变化页面回读和布局警告。',
|
||||
toolNames: ['ppt_edit'],
|
||||
requiredSkillIds: ['ppt-read']
|
||||
},
|
||||
'image-recognition': {
|
||||
skillId: 'image-recognition',
|
||||
@@ -85,7 +92,8 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'builtin',
|
||||
countsTowardToolBudget: false,
|
||||
title: 'kb-retrieval',
|
||||
description: '查询工作区知识库的已索引文档或检索相关内容片段,并返回命中证据、来源文档与内容块信息。',
|
||||
description:
|
||||
'查询工作区知识库的已索引文档或检索相关内容片段,并返回命中证据、来源文档与内容块信息。',
|
||||
toolNames: ['kb_list_documents', 'kb_search']
|
||||
},
|
||||
'web-browsing': {
|
||||
@@ -94,7 +102,8 @@ export const SKILL_DEFINITIONS: Record<RuntimeSkillId, SkillDefinition> = {
|
||||
source: 'builtin',
|
||||
countsTowardToolBudget: false,
|
||||
title: 'web-browsing',
|
||||
description: '搜索公开网页、抓取网页正文或操作内置浏览器,并返回候选网页、正文与页面链接或浏览器动作结果。',
|
||||
description:
|
||||
'搜索公开网页、抓取网页正文或操作内置浏览器,并返回候选网页、正文与页面链接或浏览器动作结果。',
|
||||
toolNames: ['web_search', 'web_crawl', 'browser_use']
|
||||
},
|
||||
utility: {
|
||||
@@ -133,10 +142,27 @@ export const getSkillDefinition = (skillId: RuntimeSkillId): SkillDefinition =>
|
||||
}
|
||||
return {
|
||||
...definition,
|
||||
toolNames: [...definition.toolNames]
|
||||
toolNames: [...definition.toolNames],
|
||||
requiredSkillIds: definition.requiredSkillIds ? [...definition.requiredSkillIds] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const expandSkillIdsWithRequirements = (
|
||||
skillIds: readonly RuntimeSkillId[]
|
||||
): RuntimeSkillId[] => {
|
||||
const expanded: RuntimeSkillId[] = []
|
||||
const visited = new Set<RuntimeSkillId>()
|
||||
const visit = (skillId: RuntimeSkillId): void => {
|
||||
if (visited.has(skillId)) return
|
||||
visited.add(skillId)
|
||||
const definition = getSkillDefinition(skillId)
|
||||
for (const requiredSkillId of definition.requiredSkillIds || []) visit(requiredSkillId)
|
||||
expanded.push(skillId)
|
||||
}
|
||||
for (const skillId of skillIds) visit(skillId)
|
||||
return expanded
|
||||
}
|
||||
|
||||
export const listSkillDefinitions = (): SkillDefinition[] =>
|
||||
RUNTIME_SKILL_IDS.map((skillId) => getSkillDefinition(skillId))
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { load } from 'cheerio'
|
||||
import type { PptBounds, PptCompiledElement, PptCompiledSlide, PptElementStyle } from './types'
|
||||
|
||||
export const PPT_CANVAS_WIDTH = 1920
|
||||
export const PPT_CANVAS_HEIGHT = 1080
|
||||
|
||||
const FORBIDDEN_TAGS = new Set([
|
||||
'script',
|
||||
'style',
|
||||
@@ -14,8 +17,18 @@ const FORBIDDEN_TAGS = new Set([
|
||||
'audio',
|
||||
'canvas'
|
||||
])
|
||||
const DEFAULT_WIDTH = 1920
|
||||
const DEFAULT_HEIGHT = 1080
|
||||
|
||||
const TEXT_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li'])
|
||||
const ELEMENT_TYPES = new Set([
|
||||
'text',
|
||||
'shape',
|
||||
'image',
|
||||
'svg',
|
||||
'table',
|
||||
'chart',
|
||||
'formula',
|
||||
'line'
|
||||
])
|
||||
|
||||
const toNumber = (value: string | undefined, base = 0): number | undefined => {
|
||||
const raw = String(value || '').trim()
|
||||
@@ -42,20 +55,53 @@ const parseStyleText = (styleText: string | undefined): Record<string, string> =
|
||||
return out
|
||||
}
|
||||
|
||||
const normalizeColor = (value: string | undefined, fallback?: string): string | undefined => {
|
||||
const assertRestrictedCss = (html: string): void => {
|
||||
const unsupported =
|
||||
/(?:^|[;{])\s*(filter|backdrop-filter|mask(?:-image)?|mix-blend-mode|animation(?:-name|-duration|-timing-function|-iteration-count)?|transition(?:-property|-duration|-timing-function|-delay)?)\s*:/i
|
||||
const match = unsupported.exec(html)
|
||||
if (match) throw new Error(`Unsupported CSS property: ${match[1]}`)
|
||||
}
|
||||
|
||||
const assertCanvasSize = (styleText: string | undefined): void => {
|
||||
const style = parseStyleText(styleText)
|
||||
const width = style.width && toNumber(style.width)
|
||||
const height = style.height && toNumber(style.height)
|
||||
if (width !== undefined && width !== PPT_CANVAS_WIDTH) {
|
||||
throw new Error('Slide root must be exactly 1920x1080.')
|
||||
}
|
||||
if (height !== undefined && height !== PPT_CANVAS_HEIGHT) {
|
||||
throw new Error('Slide root must be exactly 1920x1080.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts browser/CSS colors to the six digit format accepted by pptx-kit. */
|
||||
export const normalizePptColor = (
|
||||
value: string | undefined,
|
||||
fallback?: string
|
||||
): string | undefined => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw || raw === 'transparent' || raw === 'rgba(0, 0, 0, 0)') return fallback
|
||||
if (!raw || raw === 'transparent' || /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/i.test(raw)) {
|
||||
return fallback
|
||||
}
|
||||
const short = /^#([0-9a-f]{3})$/i.exec(raw)
|
||||
if (short) {
|
||||
if (short)
|
||||
return `#${short[1]!
|
||||
.split('')
|
||||
.map((part) => `${part}${part}`)
|
||||
.join('')}`.toUpperCase()
|
||||
}
|
||||
const hex = /^#([0-9a-f]{6})$/i.exec(raw)
|
||||
if (hex) return `#${hex[1]!.toUpperCase()}`
|
||||
const rgb = /^rgba?\((\d+)[, ]+(\d+)[, ]+(\d+)/i.exec(raw)
|
||||
const rgb =
|
||||
/^rgba?\(\s*(\d+(?:\.\d+)?)\s*[, ]\s*(\d+(?:\.\d+)?)\s*[, ]\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(
|
||||
raw
|
||||
)
|
||||
if (rgb) {
|
||||
const alpha = rgb[4]
|
||||
? rgb[4].endsWith('%')
|
||||
? Number(rgb[4].slice(0, -1)) / 100
|
||||
: Number(rgb[4])
|
||||
: 1
|
||||
if (alpha <= 0) return fallback
|
||||
return `#${[rgb[1], rgb[2], rgb[3]]
|
||||
.map((part) =>
|
||||
Math.max(0, Math.min(255, Number(part)))
|
||||
@@ -71,32 +117,59 @@ const normalizeColor = (value: string | undefined, fallback?: string): string |
|
||||
blue: '#0000FF',
|
||||
green: '#008000',
|
||||
gray: '#808080',
|
||||
grey: '#808080'
|
||||
grey: '#808080',
|
||||
transparent: '#FFFFFF'
|
||||
}
|
||||
return names[raw.toLowerCase()] || fallback
|
||||
}
|
||||
|
||||
const mergeRootStyle = (styleText: string | undefined): string => {
|
||||
const style = parseStyleText(styleText)
|
||||
style.position ||= 'relative'
|
||||
style.width ||= `${PPT_CANVAS_WIDTH}px`
|
||||
style.height ||= `${PPT_CANVAS_HEIGHT}px`
|
||||
style.overflow ||= 'hidden'
|
||||
style['box-sizing'] ||= 'border-box'
|
||||
return Object.entries(style)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join(';')
|
||||
}
|
||||
|
||||
const sanitizeHtml = (html: string): string => {
|
||||
const source = String(html || '').trim()
|
||||
if (!source) throw new Error('html 不能为空')
|
||||
if (!source) throw new Error('PPT slide HTML cannot be empty.')
|
||||
const $ = load(source)
|
||||
const forbidden = [...FORBIDDEN_TAGS].filter((tag) => $(tag).length > 0)
|
||||
if (forbidden.length > 0) {
|
||||
throw new Error(`html 包含不支持的标签: ${forbidden.join(', ')}`)
|
||||
}
|
||||
if ($('[data-ppt-slide]').length !== 1) {
|
||||
throw new Error('html 必须且只能包含一个 data-ppt-slide 页面根节点')
|
||||
}
|
||||
if (forbidden.length > 0) throw new Error(`Unsupported HTML tags: ${forbidden.join(', ')}`)
|
||||
const roots = $('[data-ppt-slide]')
|
||||
if (roots.length !== 1) throw new Error('HTML must contain exactly one data-ppt-slide root.')
|
||||
const root = roots.first()
|
||||
assertCanvasSize(root.attr('style'))
|
||||
assertRestrictedCss(source)
|
||||
root.attr('style', mergeRootStyle(root.attr('style')))
|
||||
const remoteAsset = $('[src]')
|
||||
.toArray()
|
||||
.find((element) => /^https?:\/\//i.test($(element).attr('src') || ''))
|
||||
if (remoteAsset || /url\(\s*['"]?https?:\/\//i.test(source)) {
|
||||
throw new Error('html 图片只支持工作区内已存在的本地文件。')
|
||||
if (remoteAsset || /url\(\s*["']?https?:\/\//i.test(source)) {
|
||||
throw new Error('PPT images must use local workspace assets.')
|
||||
}
|
||||
$('*').each((_index, element) => {
|
||||
if (!('attribs' in element)) return
|
||||
const node = $(element)
|
||||
const style = parseStyleText(node.attr('style'))
|
||||
const tagName = 'tagName' in element ? element.tagName.toLowerCase() : ''
|
||||
if (TEXT_TAGS.has(tagName) && !style.margin) style.margin = '0'
|
||||
if (Object.keys(style).length > 0 && !style['box-sizing']) {
|
||||
style['box-sizing'] = 'border-box'
|
||||
node.attr(
|
||||
'style',
|
||||
Object.entries(style)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join(';')
|
||||
)
|
||||
}
|
||||
for (const name of Object.keys(element.attribs || {})) {
|
||||
if (name.toLowerCase().startsWith('on')) $(element).removeAttr(name)
|
||||
if (name.toLowerCase().startsWith('on')) node.removeAttr(name)
|
||||
}
|
||||
})
|
||||
return $.html()
|
||||
@@ -109,8 +182,8 @@ const layoutWithElectron = async (html: string): Promise<PptCompiledSlide | null
|
||||
if (!electron.BrowserWindow || !electron.app?.isReady()) return null
|
||||
const window = new electron.BrowserWindow({
|
||||
show: false,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
width: PPT_CANVAS_WIDTH,
|
||||
height: PPT_CANVAS_HEIGHT,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
@@ -122,76 +195,260 @@ const layoutWithElectron = async (html: string): Promise<PptCompiledSlide | null
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
try {
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
|
||||
const result = (await window.webContents.executeJavaScript(`(() => {
|
||||
const root = document.querySelector('[data-ppt-slide]') || document.body.firstElementChild || document.body;
|
||||
return (await window.webContents.executeJavaScript(`(async () => {
|
||||
await document.fonts.ready;
|
||||
const root = document.querySelector('[data-ppt-slide]');
|
||||
if (!root) throw new Error('Missing data-ppt-slide root.');
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const cleanText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const color = (value) => value && value !== 'rgba(0, 0, 0, 0)' && value !== 'transparent' ? value : undefined;
|
||||
const elements = [];
|
||||
const candidates = Array.from(root.querySelectorAll('*'));
|
||||
candidates.forEach((element, index) => {
|
||||
if (['STYLE','META','LINK','TITLE','HEAD'].includes(element.tagName)) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
const color = (value) => {
|
||||
if (!value || value === 'transparent' || value === 'rgba(0, 0, 0, 0)') return undefined;
|
||||
const match = /^rgba?\\(\\s*(\\d+)\\s*[, ]\\s*(\\d+)\\s*[, ]\\s*(\\d+)(?:\\s*[,/]\\s*([\\d.]+%?))?\\s*\\)$/i.exec(value);
|
||||
if (match && match[4] && Number(match[4].replace('%', '')) === 0) return undefined;
|
||||
if (!match) return value;
|
||||
return '#' + [match[1], match[2], match[3]].map((part) => Number(part).toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||
};
|
||||
const px = (value) => { const n = Number.parseFloat(value || ''); return Number.isFinite(n) ? n : undefined; };
|
||||
const sameBorder = (borders) => {
|
||||
const values = Object.values(borders).filter(Boolean);
|
||||
return values.length === 4 && values.every((item) => item.color === values[0].color && item.width === values[0].width);
|
||||
};
|
||||
const styleOf = (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return;
|
||||
const bounds = { x: rect.left - rootRect.left, y: rect.top - rootRect.top, width: rect.width, height: rect.height };
|
||||
const commonStyle = {
|
||||
const borders = {
|
||||
top: { color: color(style.borderTopColor), width: px(style.borderTopWidth) || 0 },
|
||||
right: { color: color(style.borderRightColor), width: px(style.borderRightWidth) || 0 },
|
||||
bottom: { color: color(style.borderBottomColor), width: px(style.borderBottomWidth) || 0 },
|
||||
left: { color: color(style.borderLeftColor), width: px(style.borderLeftWidth) || 0 }
|
||||
};
|
||||
const activeBorders = Object.fromEntries(Object.entries(borders).filter(([, item]) => item.width > 0));
|
||||
const uniform = sameBorder(borders);
|
||||
return {
|
||||
fill: color(style.backgroundColor),
|
||||
stroke: color(style.borderTopColor),
|
||||
strokeWidth: parseFloat(style.borderTopWidth) || undefined,
|
||||
radius: parseFloat(style.borderTopLeftRadius) || undefined,
|
||||
stroke: uniform ? borders.top.color : undefined,
|
||||
strokeWidth: uniform ? borders.top.width : undefined,
|
||||
borders: uniform ? undefined : activeBorders,
|
||||
radius: px(style.borderTopLeftRadius) || undefined,
|
||||
opacity: Number(style.opacity) || 1,
|
||||
rotation: (() => { const match = /rotate\\(([-\\d.]+)deg\\)/i.exec(style.transform); return match ? Number(match[1]) : undefined; })(),
|
||||
color: color(style.color),
|
||||
fontFamily: style.fontFamily.split(',')[0]?.replace(/["']/g, '').trim(),
|
||||
fontSize: parseFloat(style.fontSize) || undefined,
|
||||
fontWeight: parseInt(style.fontWeight, 10) || undefined,
|
||||
fontSize: px(style.fontSize) || undefined,
|
||||
fontWeight: Number.parseInt(style.fontWeight, 10) || undefined,
|
||||
italic: style.fontStyle === 'italic',
|
||||
textAlign: ['left','center','right'].includes(style.textAlign) ? style.textAlign : 'left',
|
||||
verticalAlign: style.alignItems === 'center' ? 'middle' : undefined,
|
||||
shadow: style.boxShadow !== 'none' ? style.boxShadow : undefined
|
||||
};
|
||||
const sourceId = element.id || element.getAttribute('data-ppt-id') || 'html-' + (index + 1);
|
||||
if (element.hasAttribute('data-pptx-latex')) {
|
||||
elements.push({ sourceId, type: 'formula', bounds, style: commonStyle, latex: element.getAttribute('data-pptx-latex') || '', zIndex: Number(style.zIndex) || index });
|
||||
};
|
||||
const explicitType = (element) => {
|
||||
const value = element.getAttribute('data-ppt-element');
|
||||
return value && ${JSON.stringify([...ELEMENT_TYPES])}.includes(value) ? value : undefined;
|
||||
};
|
||||
const hasExplicitAncestor = (element) => Boolean(element.parentElement?.closest('[data-ppt-element]'));
|
||||
const semanticText = (element) => ['H1','H2','H3','H4','H5','H6','P','LI'].includes(element.tagName) ? cleanText(element.textContent) : '';
|
||||
const directText = (element) => cleanText(Array.from(element.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent).join(' '));
|
||||
const elements = [];
|
||||
const candidates = Array.from(root.querySelectorAll('[data-ppt-element], [data-pptx-latex], [data-pptx-chart], img, svg, table, h1, h2, h3, h4, h5, h6, p, li, div'));
|
||||
candidates.forEach((element, index) => {
|
||||
if (element === root || element.closest('[data-ppt-slide]') !== root) return;
|
||||
const type = explicitType(element) || (element.hasAttribute('data-pptx-latex') ? 'formula' : element.hasAttribute('data-pptx-chart') ? 'chart' : element.tagName === 'IMG' ? 'image' : element.tagName === 'SVG' ? 'svg' : element.tagName === 'TABLE' ? 'table' : ${JSON.stringify([...TEXT_TAGS]).toUpperCase()}.includes(element.tagName) && !hasExplicitAncestor(element) ? 'text' : undefined);
|
||||
const style = getComputedStyle(element);
|
||||
const hasVisualBox = Boolean(color(style.backgroundColor)) || Number.parseFloat(style.borderTopWidth) > 0 || Boolean(element.getAttribute('data-ppt-element') === 'shape');
|
||||
const inferredText = !hasExplicitAncestor(element) ? (semanticText(element) || directText(element)) : '';
|
||||
const inferredType = type || (inferredText ? 'text' : hasVisualBox && !hasExplicitAncestor(element) ? 'shape' : undefined);
|
||||
if (!inferredType) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0 || style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return;
|
||||
const bounds = { x: rect.left - rootRect.left, y: rect.top - rootRect.top, width: rect.width, height: rect.height };
|
||||
const common = styleOf(element);
|
||||
const sourceId = element.id || element.getAttribute('data-ppt-id') || ('html-' + (index + 1));
|
||||
const zIndex = Number(style.zIndex);
|
||||
const z = Number.isFinite(zIndex) ? zIndex : index;
|
||||
if (inferredType === 'formula') {
|
||||
elements.push({ sourceId, type: 'formula', bounds, style: common, latex: element.getAttribute('data-pptx-latex') || '', zIndex: z });
|
||||
return;
|
||||
}
|
||||
if (element.hasAttribute('data-pptx-chart')) {
|
||||
const chartType = element.getAttribute('data-pptx-chart') || 'column';
|
||||
const categories = JSON.parse(element.getAttribute('data-pptx-categories') || '[]');
|
||||
const series = JSON.parse(element.getAttribute('data-pptx-series') || '[]');
|
||||
const rows = [['', ...series.map(item => String(item.name || '数据'))], ...categories.map((category, rowIndex) => [String(category), ...series.map(item => String(item.values?.[rowIndex] ?? 0))])];
|
||||
elements.push({ sourceId, type: 'chart', bounds, style: commonStyle, chartType, rows, zIndex: Number(style.zIndex) || index });
|
||||
if (inferredType === 'chart') {
|
||||
let categories = []; let series = [];
|
||||
try { categories = JSON.parse(element.getAttribute('data-pptx-categories') || '[]'); series = JSON.parse(element.getAttribute('data-pptx-series') || '[]'); } catch { throw new Error('Invalid chart data: ' + sourceId); }
|
||||
const rows = [['', ...series.map((item) => String(item.name || 'Data'))], ...categories.map((category, rowIndex) => [String(category), ...series.map((item) => String(item.values?.[rowIndex] ?? 0))])];
|
||||
elements.push({ sourceId, type: 'chart', bounds, style: common, chartType: element.getAttribute('data-pptx-chart') || 'column', rows, zIndex: z });
|
||||
return;
|
||||
}
|
||||
if (element.tagName === 'IMG') {
|
||||
elements.push({ sourceId, type: 'image', bounds, style: commonStyle, src: element.getAttribute('src') || '', zIndex: Number(style.zIndex) || index });
|
||||
if (inferredType === 'image') {
|
||||
elements.push({ sourceId, type: 'image', bounds, style: common, src: element.getAttribute('src') || '', imageFit: style.objectFit === 'contain' ? 'contain' : style.objectFit === 'cover' ? 'cover' : 'fill', zIndex: z });
|
||||
return;
|
||||
}
|
||||
if (element.tagName === 'SVG') {
|
||||
elements.push({ sourceId, type: 'svg', bounds, style: commonStyle, svg: element.outerHTML, zIndex: Number(style.zIndex) || index });
|
||||
if (inferredType === 'svg') {
|
||||
elements.push({ sourceId, type: 'svg', bounds, style: common, svg: element.outerHTML, zIndex: z });
|
||||
return;
|
||||
}
|
||||
if (element.tagName === 'TABLE') {
|
||||
const rows = Array.from(element.querySelectorAll('tr')).map(row => Array.from(row.querySelectorAll('th,td')).map(cell => cleanText(cell.textContent)));
|
||||
elements.push({ sourceId, type: 'table', bounds, style: commonStyle, rows, zIndex: Number(style.zIndex) || index });
|
||||
if (inferredType === 'table') {
|
||||
const rows = Array.from(element.querySelectorAll('tr')).map((row) => Array.from(row.querySelectorAll('th,td')).map((cell) => cleanText(cell.textContent)));
|
||||
elements.push({ sourceId, type: 'table', bounds, style: common, rows, zIndex: z });
|
||||
return;
|
||||
}
|
||||
const directText = cleanText(Array.from(element.childNodes).filter(node => node.nodeType === Node.TEXT_NODE).map(node => node.textContent).join(' '));
|
||||
const semanticText = ['H1','H2','H3','H4','H5','H6','P','SPAN','LI'].includes(element.tagName) ? cleanText(element.textContent) : directText;
|
||||
const hasVisualBox = color(style.backgroundColor) || parseFloat(style.borderTopWidth) > 0;
|
||||
if (hasVisualBox) elements.push({ sourceId: sourceId + '-box', type: 'shape', bounds, style: commonStyle, zIndex: Number(style.zIndex) || index });
|
||||
if (semanticText) elements.push({ sourceId: sourceId + '-text', type: 'text', bounds, style: commonStyle, text: semanticText, zIndex: (Number(style.zIndex) || index) + 0.1 });
|
||||
if (inferredType === 'line') {
|
||||
const from = { x: 0, y: 0 }; const to = { x: bounds.width, y: bounds.height };
|
||||
elements.push({ sourceId, type: 'line', bounds, style: common, line: { from, to }, zIndex: z });
|
||||
return;
|
||||
}
|
||||
if (inferredType === 'text') {
|
||||
const text = semanticText(element) || directText(element) || cleanText(element.textContent);
|
||||
if (text) elements.push({ sourceId, type: 'text', bounds, style: common, text, zIndex: z });
|
||||
return;
|
||||
}
|
||||
elements.push({ sourceId, type: 'shape', bounds, style: common, zIndex: z });
|
||||
});
|
||||
const rootStyle = getComputedStyle(root);
|
||||
const notes = document.querySelector('[data-ppt-notes]')?.textContent?.trim();
|
||||
return { width: rootRect.width || ${DEFAULT_WIDTH}, height: rootRect.height || ${DEFAULT_HEIGHT}, background: color(rootStyle.backgroundColor) || '#FFFFFF', notes, elements, warnings: [] };
|
||||
const warnings = [];
|
||||
if (rootRect.width !== ${PPT_CANVAS_WIDTH} || rootRect.height !== ${PPT_CANVAS_HEIGHT}) warnings.push('Slide root must be exactly 1920x1080.');
|
||||
candidates.forEach((element) => { const style = getComputedStyle(element); if (style.filter !== 'none' || style.mixBlendMode !== 'normal' || style.animationName !== 'none') warnings.push('Unsupported CSS on ' + (element.id || element.tagName) + '.'); });
|
||||
return { width: ${PPT_CANVAS_WIDTH}, height: ${PPT_CANVAS_HEIGHT}, background: color(rootStyle.backgroundColor) || '#FFFFFF', notes: document.querySelector('[data-ppt-notes]')?.textContent?.trim(), elements, warnings };
|
||||
})()`)) as PptCompiledSlide
|
||||
return result
|
||||
} finally {
|
||||
window.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /Unsupported CSS|Invalid chart|exactly 1920/.test(error.message))
|
||||
throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const layoutWithPlaywright = async (html: string): Promise<PptCompiledSlide | null> => {
|
||||
if (process.versions.electron) return null
|
||||
let browser: Awaited<ReturnType<(typeof import('playwright'))['chromium']['launch']>> | undefined
|
||||
try {
|
||||
const { chromium } = await import('playwright')
|
||||
const channels = process.platform === 'win32' ? ['msedge', 'chrome'] : ['chrome', 'msedge']
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
browser = await chromium.launch({ channel, headless: true })
|
||||
break
|
||||
} catch {
|
||||
browser = undefined
|
||||
}
|
||||
}
|
||||
if (!browser) return null
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: PPT_CANVAS_WIDTH, height: PPT_CANVAS_HEIGHT }
|
||||
})
|
||||
await page.setContent(html, { waitUntil: 'load' })
|
||||
const positionedHtml = await page.evaluate(
|
||||
async ({ elementTypes, textTags }) => {
|
||||
await document.fonts.ready
|
||||
const root = document.querySelector<HTMLElement>('[data-ppt-slide]')
|
||||
if (!root) throw new Error('Missing data-ppt-slide root.')
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const cleanText = (value: string | null | undefined) =>
|
||||
String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const candidates = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'[data-ppt-element], [data-pptx-latex], [data-pptx-chart], img, svg, table, h1, h2, h3, h4, h5, h6, p, li, div'
|
||||
)
|
||||
)
|
||||
const records = candidates.flatMap((element, index) => {
|
||||
if (element.closest('[data-ppt-slide]') !== root) return []
|
||||
const style = getComputedStyle(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0 ||
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
Number(style.opacity) === 0
|
||||
)
|
||||
return []
|
||||
const explicit = element.getAttribute('data-ppt-element')
|
||||
const hasExplicitAncestor = Boolean(element.parentElement?.closest('[data-ppt-element]'))
|
||||
const directText = cleanText(
|
||||
Array.from(element.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent)
|
||||
.join(' ')
|
||||
)
|
||||
const semanticText = textTags.includes(element.tagName)
|
||||
? cleanText(element.textContent)
|
||||
: ''
|
||||
const hasVisualBox =
|
||||
style.backgroundColor !== 'rgba(0, 0, 0, 0)' ||
|
||||
Number.parseFloat(style.borderTopWidth) > 0 ||
|
||||
explicit === 'shape'
|
||||
const inferred =
|
||||
explicit && elementTypes.includes(explicit)
|
||||
? explicit
|
||||
: element.hasAttribute('data-pptx-latex')
|
||||
? 'formula'
|
||||
: element.hasAttribute('data-pptx-chart')
|
||||
? 'chart'
|
||||
: element.tagName === 'IMG'
|
||||
? 'image'
|
||||
: element.tagName === 'SVG'
|
||||
? 'svg'
|
||||
: element.tagName === 'TABLE'
|
||||
? 'table'
|
||||
: !hasExplicitAncestor && (semanticText || directText)
|
||||
? 'text'
|
||||
: !hasExplicitAncestor && hasVisualBox
|
||||
? 'shape'
|
||||
: undefined
|
||||
if (!inferred) return []
|
||||
const propertyNames = [
|
||||
'background-color',
|
||||
'border-top-color',
|
||||
'border-top-width',
|
||||
'border-right-color',
|
||||
'border-right-width',
|
||||
'border-bottom-color',
|
||||
'border-bottom-width',
|
||||
'border-left-color',
|
||||
'border-left-width',
|
||||
'border-radius',
|
||||
'opacity',
|
||||
'transform',
|
||||
'box-shadow',
|
||||
'color',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-style',
|
||||
'text-align',
|
||||
'object-fit'
|
||||
]
|
||||
const declarations = [
|
||||
'position:absolute',
|
||||
`left:${rect.left - rootRect.left}px`,
|
||||
`top:${rect.top - rootRect.top}px`,
|
||||
`width:${rect.width}px`,
|
||||
`height:${rect.height}px`,
|
||||
`z-index:${Number.isFinite(Number(style.zIndex)) ? style.zIndex : index}`,
|
||||
...propertyNames.map((name) => `${name}:${style.getPropertyValue(name)}`)
|
||||
]
|
||||
return [{ element, declarations }]
|
||||
})
|
||||
records.forEach(({ element, declarations }) =>
|
||||
element.setAttribute('style', declarations.join(';'))
|
||||
)
|
||||
return document.documentElement.outerHTML
|
||||
},
|
||||
{ elementTypes: [...ELEMENT_TYPES], textTags: [...TEXT_TAGS].map((tag) => tag.toUpperCase()) }
|
||||
)
|
||||
const compiled = layoutFallback(positionedHtml)
|
||||
return {
|
||||
...compiled,
|
||||
warnings: compiled.warnings.filter(
|
||||
(warning) =>
|
||||
warning !== 'Chromium layout was unavailable; explicit element bounds were used.'
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
await browser?.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,64 +457,124 @@ const fallbackBounds = (
|
||||
parent: PptBounds,
|
||||
index: number,
|
||||
siblingCount: number
|
||||
): PptBounds => {
|
||||
const width = toNumber(style.width, parent.width) ?? parent.width * 0.8
|
||||
const height =
|
||||
): PptBounds => ({
|
||||
x: parent.x + (toNumber(style.left, parent.width) ?? parent.width * 0.08),
|
||||
y: parent.y + (toNumber(style.top, parent.height) ?? parent.height * 0.08 + index * 24),
|
||||
width: toNumber(style.width, parent.width) ?? parent.width * 0.8,
|
||||
height:
|
||||
toNumber(style.height, parent.height) ??
|
||||
Math.max(48, parent.height / Math.max(2, siblingCount + 1))
|
||||
const x =
|
||||
parent.x +
|
||||
(toNumber(style.left, parent.width) ??
|
||||
toNumber(style['margin-left'], parent.width) ??
|
||||
parent.width * 0.1)
|
||||
const y =
|
||||
parent.y + (toNumber(style.top, parent.height) ?? parent.height * 0.08 + index * (height + 18))
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
const fallbackStyle = (style: Record<string, string>): PptElementStyle => ({
|
||||
fill: normalizeColor(style['background-color'] || style.background),
|
||||
stroke: normalizeColor(style['border-color']),
|
||||
strokeWidth: toNumber(style['border-width']),
|
||||
radius: toNumber(style['border-radius']),
|
||||
opacity: toNumber(style.opacity) ?? 1,
|
||||
rotation: style.transform?.includes('rotate')
|
||||
? toNumber(/rotate\(([-\d.]+)/.exec(style.transform)?.[1])
|
||||
: undefined,
|
||||
shadow: style['box-shadow'],
|
||||
color: normalizeColor(style.color, '#222222'),
|
||||
fontFamily: style['font-family']?.split(',')[0]?.replace(/["']/g, '').trim() || 'Arial',
|
||||
fontSize: toNumber(style['font-size']) ?? 32,
|
||||
fontWeight:
|
||||
Number.parseInt(style['font-weight'] || '400', 10) ||
|
||||
(style['font-weight'] === 'bold' ? 700 : 400),
|
||||
italic: style['font-style'] === 'italic',
|
||||
textAlign:
|
||||
style['text-align'] === 'center' || style['text-align'] === 'right'
|
||||
? style['text-align']
|
||||
: 'left'
|
||||
})
|
||||
|
||||
const fallbackStyle = (style: Record<string, string>): PptElementStyle => {
|
||||
const borderSpec = (side: 'top' | 'right' | 'bottom' | 'left') => {
|
||||
const shorthand = style[`border-${side}`] || style.border
|
||||
const width =
|
||||
style[`border-${side}-width`] ||
|
||||
style['border-width'] ||
|
||||
shorthand?.match(/(?:^|\s)(\d+(?:\.\d+)?)(?:px|pt)?(?:\s|$)/i)?.[1]
|
||||
const color =
|
||||
style[`border-${side}-color`] ||
|
||||
style['border-color'] ||
|
||||
shorthand?.match(/(#[0-9a-f]{3,8}|rgba?\([^)]*\)|[a-z]+)\s*$/i)?.[1]
|
||||
return { color: normalizePptColor(color), width: toNumber(width) || 0 }
|
||||
}
|
||||
const sides = (['top', 'right', 'bottom', 'left'] as const).map((side) => ({
|
||||
side,
|
||||
...borderSpec(side)
|
||||
}))
|
||||
const activeSides = sides.filter((item) => item.width > 0 && item.color)
|
||||
const uniform =
|
||||
activeSides.length === 4 &&
|
||||
activeSides.every(
|
||||
(item) => item.color === activeSides[0]!.color && item.width === activeSides[0]!.width
|
||||
)
|
||||
return {
|
||||
fill: normalizePptColor(style['background-color'] || style.background),
|
||||
stroke: uniform ? activeSides[0]!.color : undefined,
|
||||
strokeWidth: uniform ? activeSides[0]!.width : undefined,
|
||||
borders: uniform
|
||||
? undefined
|
||||
: (Object.fromEntries(
|
||||
activeSides.map((item) => [item.side, { color: item.color!, width: item.width }])
|
||||
) as PptElementStyle['borders']),
|
||||
radius: toNumber(style['border-radius']),
|
||||
opacity: toNumber(style.opacity) ?? 1,
|
||||
rotation: style.transform?.includes('rotate')
|
||||
? toNumber(/rotate\(([-\d.]+)/.exec(style.transform)?.[1])
|
||||
: undefined,
|
||||
shadow: style['box-shadow'] && style['box-shadow'] !== 'none' ? style['box-shadow'] : undefined,
|
||||
color: normalizePptColor(style.color, '#222222'),
|
||||
fontFamily: style['font-family']?.split(',')[0]?.replace(/["']/g, '').trim() || 'Arial',
|
||||
fontSize: toNumber(style['font-size']) ?? 32,
|
||||
fontWeight:
|
||||
Number.parseInt(style['font-weight'] || '400', 10) ||
|
||||
(style['font-weight'] === 'bold' ? 700 : 400),
|
||||
italic: style['font-style'] === 'italic',
|
||||
textAlign:
|
||||
style['text-align'] === 'center' || style['text-align'] === 'right'
|
||||
? style['text-align']
|
||||
: 'left'
|
||||
}
|
||||
}
|
||||
|
||||
const layoutFallback = (html: string): PptCompiledSlide => {
|
||||
const $ = load(html)
|
||||
const root = $('[data-ppt-slide]').first().length
|
||||
? $('[data-ppt-slide]').first()
|
||||
: $('body').children().not('style,meta,link').first()
|
||||
const root = $('[data-ppt-slide]').first()
|
||||
const rootStyle = parseStyleText(root.attr('style'))
|
||||
const width = toNumber(rootStyle.width) ?? DEFAULT_WIDTH
|
||||
const height = toNumber(rootStyle.height) ?? DEFAULT_HEIGHT
|
||||
const width = toNumber(rootStyle.width) ?? PPT_CANVAS_WIDTH
|
||||
const height = toNumber(rootStyle.height) ?? PPT_CANVAS_HEIGHT
|
||||
const elements: PptCompiledElement[] = []
|
||||
const children = root
|
||||
.find('*')
|
||||
.filter((_index, element) => !['style', 'meta', 'link'].includes(element.tagName))
|
||||
children.each((index, element) => {
|
||||
const candidates = root.find(
|
||||
'[data-ppt-element], [data-pptx-latex], [data-pptx-chart], img, svg, table, h1, h2, h3, h4, h5, h6, p, li, div'
|
||||
)
|
||||
candidates.each((index, element) => {
|
||||
const node = $(element)
|
||||
const tag = element.tagName.toLowerCase()
|
||||
const explicit = node.attr('data-ppt-element')
|
||||
const hasExplicitAncestor = Boolean(
|
||||
element.parent && $(element.parent).closest('[data-ppt-element]').length > 0
|
||||
)
|
||||
const directText = node
|
||||
.contents()
|
||||
.filter((_childIndex, child) => child.type === 'text')
|
||||
.text()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const style = parseStyleText(node.attr('style'))
|
||||
const bounds = fallbackBounds(style, { x: 0, y: 0, width, height }, index, children.length)
|
||||
const hasVisualBox = Boolean(
|
||||
style['background-color'] ||
|
||||
style.background ||
|
||||
toNumber(style['border-width']) ||
|
||||
toNumber(style['border-top-width']) ||
|
||||
style.border ||
|
||||
style['border-top']
|
||||
)
|
||||
const type =
|
||||
explicit && ELEMENT_TYPES.has(explicit)
|
||||
? explicit
|
||||
: node.attr('data-pptx-latex')
|
||||
? 'formula'
|
||||
: node.attr('data-pptx-chart')
|
||||
? 'chart'
|
||||
: tag === 'img'
|
||||
? 'image'
|
||||
: tag === 'svg'
|
||||
? 'svg'
|
||||
: tag === 'table'
|
||||
? 'table'
|
||||
: TEXT_TAGS.has(tag) && !hasExplicitAncestor
|
||||
? 'text'
|
||||
: directText && !hasExplicitAncestor
|
||||
? 'text'
|
||||
: hasVisualBox && !hasExplicitAncestor
|
||||
? 'shape'
|
||||
: undefined
|
||||
if (!type) return
|
||||
const bounds = fallbackBounds(style, { x: 0, y: 0, width, height }, index, candidates.length)
|
||||
const elementStyle = fallbackStyle(style)
|
||||
const sourceId = node.attr('id') || node.attr('data-ppt-id') || `html-${index + 1}`
|
||||
const tag = element.tagName.toLowerCase()
|
||||
if (node.attr('data-pptx-latex')) {
|
||||
if (type === 'formula')
|
||||
elements.push({
|
||||
sourceId,
|
||||
type: 'formula',
|
||||
@@ -266,17 +583,14 @@ const layoutFallback = (html: string): PptCompiledSlide => {
|
||||
latex: node.attr('data-pptx-latex'),
|
||||
zIndex: index
|
||||
})
|
||||
return
|
||||
}
|
||||
if (node.attr('data-pptx-chart')) {
|
||||
const chartType = node.attr('data-pptx-chart') || 'column'
|
||||
else if (type === 'chart') {
|
||||
const categories = JSON.parse(node.attr('data-pptx-categories') || '[]') as unknown[]
|
||||
const series = JSON.parse(node.attr('data-pptx-series') || '[]') as Array<{
|
||||
name?: unknown
|
||||
values?: unknown[]
|
||||
}>
|
||||
const rows = [
|
||||
['', ...series.map((item) => String(item.name || '数据'))],
|
||||
['', ...series.map((item) => String(item.name || 'Data'))],
|
||||
...categories.map((category, rowIndex) => [
|
||||
String(category),
|
||||
...series.map((item) => String(item.values?.[rowIndex] ?? 0))
|
||||
@@ -287,24 +601,26 @@ const layoutFallback = (html: string): PptCompiledSlide => {
|
||||
type: 'chart',
|
||||
bounds,
|
||||
style: elementStyle,
|
||||
chartType: chartType as PptCompiledElement['chartType'],
|
||||
chartType: node.attr('data-pptx-chart') as PptCompiledElement['chartType'],
|
||||
rows,
|
||||
zIndex: index
|
||||
})
|
||||
return
|
||||
}
|
||||
if (tag === 'img') {
|
||||
} else if (type === 'image')
|
||||
elements.push({
|
||||
sourceId,
|
||||
type: 'image',
|
||||
bounds,
|
||||
style: elementStyle,
|
||||
src: node.attr('src') || '',
|
||||
imageFit:
|
||||
style['object-fit'] === 'contain'
|
||||
? 'contain'
|
||||
: style['object-fit'] === 'cover'
|
||||
? 'cover'
|
||||
: 'fill',
|
||||
zIndex: index
|
||||
})
|
||||
return
|
||||
}
|
||||
if (tag === 'svg') {
|
||||
else if (type === 'svg')
|
||||
elements.push({
|
||||
sourceId,
|
||||
type: 'svg',
|
||||
@@ -313,81 +629,138 @@ const layoutFallback = (html: string): PptCompiledSlide => {
|
||||
svg: $.html(node),
|
||||
zIndex: index
|
||||
})
|
||||
return
|
||||
}
|
||||
if (tag === 'table') {
|
||||
const rows = node
|
||||
.find('tr')
|
||||
.toArray()
|
||||
.map((row) =>
|
||||
$(row)
|
||||
.find('th,td')
|
||||
.toArray()
|
||||
.map((cell) => $(cell).text().trim())
|
||||
)
|
||||
elements.push({ sourceId, type: 'table', bounds, style: elementStyle, rows, zIndex: index })
|
||||
return
|
||||
}
|
||||
const directText = node
|
||||
.contents()
|
||||
.filter((_childIndex, child) => child.type === 'text')
|
||||
.text()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const semanticText = /^h[1-6]$|p|span|li/.test(tag)
|
||||
? node.text().replace(/\s+/g, ' ').trim()
|
||||
: directText
|
||||
if (elementStyle.fill || elementStyle.stroke) {
|
||||
else if (type === 'table')
|
||||
elements.push({
|
||||
sourceId: `${sourceId}-box`,
|
||||
type: 'shape',
|
||||
sourceId,
|
||||
type: 'table',
|
||||
bounds,
|
||||
style: elementStyle,
|
||||
rows: node
|
||||
.find('tr')
|
||||
.toArray()
|
||||
.map((row) =>
|
||||
$(row)
|
||||
.find('th,td')
|
||||
.toArray()
|
||||
.map((cell) => $(cell).text().trim())
|
||||
),
|
||||
zIndex: index
|
||||
})
|
||||
}
|
||||
if (semanticText) {
|
||||
else if (type === 'text')
|
||||
elements.push({
|
||||
sourceId: `${sourceId}-text`,
|
||||
sourceId,
|
||||
type: 'text',
|
||||
bounds,
|
||||
style: elementStyle,
|
||||
text: semanticText,
|
||||
zIndex: index + 0.1
|
||||
text: node.text().replace(/\s+/g, ' ').trim(),
|
||||
zIndex: index
|
||||
})
|
||||
}
|
||||
else elements.push({ sourceId, type: 'shape', bounds, style: elementStyle, zIndex: index })
|
||||
})
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
width: PPT_CANVAS_WIDTH,
|
||||
height: PPT_CANVAS_HEIGHT,
|
||||
background:
|
||||
normalizeColor(rootStyle['background-color'] || rootStyle.background, '#FFFFFF') || '#FFFFFF',
|
||||
notes: $('[data-ppt-notes]').first().text().trim() || undefined,
|
||||
normalizePptColor(rootStyle['background-color'] || rootStyle.background, '#FFFFFF') ||
|
||||
'#FFFFFF',
|
||||
elements,
|
||||
warnings: ['当前环境使用确定性 HTML 布局回退;实际应用会使用 Chromium 计算布局。']
|
||||
warnings: ['Chromium layout was unavailable; explicit element bounds were used.']
|
||||
}
|
||||
}
|
||||
|
||||
export const compilePptSlideHtml = async (html: string): Promise<PptCompiledSlide> => {
|
||||
const sanitized = sanitizeHtml(html)
|
||||
const compiled = (await layoutWithElectron(sanitized)) || layoutFallback(sanitized)
|
||||
if (compiled.width <= 0 || compiled.height <= 0) throw new Error('页面尺寸无效')
|
||||
const chartTypes = new Set(['bar', 'column', 'line', 'pie', 'doughnut', 'area'])
|
||||
const restrictedHtml = load(sanitized)
|
||||
const unsupportedBackgrounds = restrictedHtml('[style]')
|
||||
.toArray()
|
||||
.flatMap((node) => {
|
||||
const style = parseStyleText(restrictedHtml(node).attr('style') || '')
|
||||
const background = `${style.background || ''} ${style['background-image'] || ''}`
|
||||
return /(?:gradient|url\s*\()/i.test(background)
|
||||
? [restrictedHtml(node).attr('id') || node.tagName.toLowerCase()]
|
||||
: []
|
||||
})
|
||||
if (unsupportedBackgrounds.length > 0) {
|
||||
throw new Error(
|
||||
`Unsupported CSS background on ${unsupportedBackgrounds.slice(0, 6).join(', ')}. Use a solid background-color or an <img> element.`
|
||||
)
|
||||
}
|
||||
const compiled =
|
||||
(await layoutWithElectron(sanitized)) ||
|
||||
(await layoutWithPlaywright(sanitized)) ||
|
||||
layoutFallback(sanitized)
|
||||
compiled.width = PPT_CANVAS_WIDTH
|
||||
compiled.height = PPT_CANVAS_HEIGHT
|
||||
const invalidChart = compiled.elements.find(
|
||||
(element) =>
|
||||
element.type === 'chart' &&
|
||||
(!element.chartType || !chartTypes.has(element.chartType) || (element.rows?.length || 0) < 2)
|
||||
(element) => element.type === 'chart' && (!element.chartType || (element.rows?.length || 0) < 2)
|
||||
)
|
||||
if (invalidChart) throw new Error(`图表配置无效: ${invalidChart.sourceId}`)
|
||||
if (invalidChart) throw new Error(`Invalid chart configuration: ${invalidChart.sourceId}`)
|
||||
const outside = compiled.elements.filter(
|
||||
(element) =>
|
||||
element.bounds.x < 0 ||
|
||||
element.bounds.y < 0 ||
|
||||
element.bounds.x + element.bounds.width > compiled.width + 1 ||
|
||||
element.bounds.y + element.bounds.height > compiled.height + 1
|
||||
element.bounds.x < -1 ||
|
||||
element.bounds.y < -1 ||
|
||||
element.bounds.x + element.bounds.width > PPT_CANVAS_WIDTH + 1 ||
|
||||
element.bounds.y + element.bounds.height > PPT_CANVAS_HEIGHT + 1
|
||||
)
|
||||
if (outside.length > 0) {
|
||||
compiled.warnings.push(`有 ${outside.length} 个元素超出页面范围。`)
|
||||
const details = outside
|
||||
.slice(0, 6)
|
||||
.map((element) => {
|
||||
const { x, y, width, height } = element.bounds
|
||||
return `${element.sourceId} (x=${Math.round(x)}, y=${Math.round(y)}, width=${Math.round(width)}, height=${Math.round(height)}, right=${Math.round(x + width)}, bottom=${Math.round(y + height)})`
|
||||
})
|
||||
.join('; ')
|
||||
throw new Error(
|
||||
`${outside.length} PPT elements exceed the 1920x1080 slide bounds: ${details}. Keep right <= 1920 and bottom <= 1080.`
|
||||
)
|
||||
}
|
||||
const undersizedText = compiled.elements.filter(
|
||||
(element) =>
|
||||
element.type === 'text' &&
|
||||
Boolean(element.text?.trim()) &&
|
||||
(Number(element.style.fontSize || 0) < 24 ||
|
||||
((element.text?.trim().length || 0) >= 18 && Number(element.style.fontSize || 0) < 32))
|
||||
)
|
||||
if (undersizedText.length > 0) {
|
||||
throw new Error(
|
||||
`PPT text must be at least 24px, and body copy with 18 or more characters must be at least 32px: ${undersizedText
|
||||
.slice(0, 8)
|
||||
.map((element) => `${element.sourceId} (${element.style.fontSize || 0}px)`)
|
||||
.join('; ')}.`
|
||||
)
|
||||
}
|
||||
const textElements = compiled.elements.filter(
|
||||
(element) => element.type === 'text' && Boolean(element.text?.trim())
|
||||
)
|
||||
if (
|
||||
textElements.length > 0 &&
|
||||
!textElements.some((element) => Number(element.style.fontSize) >= 70)
|
||||
) {
|
||||
throw new Error('Each PPT slide must include a title of at least 70px (35pt).')
|
||||
}
|
||||
const unsafeText = textElements.filter(
|
||||
(element) =>
|
||||
element.bounds.x < 60 ||
|
||||
element.bounds.y < 60 ||
|
||||
element.bounds.x + element.bounds.width > PPT_CANVAS_WIDTH - 60 ||
|
||||
element.bounds.y + element.bounds.height > PPT_CANVAS_HEIGHT - 60
|
||||
)
|
||||
if (unsafeText.length > 0) {
|
||||
throw new Error(
|
||||
`PPT text must stay inside the 60px safe area: ${unsafeText
|
||||
.slice(0, 8)
|
||||
.map((element) => {
|
||||
const { x, y, width, height } = element.bounds
|
||||
return `${element.sourceId} (x=${Math.round(x)}, y=${Math.round(y)}, right=${Math.round(x + width)}, bottom=${Math.round(y + height)})`
|
||||
})
|
||||
.join('; ')}.`
|
||||
)
|
||||
}
|
||||
const blockingWarnings = compiled.warnings.filter((warning) =>
|
||||
/Unsupported CSS|exactly 1920/.test(warning)
|
||||
)
|
||||
if (blockingWarnings.length > 0) throw new Error(blockingWarnings.join(' '))
|
||||
if (compiled.elements.length === 0)
|
||||
throw new Error('PPT slide contains no supported visual elements.')
|
||||
return compiled
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Resvg } from '@resvg/resvg-js'
|
||||
import {
|
||||
addBlankSlide,
|
||||
addSlideChart,
|
||||
@@ -84,7 +86,12 @@ import {
|
||||
import type { AiFileChangeRecord, JsonObject } from '../../../../../../shared/types'
|
||||
import { fileExists } from '../docx/review/utils'
|
||||
import { resolveWorkspacePath } from '../terminal/commandPolicy'
|
||||
import { compilePptSlideHtml } from './htmlCompiler'
|
||||
import {
|
||||
compilePptSlideHtml,
|
||||
normalizePptColor,
|
||||
PPT_CANVAS_HEIGHT,
|
||||
PPT_CANVAS_WIDTH
|
||||
} from './htmlCompiler'
|
||||
import { renderLatexToSvg } from './formula'
|
||||
import {
|
||||
pptEditSchema,
|
||||
@@ -109,8 +116,8 @@ import type {
|
||||
PptToolRuntime
|
||||
} from './types'
|
||||
|
||||
const DEFAULT_WIDTH = 1920
|
||||
const DEFAULT_HEIGHT = 1080
|
||||
const DEFAULT_WIDTH = PPT_CANVAS_WIDTH
|
||||
const DEFAULT_HEIGHT = PPT_CANVAS_HEIGHT
|
||||
|
||||
const hashBytes = (bytes: Uint8Array | string): string =>
|
||||
createHash('sha256').update(bytes).digest('hex')
|
||||
@@ -158,10 +165,7 @@ const replaceFileWithBytes = async (targetPath: string, bytes: Uint8Array): Prom
|
||||
}
|
||||
|
||||
const normalizeHex = (value: string | undefined, fallback = '#FFFFFF'): string => {
|
||||
const raw = String(value || '').trim()
|
||||
if (/^#[0-9a-f]{6}$/i.test(raw)) return raw.toUpperCase()
|
||||
if (/^[0-9a-f]{6}$/i.test(raw)) return `#${raw.toUpperCase()}`
|
||||
return fallback
|
||||
return normalizePptColor(value, fallback) || fallback
|
||||
}
|
||||
|
||||
const getSlideDimensions = (presentation: PresentationData): { width: number; height: number } => {
|
||||
@@ -267,8 +271,16 @@ const resolveAssetBytes = async (input: {
|
||||
if (/^https?:\/\//i.test(input.rawPath)) {
|
||||
throw new Error('PPT 页面资源只支持工作区内已存在的本地文件。')
|
||||
}
|
||||
let rawPath = input.rawPath
|
||||
if (/^file:/i.test(rawPath)) {
|
||||
try {
|
||||
rawPath = fileURLToPath(rawPath)
|
||||
} catch {
|
||||
throw new Error(`无效的本地 file URL: ${input.rawPath}`)
|
||||
}
|
||||
}
|
||||
const resolved = resolveWorkspacePath({
|
||||
rawPath: input.rawPath,
|
||||
rawPath,
|
||||
cwd: path.dirname(input.sourcePath),
|
||||
rootPath: input.workspaceRootPath
|
||||
})
|
||||
@@ -295,10 +307,10 @@ const applyShapeStyle = (
|
||||
) => {
|
||||
if (style.fill) setShapeFill(shape, normalizeHex(style.fill))
|
||||
else if (textShape) setShapeNoFill(shape)
|
||||
if (style.stroke) {
|
||||
if (style.stroke && Number(style.strokeWidth || 0) > 0) {
|
||||
setShapeStroke(shape, {
|
||||
color: normalizeHex(style.stroke, '#000000'),
|
||||
widthEmu: Math.round((style.strokeWidth || 1) * 12_700)
|
||||
widthEmu: Math.round(Number(style.strokeWidth) * 12_700)
|
||||
})
|
||||
} else {
|
||||
setShapeNoStroke(shape)
|
||||
@@ -308,10 +320,16 @@ const applyShapeStyle = (
|
||||
setShapeShadow(shape, { opacity: 0.22, blurEmu: 50_800, offsetEmu: 25_400 })
|
||||
}
|
||||
if (textShape) {
|
||||
const usesDefaultFont =
|
||||
!style.fontFamily ||
|
||||
/^(arial|helvetica|sans-serif|times new roman|segoe ui|calibri|aptos|inter|roboto|system-ui)$/i.test(
|
||||
style.fontFamily
|
||||
)
|
||||
const textFont = usesDefaultFont ? 'Microsoft YaHei' : style.fontFamily || 'Microsoft YaHei'
|
||||
setShapeTextFormat(shape, {
|
||||
font: style.fontFamily || 'Arial',
|
||||
fontEastAsian: style.fontFamily || 'Microsoft YaHei',
|
||||
size: Math.max(8, (style.fontSize || 32) * 0.75),
|
||||
font: textFont,
|
||||
fontEastAsian: textFont,
|
||||
size: Math.max(8, (style.fontSize || 32) * 0.5),
|
||||
color: normalizeHex(style.color, '#222222'),
|
||||
bold: (style.fontWeight || 400) >= 600,
|
||||
italic: style.italic,
|
||||
@@ -327,6 +345,37 @@ const applyShapeStyle = (
|
||||
}
|
||||
}
|
||||
|
||||
const addCompiledBorders = (input: {
|
||||
presentation: PresentationData
|
||||
slide: SlideData
|
||||
compiled: PptCompiledSlide
|
||||
element: PptCompiledElement
|
||||
}): void => {
|
||||
const borders = input.element.style.borders
|
||||
if (!borders) return
|
||||
const slideSize = getSlideDimensions(input.presentation)
|
||||
const point = (x: number, y: number) => ({
|
||||
x: emu(Math.round((x / input.compiled.width) * slideSize.width)),
|
||||
y: emu(Math.round((y / input.compiled.height) * slideSize.height))
|
||||
})
|
||||
const { x, y, width, height } = input.element.bounds
|
||||
const sides = [
|
||||
{ border: borders.top, from: point(x, y), to: point(x + width, y) },
|
||||
{ border: borders.right, from: point(x + width, y), to: point(x + width, y + height) },
|
||||
{ border: borders.bottom, from: point(x + width, y + height), to: point(x, y + height) },
|
||||
{ border: borders.left, from: point(x, y + height), to: point(x, y) }
|
||||
]
|
||||
for (const side of sides) {
|
||||
if (!side.border || side.border.width <= 0) continue
|
||||
addSlideLine(input.slide, {
|
||||
from: side.from,
|
||||
to: side.to,
|
||||
color: normalizeHex(side.border.color, '#000000'),
|
||||
widthEmu: Math.round(side.border.width * 12_700)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addCompiledElement = async (input: {
|
||||
document: PptRuntimeDocument
|
||||
slide: SlideData
|
||||
@@ -336,21 +385,69 @@ const addCompiledElement = async (input: {
|
||||
}) => {
|
||||
const bounds = boundsToEmu(
|
||||
input.document.presentation,
|
||||
input.element.bounds,
|
||||
input.element.type === 'text'
|
||||
? {
|
||||
...input.element.bounds,
|
||||
width: Math.min(
|
||||
input.compiled.width - input.element.bounds.x,
|
||||
input.element.bounds.width +
|
||||
Math.max(24, Math.min(72, (input.element.style.fontSize || 32) * 1.5))
|
||||
),
|
||||
height: Math.min(
|
||||
input.compiled.height - input.element.bounds.y,
|
||||
input.element.bounds.height + Math.max(16, (input.element.style.fontSize || 32) * 0.8)
|
||||
)
|
||||
}
|
||||
: input.element.bounds,
|
||||
input.compiled.width,
|
||||
input.compiled.height
|
||||
)
|
||||
if (input.element.type === 'shape') {
|
||||
const shape = addSlideShape(input.slide, {
|
||||
preset: input.element.style.radius ? 'roundRect' : 'rect',
|
||||
text: '',
|
||||
...bounds
|
||||
})
|
||||
applyShapeStyle(shape, input.element.style)
|
||||
addCompiledBorders({
|
||||
presentation: input.document.presentation,
|
||||
slide: input.slide,
|
||||
compiled: input.compiled,
|
||||
element: input.element
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.element.type === 'text') {
|
||||
const shape = addSlideTextBox(input.slide, { ...bounds, text: input.element.text || '' })
|
||||
applyShapeStyle(shape, input.element.style, true)
|
||||
addCompiledBorders({
|
||||
presentation: input.document.presentation,
|
||||
slide: input.slide,
|
||||
compiled: input.compiled,
|
||||
element: input.element
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.element.type === 'line') {
|
||||
const lineSpec = input.element.line || {
|
||||
from: { x: 0, y: 0 },
|
||||
to: { x: input.element.bounds.width, y: input.element.bounds.height }
|
||||
}
|
||||
const slideSize = getSlideDimensions(input.document.presentation)
|
||||
const point = (offset: { x: number; y: number }) => ({
|
||||
x: emu(
|
||||
Math.round(((input.element.bounds.x + offset.x) / input.compiled.width) * slideSize.width)
|
||||
),
|
||||
y: emu(
|
||||
Math.round(((input.element.bounds.y + offset.y) / input.compiled.height) * slideSize.height)
|
||||
)
|
||||
})
|
||||
addSlideLine(input.slide, {
|
||||
from: point(lineSpec.from),
|
||||
to: point(lineSpec.to),
|
||||
color: normalizeHex(input.element.style.stroke || input.element.style.color, '#222222'),
|
||||
widthEmu: Math.round(Math.max(1, input.element.style.strokeWidth || 2) * 12_700)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.element.type === 'table') {
|
||||
@@ -407,7 +504,19 @@ const addCompiledElement = async (input: {
|
||||
extension
|
||||
})
|
||||
}
|
||||
const image = addSlideImage(input.slide, bytes, { ...bounds, format, fit: 'contain' })
|
||||
const image = addSlideImage(input.slide, bytes, {
|
||||
...bounds,
|
||||
format,
|
||||
fit:
|
||||
input.element.type === 'image'
|
||||
? input.element.imageFit === 'contain'
|
||||
? 'contain'
|
||||
: 'fill'
|
||||
: 'contain'
|
||||
})
|
||||
if (input.element.type === 'image' && input.element.imageFit === 'cover') {
|
||||
applyCoverCrop(image, bytes, input.element.bounds)
|
||||
}
|
||||
if (input.element.type === 'formula') {
|
||||
setShapeDescription(image, `ppt-latex:${input.element.latex || ''}`)
|
||||
}
|
||||
@@ -578,13 +687,84 @@ const renderPresentationSlides = async (input: {
|
||||
prefix: string
|
||||
}): Promise<Map<string, PptDiffFrame & { hash: string }>> => {
|
||||
const { renderSlideToImage } = await import('pptx-kit-preview/node')
|
||||
const renderWithSystemFonts = async (
|
||||
presentation: PresentationData,
|
||||
slide: SlideData,
|
||||
width: number
|
||||
): Promise<Uint8Array | null> => {
|
||||
try {
|
||||
const { defaultMeasurer, renderSlideToSvg } = await import('pptx-kit-preview/node')
|
||||
const svg = renderSlideToSvg(presentation, slide, {
|
||||
measureText: defaultMeasurer,
|
||||
textLayout: 'svg'
|
||||
})
|
||||
const rendered = new Resvg(svg, {
|
||||
fitTo: { mode: 'width', value: width },
|
||||
font: {
|
||||
loadSystemFonts: true,
|
||||
defaultFontFamily: 'Microsoft YaHei',
|
||||
sansSerifFamily: 'Microsoft YaHei',
|
||||
serifFamily: 'Microsoft YaHei',
|
||||
monospaceFamily: 'Consolas'
|
||||
}
|
||||
}).render()
|
||||
return new Uint8Array(rendered.asPng())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const renderWithElectron = async (
|
||||
presentation: PresentationData,
|
||||
slide: SlideData,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<Uint8Array | null> => {
|
||||
if (!process.versions.electron) return null
|
||||
try {
|
||||
const electron = await import('electron')
|
||||
if (!electron.BrowserWindow || !electron.app?.isReady()) return null
|
||||
const { renderSlideToSvg } = await import('pptx-kit-preview')
|
||||
const svg = renderSlideToSvg(presentation, slide)
|
||||
const window = new electron.BrowserWindow({
|
||||
show: false,
|
||||
width,
|
||||
height,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
partition: `ppt-preview-${Date.now()}-${Math.random()}`
|
||||
}
|
||||
})
|
||||
try {
|
||||
const document = `<!doctype html><html><head><style>html,body{margin:0;padding:0;width:${width}px;height:${height}px;overflow:hidden;background:#fff}svg{display:block;width:${width}px;height:${height}px}text{font-family:"Microsoft YaHei","Noto Sans SC","PingFang SC",Arial,sans-serif}</style></head><body>${svg}</body></html>`
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(document)}`)
|
||||
await window.webContents.executeJavaScript('document.fonts.ready')
|
||||
const image = await window.webContents.capturePage({ x: 0, y: 0, width, height })
|
||||
return new Uint8Array(image.toPNG())
|
||||
} finally {
|
||||
window.destroy()
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
await fs.mkdir(input.previewDir, { recursive: true })
|
||||
const slides = getSlides(input.presentation)
|
||||
const output = new Map<string, PptDiffFrame & { hash: string }>()
|
||||
for (const slideId of input.slideIds) {
|
||||
const index = slides.findIndex((slide) => slideIdOf(slide) === slideId)
|
||||
if (index < 0) continue
|
||||
const png = renderSlideToImage(input.presentation, slides[index]!, { width: 1280 })
|
||||
const width = 1920
|
||||
const height = Math.round(
|
||||
(width * getSlideDimensions(input.presentation).height) /
|
||||
getSlideDimensions(input.presentation).width
|
||||
)
|
||||
const png =
|
||||
(await renderWithElectron(input.presentation, slides[index]!, width, height)) ||
|
||||
(await renderWithSystemFonts(input.presentation, slides[index]!, width)) ||
|
||||
renderSlideToImage(input.presentation, slides[index]!, { width })
|
||||
const hash = hashBytes(png)
|
||||
const image = path.join(input.previewDir, `${input.prefix}-${hash.slice(0, 20)}.png`)
|
||||
await fs.writeFile(image, png)
|
||||
@@ -593,22 +773,31 @@ const renderPresentationSlides = async (input: {
|
||||
return output
|
||||
}
|
||||
|
||||
const buildManifest = (input: {
|
||||
export const buildPptDiffManifest = (input: {
|
||||
before: Map<string, PptDiffFrame & { hash: string }>
|
||||
after: Map<string, PptDiffFrame & { hash: string }>
|
||||
operation: 'created' | 'updated'
|
||||
forceChangedSlideIds?: ReadonlySet<string>
|
||||
}): PptDiffManifest => {
|
||||
const afterEntries: PptDiffSlide[] = [...input.after.entries()]
|
||||
.sort((a, b) => a[1].index - b[1].index)
|
||||
.map(([slideId, after]) => {
|
||||
const before = input.before.get(slideId)
|
||||
const changes: PptDiffSlide['changes'] = []
|
||||
if (!before) changes.push('added')
|
||||
else {
|
||||
if (
|
||||
before.hash !== after.hash ||
|
||||
(before.index === after.index && Boolean(input.forceChangedSlideIds?.has(slideId)))
|
||||
) {
|
||||
changes.push('modified')
|
||||
}
|
||||
if (before.index !== after.index) changes.push('moved')
|
||||
}
|
||||
return {
|
||||
slideId,
|
||||
changed:
|
||||
!before ||
|
||||
before.hash !== after.hash ||
|
||||
before.index !== after.index ||
|
||||
Boolean(input.forceChangedSlideIds?.has(slideId)),
|
||||
changed: changes.length > 0,
|
||||
changes,
|
||||
before: before ? { index: before.index, image: before.image } : undefined,
|
||||
after: { index: after.index, image: after.image }
|
||||
}
|
||||
@@ -620,11 +809,12 @@ const buildManifest = (input: {
|
||||
afterEntries.splice(Math.min(Math.max(0, before.index - 1), afterEntries.length), 0, {
|
||||
slideId,
|
||||
changed: true,
|
||||
changes: ['deleted'],
|
||||
before: { index: before.index, image: before.image },
|
||||
after: undefined
|
||||
})
|
||||
}
|
||||
return { version: 1, slides: afterEntries }
|
||||
return { version: 2, operation: input.operation, slides: afterEntries }
|
||||
}
|
||||
|
||||
const readImageDimensions = (bytes: Uint8Array): { width: number; height: number } | null => {
|
||||
@@ -650,7 +840,7 @@ const readImageDimensions = (bytes: Uint8Array): { width: number; height: number
|
||||
return null
|
||||
}
|
||||
|
||||
const applyCoverCrop = (shape: SlideShapeData, bytes: Uint8Array, bounds: PptBounds): void => {
|
||||
function applyCoverCrop(shape: SlideShapeData, bytes: Uint8Array, bounds: PptBounds): void {
|
||||
const dimensions = readImageDimensions(bytes)
|
||||
if (!dimensions) return
|
||||
const imageAspect = dimensions.width / dimensions.height
|
||||
@@ -1126,7 +1316,9 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
|
||||
const create = createActions.length === 1
|
||||
const sourcePath = resolvePptPath(args.sourcePath)
|
||||
if (create && (documents.has(sourcePath) || (await fileExists(sourcePath)))) {
|
||||
throw new Error(`目标 pptx 已存在,不能 create_presentation: ${args.sourcePath}`)
|
||||
throw new Error(
|
||||
`目标 pptx 已存在,不能再次使用 create_presentation;保留现有文件并使用 insert_slide 追加页面: ${args.sourcePath}`
|
||||
)
|
||||
}
|
||||
const document = await loadDocument(args.sourcePath, create)
|
||||
const presentationSnapshot = await savePresentation(document.presentation)
|
||||
@@ -1308,6 +1500,7 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
|
||||
const afterBytes = await savePresentation(document.presentation)
|
||||
const afterPresentation = await loadPresentation(afterBytes)
|
||||
const dirs = await ensureAssetDirectory(workspaceRootPath, document.changeId)
|
||||
const beforeExists = Boolean(document.beforeBytes)
|
||||
const beforePresentation = document.beforeBytes
|
||||
? await loadPresentation(document.beforeBytes)
|
||||
: null
|
||||
@@ -1327,14 +1520,14 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
|
||||
previewDir: dirs.after,
|
||||
prefix: 'after'
|
||||
})
|
||||
const manifest = buildManifest({
|
||||
const manifest = buildPptDiffManifest({
|
||||
before: beforeFrames,
|
||||
after: afterFrames,
|
||||
operation: beforeExists ? 'updated' : 'created',
|
||||
forceChangedSlideIds: document.changedSlideIds
|
||||
})
|
||||
const changedSlides = manifest.slides.filter((slide) => slide.changed)
|
||||
await fs.writeFile(dirs.manifest, JSON.stringify(manifest, null, 2), 'utf8')
|
||||
const beforeExists = Boolean(document.beforeBytes)
|
||||
let beforeSnapshotPath: string | undefined
|
||||
let beforeChecksum: string | undefined
|
||||
if (beforeExists && document.beforeBytes) {
|
||||
|
||||
@@ -5,6 +5,17 @@ const sourcePathSchema = z.string().trim().min(1)
|
||||
const slideIdSchema = z.string().trim().min(1)
|
||||
const slideIndexSchema = z.number().int().min(1)
|
||||
|
||||
export const PPT_SLIDE_HTML_GUIDANCE =
|
||||
'每次提交一张可直接交付、内容完整的页面;禁止先创建空白或占位页再用 edit_elements 逐项拼装。页面 HTML 必须且只能包含一个 data-ppt-slide 根节点,根节点固定使用 position:relative、width:1920px、height:1080px、overflow:hidden。仅使用内联 CSS;未显式指定时所有带样式元素按 box-sizing:border-box 布局。允许 absolute/flex/grid,但所有真实 PPT 元素应使用 data-ppt-element 标注 text、shape、image、svg、table、chart、formula 或 line,纯布局容器不要标注。支持颜色、背景、字号、字体、对齐、圆角、边框、阴影、透明度和旋转;禁止伪元素、动画、filter、mask、mix-blend-mode 和滚动容器。四周保留至少 60px 安全边距,元素的 right 不得超过 1920、bottom 不得超过 1080;页面标题至少 70px、正文至少 32px、任何可见文本不得低于 24px,单行标题应预留宽度余量。避免文字过密、大片无意义留白、过小图片、单一深色满版和重复卡片堆叠。每页只表达一个主要结论,只使用任务中有依据的事实与数据。图片只能引用工作区本地文件。'
|
||||
|
||||
const PPT_SLIDE_HTML_COMPATIBILITY_GUIDANCE =
|
||||
' 仅支持纯色 background/background-color;禁止 CSS 渐变和 background-image,请使用工作区本地 <img> 元素。主内容必须横向利用画布,左、中、右区域均应有有效信息或视觉元素,禁止把一半以上页面留空;页面标题必须保持单行。'
|
||||
|
||||
const pptSlideHtmlSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(`${PPT_SLIDE_HTML_GUIDANCE}${PPT_SLIDE_HTML_COMPATIBILITY_GUIDANCE}`)
|
||||
|
||||
export const pptBoundsSchema = z
|
||||
.object({
|
||||
x: z.number().min(0),
|
||||
@@ -269,14 +280,22 @@ const pptElementOperationSchema = z.discriminatedUnion('type', [
|
||||
])
|
||||
|
||||
export const pptEditActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('create_presentation'), html: z.string().min(1) }).strict(),
|
||||
z
|
||||
.object({ type: z.literal('create_presentation'), html: pptSlideHtmlSchema })
|
||||
.strict()
|
||||
.describe(
|
||||
'仅当 sourcePath 尚不存在时使用一次,用完整 HTML 创建第一页。创建成功后不要删除或重建文件,后续页面必须使用 insert_slide。'
|
||||
),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('insert_slide'),
|
||||
beforeSlideId: slideIdSchema.optional(),
|
||||
html: z.string().min(1)
|
||||
html: pptSlideHtmlSchema
|
||||
})
|
||||
.strict(),
|
||||
.strict()
|
||||
.describe(
|
||||
'向现有 PPTX 插入一张完整 HTML 页面;省略 beforeSlideId 时追加到末尾。第二页及之后都使用此动作。'
|
||||
),
|
||||
z.object({ type: z.literal('delete_slides'), slideIds: z.array(slideIdSchema).min(1) }).strict(),
|
||||
z
|
||||
.object({
|
||||
|
||||
@@ -25,19 +25,27 @@ export type PptElementStyle = {
|
||||
underline?: boolean
|
||||
textAlign?: 'left' | 'center' | 'right'
|
||||
verticalAlign?: 'top' | 'middle' | 'bottom'
|
||||
borders?: {
|
||||
top?: { color: string; width: number }
|
||||
right?: { color: string; width: number }
|
||||
bottom?: { color: string; width: number }
|
||||
left?: { color: string; width: number }
|
||||
}
|
||||
}
|
||||
|
||||
export type PptCompiledElement = {
|
||||
sourceId: string
|
||||
type: 'text' | 'shape' | 'image' | 'svg' | 'table' | 'chart' | 'formula'
|
||||
type: 'text' | 'shape' | 'image' | 'svg' | 'table' | 'chart' | 'formula' | 'line'
|
||||
bounds: PptBounds
|
||||
style: PptElementStyle
|
||||
text?: string
|
||||
src?: string
|
||||
imageFit?: 'fill' | 'contain' | 'cover'
|
||||
svg?: string
|
||||
latex?: string
|
||||
rows?: string[][]
|
||||
chartType?: 'bar' | 'column' | 'line' | 'pie' | 'doughnut' | 'area'
|
||||
line?: { from: { x: number; y: number }; to: { x: number; y: number } }
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
@@ -96,12 +104,14 @@ export type PptDiffFrame = {
|
||||
export type PptDiffSlide = {
|
||||
slideId: string
|
||||
changed: boolean
|
||||
changes: Array<'added' | 'deleted' | 'modified' | 'moved'>
|
||||
before?: PptDiffFrame
|
||||
after?: PptDiffFrame
|
||||
}
|
||||
|
||||
export type PptDiffManifest = {
|
||||
version: 1
|
||||
version: 2
|
||||
operation: 'created' | 'updated'
|
||||
slides: PptDiffSlide[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Empty, Spin } from 'antd'
|
||||
import { HiArrowsUpDown, HiFunnel, HiMinus, HiPlus, HiSquares2X2 } from 'react-icons/hi2'
|
||||
import { HiMinus, HiPlus } from 'react-icons/hi2'
|
||||
import type { AiFileChangeRecord } from '../../../../shared/types'
|
||||
import { workbenchSelectors } from '../../testing/selectors'
|
||||
|
||||
@@ -8,10 +8,12 @@ type DiffFrame = { index: number; image: string }
|
||||
type DiffSlide = {
|
||||
slideId: string
|
||||
changed: boolean
|
||||
changes: Array<'added' | 'deleted' | 'modified' | 'moved'>
|
||||
before?: DiffFrame
|
||||
after?: DiffFrame
|
||||
}
|
||||
type DiffManifest = { version: 1; slides: DiffSlide[] }
|
||||
type DiffManifest = { version: 2; operation: 'created' | 'updated'; slides: DiffSlide[] }
|
||||
type Viewport = { scale: number; x: number; y: number }
|
||||
|
||||
const loadLocalUrl = async (filePath: string): Promise<string> => {
|
||||
const bytes = await window.api.readFile(filePath)
|
||||
@@ -20,24 +22,72 @@ const loadLocalUrl = async (filePath: string): Promise<string> => {
|
||||
return URL.createObjectURL(new Blob([buffer], { type: 'image/png' }))
|
||||
}
|
||||
|
||||
const SlideSurface: React.FC<{ imageUrl?: string; side: 'before' | 'after' }> = ({
|
||||
const changeLabel = (changes: DiffSlide['changes']): string => {
|
||||
if (changes.includes('deleted')) return '已删除'
|
||||
if (changes.includes('added')) return '新增页'
|
||||
if (changes.includes('moved') && changes.includes('modified')) return '内容已修改,位置已移动'
|
||||
if (changes.includes('moved')) return '位置已移动'
|
||||
if (changes.includes('modified')) return '内容已修改'
|
||||
return ''
|
||||
}
|
||||
|
||||
const SlideSurface: React.FC<{
|
||||
imageUrl?: string
|
||||
side: 'before' | 'after'
|
||||
showSideLabel?: boolean
|
||||
viewport: Viewport
|
||||
onWheel: (event: React.WheelEvent<HTMLDivElement>) => void
|
||||
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
|
||||
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void
|
||||
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void
|
||||
onDoubleClick: () => void
|
||||
}> = ({
|
||||
imageUrl,
|
||||
side
|
||||
side,
|
||||
showSideLabel = true,
|
||||
viewport,
|
||||
onWheel,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onDoubleClick
|
||||
}) => (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-[#F4F4F2] p-3"
|
||||
className={`relative flex min-h-0 min-w-0 flex-1 select-none items-center justify-center overflow-hidden bg-[#F4F4F2] p-3 ${viewport.scale > 1 ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||
data-testid={
|
||||
side === 'before' ? workbenchSelectors.pptDiffBefore : workbenchSelectors.pptDiffAfter
|
||||
}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{showSideLabel ? (
|
||||
<span className="pointer-events-none absolute left-3 top-2 z-10 rounded-[3px] bg-white/90 px-1.5 py-0.5 text-[10px] font-medium text-[#787774] shadow-sm">
|
||||
{side === 'before' ? '修改前' : '修改后'}
|
||||
</span>
|
||||
) : null}
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="max-h-full max-w-full border border-[#DEDCD8] bg-white object-contain shadow-sm"
|
||||
draggable={false}
|
||||
/>
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
||||
transformOrigin: 'center center',
|
||||
willChange: 'transform'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="block max-h-full max-w-full border border-[#DEDCD8] bg-white object-contain shadow-sm"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video w-full max-w-[720px] border border-dashed border-[#D8D6D1] bg-white" />
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="aspect-video w-full max-w-[960px] border border-dashed border-[#D8D6D1] bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -49,7 +99,14 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
const [changedOnly, setChangedOnly] = useState(true)
|
||||
const [activeSlideId, setActiveSlideId] = useState<string | null>(null)
|
||||
const [imageUrls, setImageUrls] = useState<Record<string, string>>({})
|
||||
const wheelLockRef = useRef(false)
|
||||
const [viewport, setViewport] = useState<Viewport>({ scale: 1, x: 0, y: 0 })
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
x: number
|
||||
y: number
|
||||
originX: number
|
||||
originY: number
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -58,7 +115,7 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
setManifest(null)
|
||||
if (!record.previewPath) {
|
||||
setLoading(false)
|
||||
setError('缺少 PPT 差异预览')
|
||||
setError('缺少 PPT 差异预览。')
|
||||
return
|
||||
}
|
||||
void window.api
|
||||
@@ -66,9 +123,14 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
.then((bytes) => JSON.parse(new TextDecoder().decode(bytes)) as DiffManifest)
|
||||
.then((next) => {
|
||||
if (cancelled) return
|
||||
if (next.version !== 2 || !Array.isArray(next.slides)) {
|
||||
throw new Error('该 PPT 预览版本已过期,请重新生成修改记录。')
|
||||
}
|
||||
setManifest(next)
|
||||
setChangedOnly(next.operation === 'updated')
|
||||
const first = next.slides.find((slide) => slide.changed) || next.slides[0]
|
||||
setActiveSlideId(first?.slideId || null)
|
||||
setViewport({ scale: 1, x: 0, y: 0 })
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
|
||||
@@ -88,9 +150,9 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
...new Set(
|
||||
manifest.slides
|
||||
.flatMap((slide) => [slide.before?.image, slide.after?.image])
|
||||
.filter(Boolean)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
] as string[]
|
||||
]
|
||||
const created: string[] = []
|
||||
void Promise.all(
|
||||
paths.map(async (filePath) => {
|
||||
@@ -98,13 +160,13 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
created.push(url)
|
||||
return [filePath, url] as const
|
||||
})
|
||||
).then((entries) => {
|
||||
if (cancelled) {
|
||||
created.forEach((url) => URL.revokeObjectURL(url))
|
||||
return
|
||||
}
|
||||
setImageUrls(Object.fromEntries(entries))
|
||||
})
|
||||
)
|
||||
.then((entries) => {
|
||||
if (!cancelled) setImageUrls(Object.fromEntries(entries))
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
created.forEach((url) => URL.revokeObjectURL(url))
|
||||
@@ -112,10 +174,13 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
}
|
||||
}, [manifest])
|
||||
|
||||
const visibleSlides = useMemo(
|
||||
() => manifest?.slides.filter((slide) => !changedOnly || slide.changed) || [],
|
||||
[changedOnly, manifest]
|
||||
)
|
||||
const hasUnchangedSlides = Boolean(manifest?.slides.some((slide) => !slide.changed))
|
||||
const showFilter = manifest?.operation === 'updated' && hasUnchangedSlides
|
||||
const visibleSlides = useMemo(() => {
|
||||
if (!manifest) return []
|
||||
if (manifest.operation === 'created' || !showFilter || !changedOnly) return manifest.slides
|
||||
return manifest.slides.filter((slide) => slide.changed)
|
||||
}, [changedOnly, manifest, showFilter])
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
visibleSlides.findIndex((slide) => slide.slideId === activeSlideId)
|
||||
@@ -123,16 +188,72 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
const activeSlide = visibleSlides[activeIndex]
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleSlides.length === 0) return
|
||||
if (!visibleSlides.length) return
|
||||
if (!visibleSlides.some((slide) => slide.slideId === activeSlideId)) {
|
||||
setActiveSlideId(visibleSlides[0]!.slideId)
|
||||
setViewport({ scale: 1, x: 0, y: 0 })
|
||||
}
|
||||
}, [activeSlideId, visibleSlides])
|
||||
|
||||
const selectOffset = (offset: number): void => {
|
||||
if (visibleSlides.length === 0) return
|
||||
const nextIndex = Math.max(0, Math.min(visibleSlides.length - 1, activeIndex + offset))
|
||||
setActiveSlideId(visibleSlides[nextIndex]!.slideId)
|
||||
const changeViewport = (event: React.WheelEvent<HTMLDivElement>): void => {
|
||||
event.preventDefault()
|
||||
const direction = event.deltaY < 0 ? 1 : -1
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const pointerX = event.clientX - rect.left - rect.width / 2
|
||||
const pointerY = event.clientY - rect.top - rect.height / 2
|
||||
setViewport((current) => {
|
||||
const nextScale = Math.min(4, Math.max(0.25, current.scale * (direction > 0 ? 1.12 : 0.89)))
|
||||
if (nextScale === current.scale) return current
|
||||
if (nextScale <= 1) return { scale: nextScale, x: 0, y: 0 }
|
||||
const ratio = nextScale / current.scale
|
||||
const rawX = current.x + (pointerX - current.x) * (1 - ratio)
|
||||
const rawY = current.y + (pointerY - current.y) * (1 - ratio)
|
||||
const maxX = rect.width > 0 ? (rect.width * (nextScale - 1)) / 2 : Number.POSITIVE_INFINITY
|
||||
const maxY = rect.height > 0 ? (rect.height * (nextScale - 1)) / 2 : Number.POSITIVE_INFINITY
|
||||
return {
|
||||
scale: nextScale,
|
||||
x: Math.max(-maxX, Math.min(maxX, rawX)),
|
||||
y: Math.max(-maxY, Math.min(maxY, rawY))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const startDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (viewport.scale <= 1) return
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
originX: viewport.x,
|
||||
originY: viewport.y
|
||||
}
|
||||
}
|
||||
|
||||
const moveDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
setViewport((current) => {
|
||||
const maxX =
|
||||
rect.width > 0 ? (rect.width * (current.scale - 1)) / 2 : Number.POSITIVE_INFINITY
|
||||
const maxY =
|
||||
rect.height > 0 ? (rect.height * (current.scale - 1)) / 2 : Number.POSITIVE_INFINITY
|
||||
return {
|
||||
...current,
|
||||
x: Math.max(-maxX, Math.min(maxX, drag.originX + event.clientX - drag.x)),
|
||||
y: Math.max(-maxY, Math.min(maxY, drag.originY + event.clientY - drag.y))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const endDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null
|
||||
}
|
||||
|
||||
const selectSlide = (slideId: string): void => {
|
||||
setActiveSlideId(slideId)
|
||||
setViewport({ scale: 1, x: 0, y: 0 })
|
||||
}
|
||||
|
||||
if (loading)
|
||||
@@ -149,48 +270,120 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
)
|
||||
}
|
||||
|
||||
const beforeUrl = activeSlide?.before ? imageUrls[activeSlide.before.image] : undefined
|
||||
const afterUrl = activeSlide?.after ? imageUrls[activeSlide.after.image] : undefined
|
||||
const resetViewport = (): void => setViewport({ scale: 1, x: 0, y: 0 })
|
||||
const changeZoom = (factor: number): void => {
|
||||
setViewport((current) => {
|
||||
const scale = Math.min(4, Math.max(0.25, current.scale * factor))
|
||||
return scale <= 1 ? { scale, x: 0, y: 0 } : { ...current, scale }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 bg-white"
|
||||
data-testid={workbenchSelectors.pptDiffViewer}
|
||||
onWheel={(event) => {
|
||||
if (wheelLockRef.current || Math.abs(event.deltaY) < 12) return
|
||||
wheelLockRef.current = true
|
||||
selectOffset(event.deltaY > 0 ? 1 : -1)
|
||||
window.setTimeout(() => {
|
||||
wheelLockRef.current = false
|
||||
}, 180)
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
event.preventDefault()
|
||||
const offset = event.key === 'ArrowLeft' ? -1 : 1
|
||||
const next =
|
||||
visibleSlides[Math.max(0, Math.min(visibleSlides.length - 1, activeIndex + offset))]
|
||||
if (next) selectSlide(next.slideId)
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 divide-x divide-[#E7E5E1]">
|
||||
<SlideSurface
|
||||
imageUrl={activeSlide?.before ? imageUrls[activeSlide.before.image] : undefined}
|
||||
side="before"
|
||||
/>
|
||||
<SlideSurface
|
||||
imageUrl={activeSlide?.after ? imageUrls[activeSlide.after.image] : undefined}
|
||||
side="after"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-[116px] shrink-0 flex-col border-l border-[#E7E5E1] bg-[#FAFAF9]">
|
||||
<div className="flex h-10 shrink-0 items-center justify-center gap-1 border-b border-[#E7E5E1]">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-9 shrink-0 items-center justify-end gap-1 border-b border-[#E7E5E1] bg-white px-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="只看修改页"
|
||||
className={`rounded p-1.5 ${changedOnly ? 'bg-[#E9E7E3] text-[#37352F]' : 'text-[#9B9A97]'}`}
|
||||
onClick={() => setChangedOnly(true)}
|
||||
title="缩小"
|
||||
aria-label="缩小"
|
||||
className="rounded p-1 text-[#787774] hover:bg-[#F4F4F2]"
|
||||
onClick={() => changeZoom(0.8)}
|
||||
>
|
||||
<HiFunnel className="h-4 w-4" />
|
||||
<HiMinus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看全部页"
|
||||
className={`rounded p-1.5 ${!changedOnly ? 'bg-[#E9E7E3] text-[#37352F]' : 'text-[#9B9A97]'}`}
|
||||
onClick={() => setChangedOnly(false)}
|
||||
title="适应窗口"
|
||||
aria-label="适应窗口"
|
||||
className="min-w-[48px] rounded px-1.5 py-0.5 text-[11px] text-[#787774] hover:bg-[#F4F4F2]"
|
||||
onClick={resetViewport}
|
||||
>
|
||||
<HiSquares2X2 className="h-4 w-4" />
|
||||
{Math.round(viewport.scale * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="放大"
|
||||
aria-label="放大"
|
||||
className="rounded p-1 text-[#787774] hover:bg-[#F4F4F2]"
|
||||
onClick={() => changeZoom(1.25)}
|
||||
>
|
||||
<HiPlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{manifest.operation === 'updated' ? (
|
||||
<div className="flex min-w-0 flex-1 divide-x divide-[#E7E5E1]">
|
||||
<SlideSurface
|
||||
imageUrl={beforeUrl}
|
||||
side="before"
|
||||
viewport={viewport}
|
||||
onWheel={changeViewport}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onDoubleClick={resetViewport}
|
||||
/>
|
||||
<SlideSurface
|
||||
imageUrl={afterUrl}
|
||||
side="after"
|
||||
viewport={viewport}
|
||||
onWheel={changeViewport}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onDoubleClick={resetViewport}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<SlideSurface
|
||||
imageUrl={afterUrl}
|
||||
side="after"
|
||||
showSideLabel={false}
|
||||
viewport={viewport}
|
||||
onWheel={changeViewport}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onDoubleClick={resetViewport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-[136px] shrink-0 flex-col border-l border-[#E7E5E1] bg-[#FAFAF9]">
|
||||
{showFilter ? (
|
||||
<div className="flex h-9 shrink-0 items-center justify-center gap-0.5 border-b border-[#E7E5E1] p-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="只看变更页"
|
||||
className={`rounded px-2 py-1 text-[11px] ${changedOnly ? 'bg-[#E9E7E3] font-medium text-[#37352F]' : 'text-[#9B9A97]'}`}
|
||||
onClick={() => setChangedOnly(true)}
|
||||
>
|
||||
变更
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看全部页"
|
||||
className={`rounded px-2 py-1 text-[11px] ${!changedOnly ? 'bg-[#E9E7E3] font-medium text-[#37352F]' : 'text-[#9B9A97]'}`}
|
||||
onClick={() => setChangedOnly(false)}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto p-2"
|
||||
data-testid={workbenchSelectors.pptDiffThumbnails}
|
||||
@@ -199,23 +392,15 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
{visibleSlides.map((slide) => {
|
||||
const frame = slide.after || slide.before
|
||||
const selected = slide.slideId === activeSlide?.slideId
|
||||
const added = !slide.before && Boolean(slide.after)
|
||||
const deleted = Boolean(slide.before) && !slide.after
|
||||
const moved = Boolean(
|
||||
slide.before && slide.after && slide.before.index !== slide.after.index
|
||||
)
|
||||
const label = changeLabel(slide.changes)
|
||||
const deleted = slide.changes.includes('deleted')
|
||||
return (
|
||||
<button
|
||||
key={slide.slideId}
|
||||
type="button"
|
||||
className={`relative block w-full overflow-hidden rounded border-2 bg-white ${
|
||||
selected
|
||||
? 'border-[#37352F]'
|
||||
: slide.changed
|
||||
? 'border-[#C7A96B]'
|
||||
: 'border-transparent'
|
||||
}`}
|
||||
onClick={() => setActiveSlideId(slide.slideId)}
|
||||
aria-label={`第 ${slide.after?.index || slide.before?.index || 0} 页${label ? `,${label}` : ''}`}
|
||||
className={`relative block box-border w-full overflow-hidden rounded border-2 bg-white ${selected ? 'border-[#37352F]' : 'border-transparent'}`}
|
||||
onClick={() => selectSlide(slide.slideId)}
|
||||
>
|
||||
{frame && imageUrls[frame.image] ? (
|
||||
<img
|
||||
@@ -229,17 +414,13 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
|
||||
<span className="absolute bottom-0.5 left-1 rounded bg-black/60 px-1 text-[9px] text-white">
|
||||
{slide.after?.index || slide.before?.index}
|
||||
</span>
|
||||
<span className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-white/95 text-[#37352F] shadow-sm">
|
||||
{added ? (
|
||||
<HiPlus className="h-3 w-3 text-[#147A3D]" />
|
||||
) : deleted ? (
|
||||
<HiMinus className="h-3 w-3 text-[#B42318]" />
|
||||
) : moved ? (
|
||||
<HiArrowsUpDown className="h-3 w-3" />
|
||||
) : slide.changed ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#C18B25]" />
|
||||
) : null}
|
||||
</span>
|
||||
{manifest.operation === 'updated' && label ? (
|
||||
<span
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full ${deleted ? 'bg-[#DC2626]' : 'bg-[#16A34A]'}`}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,17 +1,128 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { getSlides, getSlideText, loadPresentation } from 'pptx-kit'
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas'
|
||||
import {
|
||||
findShapesOutsideCanvas,
|
||||
getShapeFillColor,
|
||||
getShapeRunFormat,
|
||||
getShapeStrokeColor,
|
||||
getShapeStrokeWidth,
|
||||
getShapeText,
|
||||
getSlideShapes,
|
||||
getSlides,
|
||||
getSlideText,
|
||||
hasShapeImage,
|
||||
loadPresentation,
|
||||
validatePresentation
|
||||
} from 'pptx-kit'
|
||||
import { describe, it } from 'vitest'
|
||||
import { createPptToolRuntime } from '../../../../../src/main/services/ai/human/tools/ppt/runtime'
|
||||
import {
|
||||
hasLiveLlmConfigInEnv,
|
||||
loadLiveLlmConfigFromEnv
|
||||
loadLiveLlmConfigFromEnv,
|
||||
loadLiveVisionLlmConfigFromEnv
|
||||
} from '../../../../harness/backend/llmConfig'
|
||||
import { createHumanTestRunner } from '../../../../harness/backend/humanHarness'
|
||||
import { runBackendCase } from '../../../../harness/backend/run-backend-case'
|
||||
import { filterAiRunEventsByType, runWithStructuredOutputRetry } from '../testUtils'
|
||||
|
||||
const FILE_NAME = 'AI产品季度复盘.pptx'
|
||||
const MARKERS = ['增长引擎', '下一步行动']
|
||||
const MARKERS = ['增长引擎', '交付质量保持稳定', '下一步行动']
|
||||
const REQUIRED_CONTENT = [
|
||||
...MARKERS,
|
||||
'产品负责人',
|
||||
'推进核心体验',
|
||||
'交付负责人',
|
||||
'完善质量门禁',
|
||||
'PMO',
|
||||
'缩短决策链路',
|
||||
'Q3'
|
||||
]
|
||||
const ALLOWED_NUMERIC_TOKENS = new Set([
|
||||
'38%',
|
||||
'12',
|
||||
'78%',
|
||||
'98.7%',
|
||||
'92.4%',
|
||||
'0.3%',
|
||||
'Q3',
|
||||
'01',
|
||||
'02',
|
||||
'03'
|
||||
])
|
||||
|
||||
const hasOnlySourceNumericTokens = (text: string): boolean =>
|
||||
(text.match(/\bQ\d+\b|\d+(?:\.\d+)?%?/gi) || []).every((token) =>
|
||||
ALLOWED_NUMERIC_TOKENS.has(token.toUpperCase())
|
||||
)
|
||||
|
||||
const buildLivePptRequest = (logoName: string): string =>
|
||||
[
|
||||
`请直接在工作区创建“${FILE_NAME}”,面向产品与交付管理团队制作三页 16:9 季度复盘。`,
|
||||
'',
|
||||
'内容白名单:',
|
||||
`- 第 1 页标题原样包含“${MARKERS[0]}”。只展示 ARR 同比增长 38%、新增企业客户 12 家、活跃使用率 78%,并使用本地图片“${logoName}”。`,
|
||||
`- 第 2 页标题原样包含“${MARKERS[1]}”。只展示需求准时交付率 98.7%、验收一次通过率 92.4%、严重缺陷率 0.3%。`,
|
||||
`- 第 3 页标题原样包含“${MARKERS[2]}”。只展示产品负责人推进核心体验、交付负责人完善质量门禁、PMO 缩短决策链路,以及 Q3 责任方向。`,
|
||||
'- 业务数字或季度只允许 38%、12、78%、98.7%、92.4%、0.3%、Q3;第 3 页视觉排序可用 01、02、03。',
|
||||
'- 禁止 2026、Q1、Q2、Q4、页码、其他指标数字,以及任何没有依据的做法、年份、季度或事实。',
|
||||
'',
|
||||
'设计约束:',
|
||||
'- 浅色或中性背景,至少两种强调色,并使用细边框或分隔线;禁止单一深蓝满版。',
|
||||
'- 三页使用不同布局;每页标题保持单行。',
|
||||
'- 主内容横向覆盖画布,左、中、右区域都有有效内容或视觉元素,禁止右半屏或一半以上画布留空。',
|
||||
'- 中文清晰可读,可直接用于正式汇报。',
|
||||
'',
|
||||
'执行与验收:',
|
||||
'- 只使用 PPT 和图像识别工具,不要使用 terminal。',
|
||||
'- 使用 PPT 工具逐页创建;每次提交一张内容完整的页面 HTML,禁止先建占位页再逐元素拼装。',
|
||||
'- 三页完成后一次性渲染全部页面,并使用图像识别逐页检查一次。',
|
||||
'- 图像检查必须确认标题没有换行,左右没有大面积无意义空白,也没有缺字、溢出、重叠或异常样式。',
|
||||
'- 如需修复,只复检修改页一次,并使用最近一次 ppt_render_slides 返回的新路径;最后确认文件已写回。'
|
||||
].join('\n')
|
||||
|
||||
const imageContentMetrics = async (
|
||||
imagePath: string
|
||||
): Promise<{ visiblePixelRatio: number; contentPixelRatiosByThird: number[] }> => {
|
||||
const image = await loadImage(imagePath)
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const context = canvas.getContext('2d')
|
||||
context.drawImage(image, 0, 0)
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data
|
||||
let sampled = 0
|
||||
let visible = 0
|
||||
const sampledByThird = [0, 0, 0]
|
||||
const contentByThird = [0, 0, 0]
|
||||
for (let index = 0; index < pixels.length; index += 64) {
|
||||
sampled += 1
|
||||
const pixelIndex = index / 4
|
||||
const x = pixelIndex % image.width
|
||||
const third = Math.min(2, Math.floor((x / image.width) * 3))
|
||||
sampledByThird[third]! += 1
|
||||
const red = pixels[index] || 0
|
||||
const green = pixels[index + 1] || 0
|
||||
const blue = pixels[index + 2] || 0
|
||||
const alpha = pixels[index + 3] || 0
|
||||
if (alpha > 16 && (red < 245 || green < 245 || blue < 245)) visible += 1
|
||||
if (alpha > 16 && red + green + blue < 650) contentByThird[third]! += 1
|
||||
}
|
||||
return {
|
||||
visiblePixelRatio: sampled > 0 ? visible / sampled : 0,
|
||||
contentPixelRatiosByThird: sampledByThird.map((count, index) =>
|
||||
count > 0 ? contentByThird[index]! / count : 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getShapeTextColor = (
|
||||
shape: ReturnType<typeof getSlideShapes>[number]
|
||||
): string | undefined => {
|
||||
try {
|
||||
return getShapeText(shape) ? getShapeRunFormat(shape, 0, 0)?.color : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
describe('ai human live backend', () => {
|
||||
it('human-create-ppt-deck-live', async () => {
|
||||
@@ -30,28 +141,98 @@ describe('ai human live backend', () => {
|
||||
execute: async (runtime) => {
|
||||
if (!hasLiveLlmConfigInEnv())
|
||||
return { skipped: true, output: null, events: [], file: null }
|
||||
await runtime.seedLlmConfig(loadLiveLlmConfigFromEnv())
|
||||
const primaryConfig = loadLiveLlmConfigFromEnv()
|
||||
const visionConfig = loadLiveVisionLlmConfigFromEnv()
|
||||
await runtime.seedLlmConfig(primaryConfig)
|
||||
await runtime.seedLlmConfig(visionConfig)
|
||||
const runner = createHumanTestRunner(runtime, {
|
||||
allowedToolNames: ['ppt_read_outline', 'ppt_edit'],
|
||||
allowedToolNames: [
|
||||
'image_analyze',
|
||||
'ppt_read_outline',
|
||||
'ppt_read_slides',
|
||||
'ppt_render_slides',
|
||||
'ppt_edit'
|
||||
],
|
||||
visionModel: {
|
||||
configId: visionConfig.id,
|
||||
modelName: visionConfig.model_name
|
||||
},
|
||||
runtimeSettingsOverride: { llmStreamingMode: 'force_non_stream' }
|
||||
})
|
||||
const selectedVisionConfig = await runner.ctx.getVisionLlmConfig()
|
||||
const logoName = 'ncurator-logo.png'
|
||||
await fs.copyFile(
|
||||
path.join(process.cwd(), 'src/renderer/src/assets/icon.png'),
|
||||
path.join(runtime.workspaceRoot, logoName)
|
||||
)
|
||||
const output = await runner.run({
|
||||
taskId: runtime.taskId,
|
||||
userInput: `请直接在工作区创建“${FILE_NAME}”。制作两页、16:9、简洁专业的产品季度复盘:第一页标题必须包含“${MARKERS[0]}”,第二页标题必须包含“${MARKERS[1]}”。必须使用 PPT 工具逐页创建,每次只生成一个页面 HTML,完成后确认文件已经写回。`
|
||||
userInput: buildLivePptRequest(logoName)
|
||||
})
|
||||
const filePath = path.join(runtime.workspaceRoot, FILE_NAME)
|
||||
const bytes = new Uint8Array(await fs.readFile(filePath))
|
||||
const presentation = await loadPresentation(bytes)
|
||||
const slides = getSlides(presentation)
|
||||
const shapes = slides.flatMap((slide) => getSlideShapes(slide))
|
||||
const visualRuntime = createPptToolRuntime({
|
||||
ctx: { saveFileChangeRecord: async () => undefined } as never,
|
||||
cache: {} as never,
|
||||
runId: `${runtime.runId}-visual-review`,
|
||||
sessionId: 'ppt-live-visual-review',
|
||||
taskId: runtime.taskId,
|
||||
workspaceRootPath: runtime.workspaceRoot,
|
||||
emitActivity: () => undefined
|
||||
})
|
||||
const renderedImages =
|
||||
slides.length > 0
|
||||
? (((
|
||||
await visualRuntime.render({
|
||||
sourcePath: FILE_NAME,
|
||||
ranges: [
|
||||
{ type: 'slide_index', fromSlideIndex: 1, toSlideIndex: slides.length }
|
||||
]
|
||||
})
|
||||
).images as Array<{ path: string }>) ?? [])
|
||||
: []
|
||||
const renderedImageMetrics = await Promise.all(
|
||||
renderedImages.map((image) => imageContentMetrics(image.path))
|
||||
)
|
||||
return {
|
||||
skipped: false,
|
||||
output,
|
||||
events: runtime.eventCollector.snapshot(),
|
||||
visionModelName: selectedVisionConfig.model_name,
|
||||
file: {
|
||||
path: filePath,
|
||||
slideCount: getSlides(presentation).length,
|
||||
text: getSlides(presentation)
|
||||
.map((slide) => getSlideText(slide))
|
||||
.join('\n')
|
||||
slideCount: slides.length,
|
||||
text: slides.map((slide) => getSlideText(slide)).join('\n'),
|
||||
shapeCount: shapes.length,
|
||||
distinctVisualColors: [
|
||||
...new Set(
|
||||
shapes
|
||||
.flatMap((shape) => [
|
||||
getShapeFillColor(shape),
|
||||
getShapeStrokeColor(shape),
|
||||
getShapeTextColor(shape)
|
||||
])
|
||||
.filter(Boolean)
|
||||
)
|
||||
].length,
|
||||
strokedShapeCount: shapes.filter((shape) => Number(getShapeStrokeWidth(shape)) > 0)
|
||||
.length,
|
||||
imageShapeCount: shapes.filter(hasShapeImage).length,
|
||||
outsideShapeCount: slides.reduce(
|
||||
(count, slide) => count + findShapesOutsideCanvas(slide, presentation).length,
|
||||
0
|
||||
),
|
||||
validationErrors: validatePresentation(presentation).filter(
|
||||
(issue) => issue.severity === 'error'
|
||||
).length,
|
||||
renderedImageCount: renderedImages.length,
|
||||
visiblePixelRatios: renderedImageMetrics.map((metric) => metric.visiblePixelRatio),
|
||||
contentPixelRatiosByThird: renderedImageMetrics.map(
|
||||
(metric) => metric.contentPixelRatiosByThird
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -71,18 +252,32 @@ describe('ai human live backend', () => {
|
||||
)
|
||||
runtime.assertThat(
|
||||
result.output?.status === 'completed' &&
|
||||
startedTools.filter((name) => name === 'ppt_edit').length >= 2 &&
|
||||
!startedTools.includes('terminal_run') &&
|
||||
result.file.slideCount === 2 &&
|
||||
MARKERS.every((marker) => result.file!.text.includes(marker)) &&
|
||||
startedTools.filter((name) => name === 'ppt_edit').length >= 3 &&
|
||||
startedTools.includes('ppt_render_slides') &&
|
||||
startedTools.filter((name) => name === 'image_analyze').length >= 3 &&
|
||||
result.visionModelName === loadLiveVisionLlmConfigFromEnv().model_name &&
|
||||
result.file.slideCount === 3 &&
|
||||
REQUIRED_CONTENT.every((marker) => result.file!.text.includes(marker)) &&
|
||||
hasOnlySourceNumericTokens(result.file.text) &&
|
||||
result.file.shapeCount >= 9 &&
|
||||
result.file.distinctVisualColors >= 4 &&
|
||||
result.file.strokedShapeCount >= 1 &&
|
||||
result.file.imageShapeCount >= 1 &&
|
||||
result.file.outsideShapeCount === 0 &&
|
||||
result.file.validationErrors === 0 &&
|
||||
result.file.renderedImageCount === 3 &&
|
||||
result.file.visiblePixelRatios.every((ratio) => ratio > 0.015) &&
|
||||
result.file.contentPixelRatiosByThird.every((thirds) =>
|
||||
thirds.every((ratio) => ratio > 0.005)
|
||||
) &&
|
||||
pptArtifacts.length === 1 &&
|
||||
result.output.artifacts.some(
|
||||
(artifact) => artifact.kind === 'file' && artifact.capability === 'ppt'
|
||||
),
|
||||
{
|
||||
name: 'human-live-creates-two-page-ppt-through-ppt-skill',
|
||||
name: 'human-live-creates-three-page-ppt-through-ppt-skill',
|
||||
message:
|
||||
'真实 AI 应通过 ppt_edit 逐页创建两页 PPT,最终写回一个可打开文件并产生 PPT 修改记录。',
|
||||
'真实 AI 应通过 ppt_edit 逐页创建三页 PPT,最终写回一个可打开文件并产生 PPT 修改记录。',
|
||||
data: {
|
||||
output: result.output,
|
||||
file: result.file,
|
||||
|
||||
@@ -20,7 +20,7 @@ const coverHtml = `<section data-ppt-slide style="position:relative;width:1920px
|
||||
</section>`
|
||||
|
||||
const detailHtml = `<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#FFFFFF">
|
||||
<h2 style="position:absolute;left:140px;top:100px;width:1500px;height:100px;color:#17324D;font-size:56px">下一步行动</h2>
|
||||
<h2 style="position:absolute;left:140px;top:100px;width:1500px;height:110px;color:#17324D;font-size:72px">下一步行动</h2>
|
||||
<p style="position:absolute;left:160px;top:300px;width:1450px;height:120px;color:#333333;font-size:38px">缩短反馈周期,集中投入增长引擎。</p>
|
||||
</section>`
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import { runBackendCase } from '../../../../harness/backend/run-backend-case'
|
||||
|
||||
const coverHtml = `<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#F5F0E8">
|
||||
<div style="position:absolute;left:100px;top:90px;width:1720px;height:220px;background:#17324D;border-radius:28px"></div>
|
||||
<h1 style="position:absolute;left:170px;top:145px;width:1450px;height:100px;color:#FFFFFF;font-size:64px">2026 产品复盘</h1>
|
||||
<h1 style="position:absolute;left:170px;top:135px;width:1450px;height:120px;color:#FFFFFF;font-size:72px">2026 产品复盘</h1>
|
||||
<p style="position:absolute;left:170px;top:420px;width:1480px;height:120px;color:#25313C;font-size:38px">收入增长 18%,交付质量继续提升。</p>
|
||||
</section>`
|
||||
|
||||
const detailHtml = `<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#FFFFFF">
|
||||
<h2 style="position:absolute;left:140px;top:100px;width:1500px;height:100px;color:#17324D;font-size:54px">核心结论</h2>
|
||||
<h2 style="position:absolute;left:140px;top:100px;width:1500px;height:110px;color:#17324D;font-size:72px">核心结论</h2>
|
||||
<p style="position:absolute;left:160px;top:300px;width:1450px;height:120px;color:#333333;font-size:36px">下一季度重点:缩短反馈周期。</p>
|
||||
<span data-pptx-latex="E=mc^2" style="position:absolute;left:160px;top:520px;width:600px;height:160px"></span>
|
||||
<div data-pptx-chart="column" data-pptx-categories='["Q1","Q2"]' data-pptx-series='[{"name":"收入","values":[12,18]}]' style="position:absolute;left:900px;top:500px;width:760px;height:420px"></div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createPptToolHarness } from '../../../../harness/backend/aiToolHarness'
|
||||
import { runBackendCase } from '../../../../harness/backend/run-backend-case'
|
||||
|
||||
const baseHtml = `<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#FFFFFF">
|
||||
<h1 style="position:absolute;left:140px;top:100px;width:1500px;height:100px;color:#17324D;font-size:56px">专业编辑验证</h1>
|
||||
<h1 style="position:absolute;left:140px;top:100px;width:1500px;height:110px;color:#17324D;font-size:72px">专业编辑验证</h1>
|
||||
</section>`
|
||||
|
||||
describe('ai tool ppt professional editing', () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasLiveLlmConfigInEnv,
|
||||
loadLiveVisionLlmConfigFromEnv
|
||||
} from '../../../harness/backend/llmConfig'
|
||||
|
||||
const ENV_KEYS = [
|
||||
'AI_LIVE_BASE_URL',
|
||||
'AI_LIVE_API_KEY',
|
||||
'AI_LIVE_PRIMARY_MODEL_NAME',
|
||||
'AI_LIVE_VISION_BASE_URL',
|
||||
'AI_LIVE_VISION_API_KEY',
|
||||
'AI_LIVE_VISION_MODEL_NAME',
|
||||
'AI_LIVE_VISION_PROVIDER_KIND'
|
||||
] as const
|
||||
|
||||
const originalEnv = new Map<string, string | undefined>()
|
||||
|
||||
beforeAll(() => {
|
||||
hasLiveLlmConfigInEnv()
|
||||
for (const key of ENV_KEYS) originalEnv.set(key, process.env[key])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = originalEnv.get(key)
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
describe('live LLM configuration', () => {
|
||||
it('selects qwen3.6-plus as the independent image model for Alibaba Cloud', () => {
|
||||
process.env.AI_LIVE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
process.env.AI_LIVE_API_KEY = 'test-key'
|
||||
process.env.AI_LIVE_PRIMARY_MODEL_NAME = 'deepseek-v4-flash'
|
||||
delete process.env.AI_LIVE_VISION_BASE_URL
|
||||
delete process.env.AI_LIVE_VISION_API_KEY
|
||||
delete process.env.AI_LIVE_VISION_MODEL_NAME
|
||||
delete process.env.AI_LIVE_VISION_PROVIDER_KIND
|
||||
|
||||
const config = loadLiveVisionLlmConfigFromEnv()
|
||||
|
||||
expect(config.id).toBe('ai-live-vision-config')
|
||||
expect(config.model_name).toBe('qwen3.6-plus')
|
||||
expect(config.base_url).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
expect(config.api_key).toBe('test-key')
|
||||
})
|
||||
|
||||
it('honors an explicitly configured image model', () => {
|
||||
process.env.AI_LIVE_BASE_URL = 'https://example.test/v1'
|
||||
process.env.AI_LIVE_API_KEY = 'primary-key'
|
||||
process.env.AI_LIVE_PRIMARY_MODEL_NAME = 'primary-model'
|
||||
process.env.AI_LIVE_VISION_BASE_URL = 'https://vision.example.test/v1'
|
||||
process.env.AI_LIVE_VISION_API_KEY = 'vision-key'
|
||||
process.env.AI_LIVE_VISION_MODEL_NAME = 'vision-model'
|
||||
|
||||
const config = loadLiveVisionLlmConfigFromEnv()
|
||||
|
||||
expect(config.model_name).toBe('vision-model')
|
||||
expect(config.base_url).toBe('https://vision.example.test/v1')
|
||||
expect(config.api_key).toBe('vision-key')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SKILL_DEFINITIONS } from '../../../../src/main/services/ai/human/skills'
|
||||
import {
|
||||
expandSkillIdsWithRequirements,
|
||||
SKILL_DEFINITIONS
|
||||
} from '../../../../src/main/services/ai/human/skills'
|
||||
import {
|
||||
PPT_EDIT_TOOL_SPEC,
|
||||
PPT_TOOL_SPECS
|
||||
@@ -11,6 +14,13 @@ import {
|
||||
pptSearchSchema
|
||||
} from '../../../../src/main/services/ai/human/tools/ppt/schemas'
|
||||
import { renderLatexToSvg } from '../../../../src/main/services/ai/human/tools/ppt/formula'
|
||||
import {
|
||||
compilePptSlideHtml,
|
||||
normalizePptColor,
|
||||
PPT_CANVAS_HEIGHT,
|
||||
PPT_CANVAS_WIDTH
|
||||
} from '../../../../src/main/services/ai/human/tools/ppt/htmlCompiler'
|
||||
import { buildPptDiffManifest } from '../../../../src/main/services/ai/human/tools/ppt/runtime'
|
||||
|
||||
describe('ai unit ppt tool contract', () => {
|
||||
it('splits ppt reading and writing into two skills and five prefixed tools', () => {
|
||||
@@ -21,6 +31,8 @@ describe('ai unit ppt tool contract', () => {
|
||||
'ppt_render_slides'
|
||||
])
|
||||
expect(SKILL_DEFINITIONS['ppt-write'].toolNames).toEqual(['ppt_edit'])
|
||||
expect(SKILL_DEFINITIONS['ppt-write'].requiredSkillIds).toEqual(['ppt-read'])
|
||||
expect(expandSkillIdsWithRequirements(['ppt-write'])).toEqual(['ppt-read', 'ppt-write'])
|
||||
expect(PPT_TOOL_SPECS.map((spec) => spec.name)).toEqual([
|
||||
'ppt_read_outline',
|
||||
'ppt_read_slides',
|
||||
@@ -118,4 +130,88 @@ describe('ai unit ppt tool contract', () => {
|
||||
expect(svg.startsWith('<svg')).toBe(true)
|
||||
expect(svg.endsWith('</svg>')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes browser colors before writing native PPT styles', () => {
|
||||
expect(normalizePptColor('rgb(12, 34, 56)')).toBe('#0C2238')
|
||||
expect(normalizePptColor('rgba(12, 34, 56, 0)')).toBeUndefined()
|
||||
expect(normalizePptColor('#abc')).toBe('#AABBCC')
|
||||
expect(normalizePptColor('rebeccapurple', '#FFFFFF')).toBe('#FFFFFF')
|
||||
})
|
||||
|
||||
it('compiles restricted HTML on a fixed canvas without duplicate nested text', async () => {
|
||||
const slide = await compilePptSlideHtml(`
|
||||
<section data-ppt-slide style="width:1920px;height:1080px;background:#fff">
|
||||
<div data-ppt-element="text" id="title" style="position:absolute;left:100px;top:100px;width:800px;height:100px;color:rgb(12,34,56);font-size:72px"><p>唯一标题</p></div>
|
||||
<div data-ppt-element="shape" id="box" style="position:absolute;left:100px;top:240px;width:400px;height:200px;border-top:2px solid #00ff00;border-right:0 solid #ff0000;background:#eeeeee;box-shadow:0 4px 12px rgba(0,0,0,.15)"></div>
|
||||
<img data-ppt-element="image" id="photo" src="local.png" style="position:absolute;left:600px;top:240px;width:400px;height:200px;object-fit:cover" />
|
||||
</section>
|
||||
`)
|
||||
expect(slide.width).toBe(PPT_CANVAS_WIDTH)
|
||||
expect(slide.height).toBe(PPT_CANVAS_HEIGHT)
|
||||
expect(slide.elements.filter((element) => element.sourceId === 'title')).toHaveLength(1)
|
||||
expect(slide.elements.find((element) => element.sourceId === 'title')?.type).toBe('text')
|
||||
const box = slide.elements.find((element) => element.sourceId === 'box')
|
||||
expect(box?.style.fill).toBe('#EEEEEE')
|
||||
expect(box?.style.borders?.top).toEqual({ color: '#00FF00', width: 2 })
|
||||
expect(box?.style.borders?.right).toBeUndefined()
|
||||
expect(box?.style.shadow).toBeTruthy()
|
||||
expect(
|
||||
slide.elements.find((element) => element.sourceId === 'title')?.style.shadow
|
||||
).toBeUndefined()
|
||||
expect(slide.elements.find((element) => element.sourceId === 'photo')?.imageFit).toBe('cover')
|
||||
})
|
||||
|
||||
it('rejects unsupported CSS and non-standard slide roots', async () => {
|
||||
await expect(
|
||||
compilePptSlideHtml(
|
||||
'<section data-ppt-slide style="width:1000px;height:1080px"><div data-ppt-element="shape" style="width:10px;height:10px"></div></section>'
|
||||
)
|
||||
).rejects.toThrow('1920x1080')
|
||||
await expect(
|
||||
compilePptSlideHtml(
|
||||
'<section data-ppt-slide style="width:1920px;height:1080px"><div data-ppt-element="shape" style="width:10px;height:10px;filter:blur(2px)"></div></section>'
|
||||
)
|
||||
).rejects.toThrow('Unsupported CSS property')
|
||||
await expect(
|
||||
compilePptSlideHtml(
|
||||
'<section data-ppt-slide style="width:1920px;height:1080px"><div data-ppt-element="shape" style="width:100px;height:20px;background:linear-gradient(90deg,#0a0,#00f)"></div></section>'
|
||||
)
|
||||
).rejects.toThrow('Unsupported CSS background')
|
||||
})
|
||||
|
||||
it('requires a readable title and keeps text inside the slide safe area', async () => {
|
||||
await expect(
|
||||
compilePptSlideHtml(
|
||||
'<section data-ppt-slide style="width:1920px;height:1080px"><h1 data-ppt-element="text" style="position:absolute;left:100px;top:100px;width:800px;height:100px;font-size:64px">标题</h1></section>'
|
||||
)
|
||||
).rejects.toThrow('at least 70px')
|
||||
await expect(
|
||||
compilePptSlideHtml(
|
||||
'<section data-ppt-slide style="width:1920px;height:1080px"><h1 data-ppt-element="text" style="position:absolute;left:20px;top:100px;width:800px;height:100px;font-size:72px">标题</h1></section>'
|
||||
)
|
||||
).rejects.toThrow('60px safe area')
|
||||
})
|
||||
|
||||
it('emits v2 created and updated diff semantics', () => {
|
||||
const before = new Map([
|
||||
['slide-1', { index: 1, image: 'before-1.png', hash: 'same' }],
|
||||
['slide-2', { index: 2, image: 'before-2.png', hash: 'old' }]
|
||||
])
|
||||
const after = new Map([
|
||||
['slide-2', { index: 1, image: 'after-2.png', hash: 'new' }],
|
||||
['slide-3', { index: 2, image: 'after-3.png', hash: 'new' }]
|
||||
])
|
||||
const manifest = buildPptDiffManifest({ before, after, operation: 'updated' })
|
||||
expect(manifest.version).toBe(2)
|
||||
expect(manifest.slides.map((slide) => slide.changes)).toEqual([
|
||||
['deleted'],
|
||||
['modified', 'moved'],
|
||||
['added']
|
||||
])
|
||||
expect(
|
||||
buildPptDiffManifest({ before: new Map(), after, operation: 'created' }).slides.every(
|
||||
(slide) => slide.changes.includes('added')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createPptToolRuntime } from '../../../../src/main/services/ai/human/tools/ppt/runtime'
|
||||
|
||||
describe('ppt visual smoke', () => {
|
||||
it('writes and renders a representative restricted HTML slide', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ncurator-ppt-smoke-'))
|
||||
try {
|
||||
const runtime = createPptToolRuntime({
|
||||
ctx: { saveFileChangeRecord: async () => undefined } as never,
|
||||
cache: {} as never,
|
||||
runId: 'smoke-run',
|
||||
sessionId: 'smoke-session',
|
||||
taskId: 'smoke-task',
|
||||
workspaceRootPath: root,
|
||||
emitActivity: () => undefined
|
||||
})
|
||||
const imagePath = path.join(root, 'sample.png')
|
||||
await fs.writeFile(
|
||||
imagePath,
|
||||
Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
const created = await runtime.edit({
|
||||
sourcePath: 'visual-smoke.pptx',
|
||||
actions: [
|
||||
{
|
||||
type: 'create_presentation',
|
||||
html: `<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#F7F8FA">
|
||||
<img data-ppt-element="image" id="sample" src="${pathToFileURL(imagePath).href}" style="position:absolute;left:1700px;top:72px;width:80px;height:80px;object-fit:contain" />
|
||||
<div data-ppt-element="shape" id="band" style="position:absolute;left:96px;top:72px;width:1728px;height:168px;background:#17324D;border-radius:18px"></div>
|
||||
<h1 data-ppt-element="text" id="title" style="position:absolute;left:144px;top:112px;width:1500px;height:96px;color:#FFFFFF;font-family:Microsoft YaHei;font-size:72px;font-weight:700">季度经营复盘</h1>
|
||||
<p data-ppt-element="text" id="summary" style="position:absolute;left:144px;top:330px;width:1500px;height:96px;color:rgb(30,41,59);font-size:34px">收入增长 18%,交付质量保持稳定</p>
|
||||
<div data-ppt-element="shape" id="card" style="position:absolute;left:144px;top:500px;width:760px;height:300px;background:#FFFFFF;border-top:3px solid #16A34A;border-bottom:1px solid #D1D5DB"></div>
|
||||
<p data-ppt-element="text" id="cardText" style="position:absolute;left:192px;top:560px;width:650px;height:100px;color:#334155;font-size:30px">绿色标记代表本次内容改动</p>
|
||||
</section>`
|
||||
}
|
||||
]
|
||||
})
|
||||
const inserted = await runtime.edit({
|
||||
sourcePath: 'visual-smoke.pptx',
|
||||
actions: [
|
||||
{
|
||||
type: 'insert_slide',
|
||||
html: `<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#FFFFFF">
|
||||
<div data-ppt-element="shape" id="second-band" style="position:absolute;left:120px;top:120px;width:1680px;height:180px;background:#17324D"></div>
|
||||
<h2 data-ppt-element="text" id="second-title" style="position:absolute;left:160px;top:155px;width:1400px;height:100px;color:#FFFFFF;font-size:72px">第二页可以在独立调用中追加</h2>
|
||||
</section>`
|
||||
}
|
||||
]
|
||||
})
|
||||
const pptxPath = path.join(root, 'visual-smoke.pptx')
|
||||
const rendered = await runtime.render({
|
||||
sourcePath: pptxPath,
|
||||
ranges: [{ type: 'slide_index', fromSlideIndex: 1, toSlideIndex: 2 }]
|
||||
})
|
||||
const renderedImages = rendered.images as Array<{ path: string }>
|
||||
expect(created.warnings).not.toContain(
|
||||
'Chromium layout was unavailable; explicit element bounds were used.'
|
||||
)
|
||||
expect(inserted.warnings).not.toContain(
|
||||
'Chromium layout was unavailable; explicit element bounds were used.'
|
||||
)
|
||||
const createdSlides = (
|
||||
created.postWriteReadback as {
|
||||
slides: Array<{
|
||||
elements: Array<{
|
||||
text?: string
|
||||
bounds: { x: number; y: number; width: number }
|
||||
style?: { fontFamily?: string; fontSize?: number }
|
||||
}>
|
||||
}>
|
||||
}
|
||||
).slides
|
||||
const title = createdSlides[0]!.elements.find((element) => element.text === '季度经营复盘')
|
||||
expect(title?.bounds.x).toBeCloseTo(144, 0)
|
||||
expect(title?.bounds.y).toBeCloseTo(112, 0)
|
||||
expect(title?.bounds.width).toBeCloseTo(1572, 0)
|
||||
expect(title?.style?.fontFamily).toBe('Microsoft YaHei')
|
||||
expect(title?.style?.fontSize).toBeCloseTo(36, 1)
|
||||
expect((await fs.stat(pptxPath)).size).toBeGreaterThan(1000)
|
||||
expect(renderedImages).toHaveLength(2)
|
||||
await Promise.all(
|
||||
renderedImages.map(async (image) =>
|
||||
expect((await fs.stat(image.path)).size).toBeGreaterThan(1000)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 15_000)
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from 'react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WorkspacePathLabel from '../../../../src/renderer/src/components/workbench/WorkspacePathLabel'
|
||||
import { TextFileDiffViewer } from '../../../../src/renderer/src/components/workbench/TextFileDiffViewer'
|
||||
import type { WorkspaceSource } from '../../../../src/shared/types'
|
||||
import PptDiffViewer from '../../../../src/renderer/src/components/workbench/PptDiffViewer'
|
||||
import { workbenchSelectors } from '../../../../src/renderer/src/testing/selectors'
|
||||
import type { AiFileChangeRecord, WorkspaceSource } from '../../../../src/shared/types'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -62,4 +64,138 @@ describe('workbench panel visual contract', () => {
|
||||
expect(line?.className).toContain('w-full')
|
||||
expect(line?.parentElement?.className).toContain('w-full')
|
||||
})
|
||||
|
||||
it('shows a created PPT as one final canvas without change markers or filters', async () => {
|
||||
const manifest = {
|
||||
version: 2,
|
||||
operation: 'created',
|
||||
slides: [
|
||||
{
|
||||
slideId: 'slide-1',
|
||||
changed: true,
|
||||
changes: ['added'],
|
||||
after: { index: 1, image: '/after-1.png' }
|
||||
},
|
||||
{
|
||||
slideId: 'slide-2',
|
||||
changed: true,
|
||||
changes: ['added'],
|
||||
after: { index: 2, image: '/after-2.png' }
|
||||
}
|
||||
]
|
||||
}
|
||||
mockPptFiles(manifest)
|
||||
const { container } = render(
|
||||
React.createElement(PptDiffViewer, { record: pptRecord('created') })
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(workbenchSelectors.pptDiffAfter)).toBeTruthy())
|
||||
expect(screen.queryByTestId(workbenchSelectors.pptDiffBefore)).toBeNull()
|
||||
expect(screen.queryByText('修改后')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '查看全部页' })).toBeNull()
|
||||
expect(screen.queryByLabelText('新增页')).toBeNull()
|
||||
const thumbnails = container.querySelectorAll(
|
||||
`[data-testid="${workbenchSelectors.pptDiffThumbnails}"] button`
|
||||
)
|
||||
expect(thumbnails).toHaveLength(2)
|
||||
expect(thumbnails[1]?.className).toContain('border-transparent')
|
||||
})
|
||||
|
||||
it('shows updated PPT markers and supports shared wheel zoom and drag pan', async () => {
|
||||
const manifest = {
|
||||
version: 2,
|
||||
operation: 'updated',
|
||||
slides: [
|
||||
{
|
||||
slideId: 'slide-1',
|
||||
changed: true,
|
||||
changes: ['modified'],
|
||||
before: { index: 1, image: '/before-1.png' },
|
||||
after: { index: 1, image: '/after-1.png' }
|
||||
},
|
||||
{
|
||||
slideId: 'slide-2',
|
||||
changed: true,
|
||||
changes: ['deleted'],
|
||||
before: { index: 2, image: '/before-2.png' }
|
||||
},
|
||||
{
|
||||
slideId: 'slide-3',
|
||||
changed: false,
|
||||
changes: [],
|
||||
before: { index: 3, image: '/before-3.png' },
|
||||
after: { index: 3, image: '/after-3.png' }
|
||||
}
|
||||
]
|
||||
}
|
||||
mockPptFiles(manifest)
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
value: MouseEvent
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn()
|
||||
})
|
||||
render(React.createElement(PptDiffViewer, { record: pptRecord('updated') }))
|
||||
|
||||
const after = await screen.findByTestId(workbenchSelectors.pptDiffAfter)
|
||||
expect(screen.getByTestId(workbenchSelectors.pptDiffBefore)).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '查看全部页' })).toBeTruthy()
|
||||
expect(screen.getByLabelText('内容已修改').className).toContain('bg-[#16A34A]')
|
||||
expect(screen.getByLabelText('已删除').className).toContain('bg-[#DC2626]')
|
||||
fireEvent.wheel(after, { deltaY: -100, clientX: 200, clientY: 120 })
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: '适应窗口' }).textContent).toBe('112%')
|
||||
)
|
||||
const transformNode = after.querySelector<HTMLElement>('[style*="transform"]')!
|
||||
const beforeDrag = transformNode.style.transform
|
||||
fireEvent.pointerDown(after, { pointerId: 7, clientX: 200, clientY: 120 })
|
||||
fireEvent.pointerMove(after, { pointerId: 7, clientX: 240, clientY: 150 })
|
||||
expect(transformNode.style.transform).not.toBe(beforeDrag)
|
||||
const beforeCoordinates = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(beforeDrag)!
|
||||
const afterCoordinates = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(
|
||||
transformNode.style.transform
|
||||
)!
|
||||
expect(Number(afterCoordinates[1]) - Number(beforeCoordinates[1])).toBeCloseTo(40)
|
||||
expect(Number(afterCoordinates[2]) - Number(beforeCoordinates[2])).toBeCloseTo(30)
|
||||
})
|
||||
})
|
||||
|
||||
const pptRecord = (operation: 'created' | 'updated'): AiFileChangeRecord => ({
|
||||
id: `ppt-${operation}`,
|
||||
sessionId: 'session-1',
|
||||
runId: 'run-1',
|
||||
status: 'applied',
|
||||
capability: 'ppt',
|
||||
path: '/deck.pptx',
|
||||
fileName: 'deck.pptx',
|
||||
operation,
|
||||
changeCount: 2,
|
||||
insertCount: 2,
|
||||
deleteCount: 0,
|
||||
previewPath: `/manifest-${operation}.json`,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
|
||||
const mockPptFiles = (manifest: object): void => {
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:preview')
|
||||
})
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn()
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readFile: vi.fn(async (filePath: string) =>
|
||||
filePath.endsWith('.json')
|
||||
? new TextEncoder().encode(JSON.stringify(manifest))
|
||||
: new Uint8Array([137, 80, 78, 71])
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -29,8 +29,7 @@ describe('workbench result e2e', () => {
|
||||
domain: 'workbench',
|
||||
suite: 'result',
|
||||
groups: ['apply'],
|
||||
description:
|
||||
'真实 AI 创建 PPT 后,验证修改文件入口、分页左右 Diff、缩略图筛选和新建文件回撤。',
|
||||
description: '真实 AI 创建 PPT 后,验证新建 PPT 单画布预览、缩略图和新建文件回撤。',
|
||||
fixtures: [],
|
||||
launchMode: 'real',
|
||||
windowMode: 'visible',
|
||||
@@ -76,21 +75,50 @@ describe('workbench result e2e', () => {
|
||||
page,
|
||||
workbenchSelectors.pptDiffAfter
|
||||
).isVisible()
|
||||
const changedThumbCount = await getWorkbenchLocator(
|
||||
const thumbnailCount = await getWorkbenchLocator(
|
||||
page,
|
||||
workbenchSelectors.pptDiffThumbnails
|
||||
)
|
||||
.locator('button')
|
||||
.count()
|
||||
await getWorkbenchLocator(page, workbenchSelectors.pptDiffViewer)
|
||||
.getByLabel('查看全部页')
|
||||
.click()
|
||||
const allThumbCount = await getWorkbenchLocator(
|
||||
const filterCount = await getWorkbenchLocator(page, workbenchSelectors.pptDiffViewer)
|
||||
.getByRole('button', { name: /只看变更页|查看全部页/ })
|
||||
.count()
|
||||
const addedMarkerCount = await getWorkbenchLocator(
|
||||
page,
|
||||
workbenchSelectors.pptDiffViewer
|
||||
)
|
||||
.locator('[aria-label="新增页"]')
|
||||
.count()
|
||||
const thumbnailButtons = getWorkbenchLocator(
|
||||
page,
|
||||
workbenchSelectors.pptDiffThumbnails
|
||||
)
|
||||
.locator('button')
|
||||
.count()
|
||||
).locator('button')
|
||||
const inactiveThumbnailClass =
|
||||
(await thumbnailButtons.nth(1).getAttribute('class')) || ''
|
||||
const afterSurface = getWorkbenchLocator(page, workbenchSelectors.pptDiffAfter)
|
||||
const transform = afterSurface.locator(':scope > div[style*="transform"]').first()
|
||||
const initialTransform = (await transform.getAttribute('style')) || ''
|
||||
await afterSurface.hover()
|
||||
await page.mouse.wheel(0, -420)
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector('[aria-label="适应窗口"]')
|
||||
return button?.textContent !== '100%'
|
||||
})
|
||||
const zoomPercent = (
|
||||
(await getWorkbenchLocator(page, workbenchSelectors.pptDiffViewer)
|
||||
.getByRole('button', { name: '适应窗口' })
|
||||
.textContent()) || ''
|
||||
).trim()
|
||||
const zoomedTransform = (await transform.getAttribute('style')) || ''
|
||||
const box = await afterSurface.boundingBox()
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width / 2 + 56, box.y + box.height / 2 + 32)
|
||||
await page.mouse.up()
|
||||
}
|
||||
const draggedTransform = (await transform.getAttribute('style')) || ''
|
||||
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
|
||||
await page.getByText('删除这个新建文件?').waitFor({ state: 'visible' })
|
||||
await page.getByRole('button', { name: '恢复到此前版本' }).click()
|
||||
@@ -105,8 +133,14 @@ describe('workbench result e2e', () => {
|
||||
applyEntryText,
|
||||
beforeVisible,
|
||||
afterVisible,
|
||||
changedThumbCount,
|
||||
allThumbCount,
|
||||
thumbnailCount,
|
||||
filterCount,
|
||||
addedMarkerCount,
|
||||
inactiveThumbnailClass,
|
||||
initialTransform,
|
||||
zoomedTransform,
|
||||
draggedTransform,
|
||||
zoomPercent,
|
||||
existsAfterRevert,
|
||||
pptToolCalls: evidence.events.filter(
|
||||
(event) => event.type === 'tool_call_started' && event.toolName === 'ppt_edit'
|
||||
@@ -124,10 +158,15 @@ describe('workbench result e2e', () => {
|
||||
result.slideCount === 2 &&
|
||||
result.slideText.includes(MARKER) &&
|
||||
result.applyEntryText.includes(FILE_NAME) &&
|
||||
result.beforeVisible &&
|
||||
!result.beforeVisible &&
|
||||
result.afterVisible &&
|
||||
result.changedThumbCount === 2 &&
|
||||
result.allThumbCount === 2 &&
|
||||
result.thumbnailCount === 2 &&
|
||||
result.filterCount === 0 &&
|
||||
result.addedMarkerCount === 0 &&
|
||||
result.inactiveThumbnailClass.includes('border-transparent') &&
|
||||
result.zoomPercent !== '100%' &&
|
||||
result.zoomedTransform !== result.initialTransform &&
|
||||
result.draggedTransform !== result.zoomedTransform &&
|
||||
result.existsAfterRevert === false &&
|
||||
result.pptToolCalls >= 2 &&
|
||||
result.pptArtifacts === 1,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { getSlides, getSlideText, loadPresentation } from 'pptx-kit'
|
||||
import { describe, it } from 'vitest'
|
||||
import { createPptToolRuntime } from '../../../../../src/main/services/ai/human/tools/ppt/runtime'
|
||||
import { runE2ECase } from '../../../../harness/e2e/run-e2e-case'
|
||||
import {
|
||||
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
|
||||
assertNoWorkbenchFakeSignals,
|
||||
getWorkbenchLocator,
|
||||
recordWorkbenchAiEvidence,
|
||||
sendWorkbenchMessage,
|
||||
setupWorkbenchWithTempWorkspace,
|
||||
waitForApplyEntry,
|
||||
waitForWorkbenchAiRunCompletion,
|
||||
waitForWorkbenchApplyRecordStatus,
|
||||
workbenchSelectors
|
||||
} from '../../../../harness/e2e/workbench'
|
||||
|
||||
const CASE_ID = 'ppt-update-diff-interactions'
|
||||
const FILE_NAME = 'existing-quarterly-review.pptx'
|
||||
|
||||
const slideHtml = (title: string, body: string, accent: string): string => `
|
||||
<section data-ppt-slide style="position:relative;width:1920px;height:1080px;overflow:hidden;background:#F7F8FA">
|
||||
<div data-ppt-element="shape" style="position:absolute;left:100px;top:78px;width:24px;height:170px;background:${accent}"></div>
|
||||
<h1 data-ppt-element="text" style="position:absolute;left:170px;top:96px;width:1500px;height:100px;color:#17324D;font-family:Microsoft YaHei;font-size:72px;font-weight:700">${title}</h1>
|
||||
<p data-ppt-element="text" style="position:absolute;left:170px;top:330px;width:1450px;height:150px;color:#334155;font-family:Microsoft YaHei;font-size:34px">${body}</p>
|
||||
<div data-ppt-element="line" style="position:absolute;left:170px;top:820px;width:1450px;height:1px;border-top:2px solid ${accent}"></div>
|
||||
</section>`
|
||||
|
||||
const createBaselineDeck = async (workspaceRootPath: string, runId: string, taskId: string) => {
|
||||
const runtime = createPptToolRuntime({
|
||||
ctx: { saveFileChangeRecord: async () => undefined } as never,
|
||||
cache: {} as never,
|
||||
runId: `${runId}-baseline`,
|
||||
sessionId: 'ppt-update-baseline',
|
||||
taskId,
|
||||
workspaceRootPath,
|
||||
emitActivity: () => undefined
|
||||
})
|
||||
await runtime.edit({
|
||||
sourcePath: FILE_NAME,
|
||||
actions: [
|
||||
{
|
||||
type: 'create_presentation',
|
||||
html: slideHtml('市场信号', '核心客户需求持续增加,市场窗口已经明确。', '#2563EB')
|
||||
},
|
||||
{
|
||||
type: 'insert_slide',
|
||||
html: slideHtml('产品进展', '关键能力已经进入稳定交付阶段。', '#7C3AED')
|
||||
},
|
||||
{
|
||||
type: 'insert_slide',
|
||||
html: slideHtml('交付质量', '本季度交付准时率保持在目标区间。', '#16A34A')
|
||||
},
|
||||
{
|
||||
type: 'insert_slide',
|
||||
html: slideHtml('附录数据', '本页必须保持完全不变,用于验证未修改页面。', '#64748B')
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
describe('workbench result e2e', () => {
|
||||
it(
|
||||
CASE_ID,
|
||||
async () => {
|
||||
await runE2ECase({
|
||||
caseId: CASE_ID,
|
||||
domain: 'workbench',
|
||||
suite: 'result',
|
||||
groups: ['apply'],
|
||||
description:
|
||||
'真实 AI 更新已有 PPT 后,验证页面增删改移动标记、左右同步缩放拖拽、筛选和文件回撤。',
|
||||
fixtures: [],
|
||||
launchMode: 'real',
|
||||
windowMode: 'visible',
|
||||
runMode: 'single',
|
||||
execute: async (runtime) => {
|
||||
const { page } = runtime
|
||||
const setup = await setupWorkbenchWithTempWorkspace(page, {
|
||||
caseId: CASE_ID,
|
||||
rootBindingMode: 'ipc'
|
||||
})
|
||||
try {
|
||||
await createBaselineDeck(setup.workspace.rootPath, runtime.runId, runtime.taskId)
|
||||
const filePath = path.join(setup.workspace.rootPath, FILE_NAME)
|
||||
const baselineBytes = await fs.readFile(filePath)
|
||||
const startedAtMs = Date.now()
|
||||
await sendWorkbenchMessage(
|
||||
page,
|
||||
`请直接修改工作区已有的“${FILE_NAME}”。必须先读取页面提纲,再完成这些操作:把“交付质量”页移动到第一页;把“市场信号”页标题改成“市场信号已确认”;删除“产品进展”页;在“附录数据”页之前新增一页“风险与应对”;“附录数据”页必须保持完全不变。只使用 PPT 工具,完成后渲染全部变化页面检查并确认文件已经写回。`
|
||||
)
|
||||
const evidence = await waitForWorkbenchAiRunCompletion(
|
||||
runtime,
|
||||
startedAtMs,
|
||||
setup.env.modelName,
|
||||
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
|
||||
{ page, blockerPolicy: 'auto_approve' }
|
||||
)
|
||||
recordWorkbenchAiEvidence(runtime, evidence, 0.8)
|
||||
|
||||
const updatedPresentation = await loadPresentation(
|
||||
new Uint8Array(await fs.readFile(filePath))
|
||||
)
|
||||
const updatedText = getSlides(updatedPresentation)
|
||||
.map((slide) => getSlideText(slide))
|
||||
.join('\n')
|
||||
const applyEntry = await waitForApplyEntry(page)
|
||||
await applyEntry.click()
|
||||
const viewer = getWorkbenchLocator(page, workbenchSelectors.pptDiffViewer)
|
||||
await viewer.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
const beforeSurface = getWorkbenchLocator(page, workbenchSelectors.pptDiffBefore)
|
||||
const afterSurface = getWorkbenchLocator(page, workbenchSelectors.pptDiffAfter)
|
||||
const beforeVisible = await beforeSurface.isVisible()
|
||||
const afterVisible = await afterSurface.isVisible()
|
||||
const changedThumbnails = getWorkbenchLocator(
|
||||
page,
|
||||
workbenchSelectors.pptDiffThumbnails
|
||||
).locator('button')
|
||||
const changedThumbnailCount = await changedThumbnails.count()
|
||||
const greenMarkerCount = await viewer
|
||||
.locator(
|
||||
'[aria-label="新增页"], [aria-label="内容已修改"], [aria-label="位置已移动"], [aria-label="内容已修改,位置已移动"]'
|
||||
)
|
||||
.count()
|
||||
const redMarkerCount = await viewer.locator('[aria-label="已删除"]').count()
|
||||
const inactiveThumbnailClass =
|
||||
(await changedThumbnails.nth(1).getAttribute('class')) || ''
|
||||
|
||||
const beforeTransform = beforeSurface
|
||||
.locator(':scope > div[style*="transform"]')
|
||||
.first()
|
||||
const afterTransform = afterSurface.locator(':scope > div[style*="transform"]').first()
|
||||
await afterSurface.hover()
|
||||
await page.mouse.wheel(0, -420)
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector('[aria-label="适应窗口"]')
|
||||
return button?.textContent !== '100%'
|
||||
})
|
||||
const beforeZoomTransform = (await beforeTransform.getAttribute('style')) || ''
|
||||
const afterZoomTransform = (await afterTransform.getAttribute('style')) || ''
|
||||
const box = await afterSurface.boundingBox()
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width / 2 + 48, box.y + box.height / 2 + 28)
|
||||
await page.mouse.up()
|
||||
}
|
||||
const beforeDragTransform = (await beforeTransform.getAttribute('style')) || ''
|
||||
const afterDragTransform = (await afterTransform.getAttribute('style')) || ''
|
||||
|
||||
await viewer.getByRole('button', { name: '查看全部页' }).click()
|
||||
const allThumbnailCount = await getWorkbenchLocator(
|
||||
page,
|
||||
workbenchSelectors.pptDiffThumbnails
|
||||
)
|
||||
.locator('button')
|
||||
.count()
|
||||
|
||||
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
|
||||
await waitForWorkbenchApplyRecordStatus(page, 'reverted')
|
||||
const revertedBytes = await fs.readFile(filePath)
|
||||
const revertedPresentation = await loadPresentation(new Uint8Array(revertedBytes))
|
||||
const revertedText = getSlides(revertedPresentation)
|
||||
.map((slide) => getSlideText(slide))
|
||||
.join('\n')
|
||||
|
||||
return {
|
||||
updatedText,
|
||||
beforeVisible,
|
||||
afterVisible,
|
||||
changedThumbnailCount,
|
||||
allThumbnailCount,
|
||||
greenMarkerCount,
|
||||
redMarkerCount,
|
||||
inactiveThumbnailClass,
|
||||
beforeZoomTransform,
|
||||
afterZoomTransform,
|
||||
beforeDragTransform,
|
||||
afterDragTransform,
|
||||
revertedToBaseline: revertedBytes.equals(baselineBytes),
|
||||
revertedText,
|
||||
toolNames: evidence.events
|
||||
.filter((event) => event.type === 'tool_call_started')
|
||||
.map((event) => event.toolName)
|
||||
}
|
||||
} finally {
|
||||
await setup.workspace.cleanup()
|
||||
}
|
||||
},
|
||||
assert: async (result, runtime) => {
|
||||
await runtime.assertThat(
|
||||
result.updatedText.includes('市场信号已确认') &&
|
||||
result.updatedText.includes('风险与应对') &&
|
||||
!result.updatedText.includes('产品进展') &&
|
||||
result.beforeVisible &&
|
||||
result.afterVisible &&
|
||||
result.changedThumbnailCount === 4 &&
|
||||
result.allThumbnailCount === 5 &&
|
||||
result.greenMarkerCount >= 3 &&
|
||||
result.redMarkerCount === 1 &&
|
||||
result.inactiveThumbnailClass.includes('border-transparent') &&
|
||||
result.beforeZoomTransform === result.afterZoomTransform &&
|
||||
result.beforeDragTransform === result.afterDragTransform &&
|
||||
result.beforeDragTransform !== result.beforeZoomTransform &&
|
||||
result.revertedToBaseline &&
|
||||
result.revertedText.includes('产品进展') &&
|
||||
!result.revertedText.includes('风险与应对') &&
|
||||
result.toolNames.includes('ppt_edit') &&
|
||||
result.toolNames.includes('ppt_render_slides'),
|
||||
{
|
||||
name: 'workbench-ppt-update-diff-interactions-real',
|
||||
message: '真实 AI 更新 PPT 后应正确展示页面增删改移动、同步缩放拖拽、筛选和回撤。',
|
||||
data: result
|
||||
}
|
||||
)
|
||||
await assertNoWorkbenchFakeSignals(runtime)
|
||||
}
|
||||
})
|
||||
},
|
||||
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS
|
||||
)
|
||||
})
|
||||
@@ -36,7 +36,7 @@ const resolveLiveVisionModelName = (primaryConfig: LiveLlmConfig): string => {
|
||||
if (explicit) return explicit
|
||||
try {
|
||||
if (new URL(primaryConfig.base_url).hostname.endsWith('dashscope.aliyuncs.com')) {
|
||||
return 'qwen3-vl-flash'
|
||||
return 'qwen3.6-plus'
|
||||
}
|
||||
} catch {
|
||||
// 交给主配置校验报告无效 URL。
|
||||
|
||||
@@ -365,7 +365,9 @@ export const createTemporaryWorkbenchWorkspace = async (
|
||||
cleanup: async () => {
|
||||
await rm(rootPath, {
|
||||
recursive: true,
|
||||
force: true
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user