diff --git a/package-lock.json b/package-lock.json
index de2ca20b..88c8b60d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -776,6 +776,191 @@
"resolved": "packages/tui",
"link": true
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
+ "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-x64": "0.1.80",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.80",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.80",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.80"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
+ "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
+ "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
+ "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
+ "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
+ "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
+ "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
+ "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
+ "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
+ "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
+ "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -1927,6 +2112,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/adler-32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+ "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -2119,6 +2313,19 @@
"node": "^18.12.0 || >= 20.9.0"
}
},
+ "node_modules/cfb": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+ "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "crc-32": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -2224,6 +2431,15 @@
"node": ">=6"
}
},
+ "node_modules/codepage": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+ "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2322,6 +2538,24 @@
"node": ">=8"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -2394,6 +2628,15 @@
"node": ">=0.3.1"
}
},
+ "node_modules/docx-preview": {
+ "version": "0.3.7",
+ "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz",
+ "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jszip": ">=3.0.0"
+ }
+ },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -2600,6 +2843,15 @@
"optional": true,
"peer": true
},
+ "node_modules/frac": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+ "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -2803,11 +3055,16 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/ini": {
@@ -2871,6 +3128,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
"node_modules/jiti": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz",
@@ -2903,6 +3166,48 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/jszip/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/jszip/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -2940,6 +3245,15 @@
"katex": "cli.js"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@@ -3458,6 +3772,12 @@
}
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/partial-json": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz",
@@ -3481,6 +3801,18 @@
"node": ">= 14.16"
}
},
+ "node_modules/pdfjs-dist": {
+ "version": "5.4.149",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.149.tgz",
+ "integrity": "sha512-Xe8/1FMJEQPUVSti25AlDpwpUm2QAVmNOpFP0SIahaPIOKBKICaefbzogLdwey3XGGoaP4Lb9wqiw2e9Jqp0LA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.77"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3557,6 +3889,12 @@
"node": ">=10"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
@@ -3711,6 +4049,12 @@
"node": ">=10"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
@@ -3817,6 +4161,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ssf": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+ "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "frac": "~1.1.2"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4214,7 +4570,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
"license": "MIT"
},
"node_modules/uuid": {
@@ -4440,6 +4795,24 @@
"node": ">=8"
}
},
+ "node_modules/wmf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+ "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+ "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -4525,6 +4898,27 @@
}
}
},
+ "node_modules/xlsx": {
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+ "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "cfb": "~1.2.1",
+ "codepage": "~1.15.0",
+ "crc-32": "~1.2.1",
+ "ssf": "~0.11.2",
+ "wmf": "~1.0.1",
+ "word": "~0.3.0"
+ },
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -4994,9 +5388,13 @@
"dependencies": {
"@mariozechner/mini-lit": "^0.1.4",
"@mariozechner/pi-ai": "^0.5.43",
+ "docx-preview": "^0.3.7",
+ "jszip": "^3.10.1",
"lit": "^3.3.1",
"lucide": "^0.544.0",
- "ollama": "^0.6.0"
+ "ollama": "^0.6.0",
+ "pdfjs-dist": "^5.4.149",
+ "xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0-beta.14",
diff --git a/packages/browser-extension/manifest.chrome.json b/packages/browser-extension/manifest.chrome.json
index b13496dd..257f5321 100644
--- a/packages/browser-extension/manifest.chrome.json
+++ b/packages/browser-extension/manifest.chrome.json
@@ -1,6 +1,6 @@
{
"manifest_version": 3,
- "name": "Pi Reader Assistant",
+ "name": "pi-ai",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43",
"action": {
diff --git a/packages/browser-extension/manifest.firefox.json b/packages/browser-extension/manifest.firefox.json
index cf8dbc00..fd0fefe4 100644
--- a/packages/browser-extension/manifest.firefox.json
+++ b/packages/browser-extension/manifest.firefox.json
@@ -1,6 +1,6 @@
{
"manifest_version": 3,
- "name": "Pi Reader Assistant",
+ "name": "pi-ai",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43",
"action": {
@@ -17,7 +17,7 @@
},
"sidebar_action": {
"default_panel": "sidepanel.html",
- "default_title": "Pi Reader Assistant",
+ "default_title": "pi-ai",
"default_icon": {
"16": "icon-16.png",
"48": "icon-48.png"
diff --git a/packages/browser-extension/package.json b/packages/browser-extension/package.json
index 4b0a6cc3..24c716b9 100644
--- a/packages/browser-extension/package.json
+++ b/packages/browser-extension/package.json
@@ -17,9 +17,13 @@
"dependencies": {
"@mariozechner/mini-lit": "^0.1.4",
"@mariozechner/pi-ai": "^0.5.43",
+ "docx-preview": "^0.3.7",
+ "jszip": "^3.10.1",
"lit": "^3.3.1",
"lucide": "^0.544.0",
- "ollama": "^0.6.0"
+ "ollama": "^0.6.0",
+ "pdfjs-dist": "^5.4.149",
+ "xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0-beta.14",
diff --git a/packages/browser-extension/scripts/build.mjs b/packages/browser-extension/scripts/build.mjs
index 8bc273f7..29fad24f 100644
--- a/packages/browser-extension/scripts/build.mjs
+++ b/packages/browser-extension/scripts/build.mjs
@@ -1,5 +1,5 @@
import { build, context } from "esbuild";
-import { copyFileSync, mkdirSync, rmSync } from "node:fs";
+import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -63,6 +63,16 @@ const copyStatic = () => {
copyFileSync(source, destination);
}
+ // Copy PDF.js worker from node_modules (check both local and monorepo root)
+ let pdfWorkerSource = join(packageRoot, "node_modules/pdfjs-dist/build/pdf.worker.min.mjs");
+ if (!existsSync(pdfWorkerSource)) {
+ pdfWorkerSource = join(packageRoot, "../../node_modules/pdfjs-dist/build/pdf.worker.min.mjs");
+ }
+ const pdfWorkerDestDir = join(outDir, "pdfjs-dist/build");
+ mkdirSync(pdfWorkerDestDir, { recursive: true });
+ const pdfWorkerDest = join(pdfWorkerDestDir, "pdf.worker.min.mjs");
+ copyFileSync(pdfWorkerSource, pdfWorkerDest);
+
console.log(`Built for ${targetBrowser} in ${outDir}`);
};
diff --git a/packages/browser-extension/src/AttachmentOverlay.ts b/packages/browser-extension/src/AttachmentOverlay.ts
new file mode 100644
index 00000000..35c1c8a7
--- /dev/null
+++ b/packages/browser-extension/src/AttachmentOverlay.ts
@@ -0,0 +1,635 @@
+import { Button, html, icon } from "@mariozechner/mini-lit";
+import { renderAsync } from "docx-preview";
+import { LitElement } from "lit";
+import { state } from "lit/decorators.js";
+import { Download, X } from "lucide";
+import * as pdfjsLib from "pdfjs-dist";
+import * as XLSX from "xlsx";
+import { i18n } from "./utils/i18n.js";
+import "./ModeToggle.js";
+import type { Attachment } from "./utils/attachment-utils.js";
+
+type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
+
+export class AttachmentOverlay extends LitElement {
+ @state() private attachment?: Attachment;
+ @state() private showExtractedText = false;
+ @state() private error: string | null = null;
+
+ // Track current loading task to cancel if needed
+ private currentLoadingTask: any = null;
+ private onCloseCallback?: () => void;
+ private boundHandleKeyDown?: (e: KeyboardEvent) => void;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ static open(attachment: Attachment, onClose?: () => void) {
+ const overlay = new AttachmentOverlay();
+ overlay.attachment = attachment;
+ overlay.onCloseCallback = onClose;
+ document.body.appendChild(overlay);
+ overlay.setupEventListeners();
+ }
+
+ private setupEventListeners() {
+ this.boundHandleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ this.close();
+ }
+ };
+ window.addEventListener("keydown", this.boundHandleKeyDown);
+ }
+
+ private close() {
+ this.cleanup();
+ if (this.boundHandleKeyDown) {
+ window.removeEventListener("keydown", this.boundHandleKeyDown);
+ }
+ this.onCloseCallback?.();
+ this.remove();
+ }
+
+ private getFileType(): FileType {
+ if (!this.attachment) return "text";
+
+ if (this.attachment.type === "image") return "image";
+ if (this.attachment.mimeType === "application/pdf") return "pdf";
+ if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx";
+ if (
+ this.attachment.mimeType?.includes("presentationml") ||
+ this.attachment.fileName.toLowerCase().endsWith(".pptx")
+ )
+ return "pptx";
+ if (
+ this.attachment.mimeType?.includes("spreadsheetml") ||
+ this.attachment.mimeType?.includes("ms-excel") ||
+ this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
+ this.attachment.fileName.toLowerCase().endsWith(".xls")
+ )
+ return "excel";
+
+ return "text";
+ }
+
+ private getFileTypeLabel(): string {
+ const type = this.getFileType();
+ switch (type) {
+ case "pdf":
+ return i18n("PDF");
+ case "docx":
+ return i18n("Document");
+ case "pptx":
+ return i18n("Presentation");
+ case "excel":
+ return i18n("Spreadsheet");
+ default:
+ return "";
+ }
+ }
+
+ private handleBackdropClick = () => {
+ this.close();
+ };
+
+ private handleDownload = () => {
+ if (!this.attachment) return;
+
+ // Create a blob from the base64 content
+ const byteCharacters = atob(this.attachment.content);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ const blob = new Blob([byteArray], { type: this.attachment.mimeType });
+
+ // Create download link
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = this.attachment.fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ private cleanup() {
+ this.showExtractedText = false;
+ this.error = null;
+ // Cancel any loading PDF task when closing
+ if (this.currentLoadingTask) {
+ this.currentLoadingTask.destroy();
+ this.currentLoadingTask = null;
+ }
+ }
+
+ override render() {
+ if (!this.attachment) return html``;
+
+ return html`
+
+
+
+
e.stopPropagation()}>
+
+
+ ${this.attachment.fileName}
+
+
+ ${this.renderToggle()}
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ onClick: this.handleDownload,
+ children: icon(Download, "sm"),
+ className: "h-8 w-8",
+ })}
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ onClick: () => this.close(),
+ children: icon(X, "sm"),
+ className: "h-8 w-8",
+ })}
+
+
+
+
+
+
e.stopPropagation()}>
+ ${this.renderContent()}
+
+
+ `;
+ }
+
+ private renderToggle() {
+ if (!this.attachment) return html``;
+
+ const fileType = this.getFileType();
+ const hasExtractedText = !!this.attachment.extractedText;
+ const showToggle = fileType !== "image" && fileType !== "text" && fileType !== "pptx" && hasExtractedText;
+
+ if (!showToggle) return html``;
+
+ const fileTypeLabel = this.getFileTypeLabel();
+
+ return html`
+ ) => {
+ e.stopPropagation();
+ this.showExtractedText = e.detail.index === 1;
+ this.error = null;
+ }}
+ >
+ `;
+ }
+
+ private renderContent() {
+ if (!this.attachment) return html``;
+
+ // Error state
+ if (this.error) {
+ return html`
+
+
${i18n("Error loading file")}
+
${this.error}
+
+ `;
+ }
+
+ // Content based on file type
+ return this.renderFileContent();
+ }
+
+ private renderFileContent() {
+ if (!this.attachment) return html``;
+
+ const fileType = this.getFileType();
+
+ // Show extracted text if toggled
+ if (this.showExtractedText && fileType !== "image") {
+ return html`
+
+
${
+ this.attachment.extractedText || i18n("No text content available")
+ }
+
+ `;
+ }
+
+ // Render based on file type
+ switch (fileType) {
+ case "image": {
+ const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`;
+ return html`
+
+ `;
+ }
+
+ case "pdf":
+ return html`
+
+ `;
+
+ case "docx":
+ return html`
+
+ `;
+
+ case "excel":
+ return html` `;
+
+ case "pptx":
+ return html`
+
+ `;
+
+ default:
+ return html`
+
+
${
+ this.attachment.extractedText || i18n("No content available")
+ }
+
+ `;
+ }
+ }
+
+ override async updated(changedProperties: Map) {
+ super.updated(changedProperties);
+
+ // Only process if we need to render the actual file (not extracted text)
+ if (
+ (changedProperties.has("attachment") || changedProperties.has("showExtractedText")) &&
+ this.attachment &&
+ !this.showExtractedText &&
+ !this.error
+ ) {
+ const fileType = this.getFileType();
+
+ switch (fileType) {
+ case "pdf":
+ await this.renderPdf();
+ break;
+ case "docx":
+ await this.renderDocx();
+ break;
+ case "excel":
+ await this.renderExcel();
+ break;
+ case "pptx":
+ await this.renderExtractedText();
+ break;
+ }
+ }
+ }
+
+ private async renderPdf() {
+ const container = this.querySelector("#pdf-container");
+ if (!container || !this.attachment) return;
+
+ let pdf: any = null;
+
+ try {
+ // Convert base64 to ArrayBuffer
+ const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
+
+ // Cancel any existing loading task
+ if (this.currentLoadingTask) {
+ this.currentLoadingTask.destroy();
+ }
+
+ // Load the PDF
+ this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
+ pdf = await this.currentLoadingTask.promise;
+ this.currentLoadingTask = null;
+
+ // Clear container and add wrapper
+ container.innerHTML = "";
+ const wrapper = document.createElement("div");
+ wrapper.className = "";
+ container.appendChild(wrapper);
+
+ // Render all pages
+ for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
+ const page = await pdf.getPage(pageNum);
+
+ // Create a container for each page
+ const pageContainer = document.createElement("div");
+ pageContainer.className = "mb-4 last:mb-0";
+
+ // Create canvas for this page
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d");
+
+ // Set scale for reasonable resolution
+ const viewport = page.getViewport({ scale: 1.5 });
+ canvas.height = viewport.height;
+ canvas.width = viewport.width;
+
+ // Style the canvas
+ canvas.className = "w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
+
+ // Fill white background for proper PDF rendering
+ if (context) {
+ context.fillStyle = "white";
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ }
+
+ // Render page
+ await page.render({
+ canvasContext: context!,
+ viewport: viewport,
+ canvas: canvas,
+ }).promise;
+
+ pageContainer.appendChild(canvas);
+
+ // Add page separator for multi-page documents
+ if (pageNum < pdf.numPages) {
+ const separator = document.createElement("div");
+ separator.className = "h-px bg-border my-4";
+ pageContainer.appendChild(separator);
+ }
+
+ wrapper.appendChild(pageContainer);
+ }
+ } catch (error: any) {
+ console.error("Error rendering PDF:", error);
+ this.error = error?.message || i18n("Failed to load PDF");
+ } finally {
+ if (pdf) {
+ pdf.destroy();
+ }
+ }
+ }
+
+ private async renderDocx() {
+ const container = this.querySelector("#docx-container");
+ if (!container || !this.attachment) return;
+
+ try {
+ // Convert base64 to ArrayBuffer
+ const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
+
+ // Clear container first
+ container.innerHTML = "";
+
+ // Create a wrapper div for the document
+ const wrapper = document.createElement("div");
+ wrapper.className = "docx-wrapper-custom";
+ container.appendChild(wrapper);
+
+ // Render the DOCX file into the wrapper
+ await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
+ className: "docx",
+ inWrapper: true,
+ ignoreWidth: true, // Let it be responsive
+ ignoreHeight: false,
+ ignoreFonts: false,
+ breakPages: true,
+ ignoreLastRenderedPageBreak: true,
+ experimental: false,
+ trimXmlDeclaration: true,
+ useBase64URL: false,
+ renderHeaders: true,
+ renderFooters: true,
+ renderFootnotes: true,
+ renderEndnotes: true,
+ });
+
+ // Apply custom styles to match theme and fix sizing
+ const style = document.createElement("style");
+ style.textContent = `
+ #docx-container {
+ padding: 0;
+ }
+
+ #docx-container .docx-wrapper-custom {
+ max-width: 100%;
+ overflow-x: auto;
+ }
+
+ #docx-container .docx-wrapper {
+ max-width: 100% !important;
+ margin: 0 !important;
+ background: transparent !important;
+ padding: 0em !important;
+ }
+
+ #docx-container .docx-wrapper > section.docx {
+ box-shadow: none !important;
+ border: none !important;
+ border-radius: 0 !important;
+ margin: 0 !important;
+ padding: 2em !important;
+ background: white !important;
+ color: black !important;
+ max-width: 100% !important;
+ width: 100% !important;
+ min-width: 0 !important;
+ overflow-x: auto !important;
+ }
+
+ /* Fix tables and wide content */
+ #docx-container table {
+ max-width: 100% !important;
+ width: auto !important;
+ overflow-x: auto !important;
+ display: block !important;
+ }
+
+ #docx-container img {
+ max-width: 100% !important;
+ height: auto !important;
+ }
+
+ /* Fix paragraphs and text */
+ #docx-container p,
+ #docx-container span,
+ #docx-container div {
+ max-width: 100% !important;
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ }
+
+ /* Hide page breaks in web view */
+ #docx-container .docx-page-break {
+ display: none !important;
+ }
+ `;
+ container.appendChild(style);
+ } catch (error: any) {
+ console.error("Error rendering DOCX:", error);
+ this.error = error?.message || i18n("Failed to load document");
+ }
+ }
+
+ private async renderExcel() {
+ const container = this.querySelector("#excel-container");
+ if (!container || !this.attachment) return;
+
+ try {
+ // Convert base64 to ArrayBuffer
+ const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
+
+ // Read the workbook
+ const workbook = XLSX.read(arrayBuffer, { type: "array" });
+
+ // Clear container
+ container.innerHTML = "";
+ const wrapper = document.createElement("div");
+ wrapper.className = "overflow-auto h-full flex flex-col";
+ container.appendChild(wrapper);
+
+ // Create tabs for multiple sheets
+ if (workbook.SheetNames.length > 1) {
+ const tabContainer = document.createElement("div");
+ tabContainer.className = "flex gap-2 mb-4 border-b border-border sticky top-0 bg-card z-10";
+
+ const sheetContents: HTMLElement[] = [];
+
+ workbook.SheetNames.forEach((sheetName, index) => {
+ // Create tab button
+ const tab = document.createElement("button");
+ tab.textContent = sheetName;
+ tab.className =
+ index === 0
+ ? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
+ : "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
+
+ // Create sheet content
+ const sheetDiv = document.createElement("div");
+ sheetDiv.style.display = index === 0 ? "flex" : "none";
+ sheetDiv.className = "flex-1 overflow-auto";
+ sheetDiv.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
+ sheetContents.push(sheetDiv);
+
+ // Tab click handler
+ tab.onclick = () => {
+ // Update tab styles
+ tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
+ if (btnIndex === index) {
+ btn.className = "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
+ } else {
+ btn.className =
+ "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
+ }
+ });
+ // Show/hide sheets
+ sheetContents.forEach((content, contentIndex) => {
+ content.style.display = contentIndex === index ? "flex" : "none";
+ });
+ };
+
+ tabContainer.appendChild(tab);
+ });
+
+ wrapper.appendChild(tabContainer);
+ sheetContents.forEach((content) => {
+ wrapper.appendChild(content);
+ });
+ } else {
+ // Single sheet
+ const sheetName = workbook.SheetNames[0];
+ wrapper.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
+ }
+ } catch (error: any) {
+ console.error("Error rendering Excel:", error);
+ this.error = error?.message || i18n("Failed to load spreadsheet");
+ }
+ }
+
+ private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
+ const sheetDiv = document.createElement("div");
+
+ // Generate HTML table
+ const htmlTable = XLSX.utils.sheet_to_html(worksheet, { id: `sheet-${sheetName}` });
+ const tempDiv = document.createElement("div");
+ tempDiv.innerHTML = htmlTable;
+
+ // Find and style the table
+ const table = tempDiv.querySelector("table");
+ if (table) {
+ table.className = "w-full border-collapse text-foreground";
+
+ // Style all cells
+ table.querySelectorAll("td, th").forEach((cell) => {
+ const cellEl = cell as HTMLElement;
+ cellEl.className = "border border-border px-3 py-2 text-sm text-left";
+ });
+
+ // Style header row
+ const headerCells = table.querySelectorAll("thead th, tr:first-child td");
+ if (headerCells.length > 0) {
+ headerCells.forEach((th) => {
+ const thEl = th as HTMLElement;
+ thEl.className =
+ "border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
+ });
+ }
+
+ // Alternate row colors
+ table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
+ const rowEl = row as HTMLElement;
+ rowEl.className = "bg-muted/30";
+ });
+
+ sheetDiv.appendChild(table);
+ }
+
+ return sheetDiv;
+ }
+
+ private base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binaryString = atob(base64);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+
+ private async renderExtractedText() {
+ const container = this.querySelector("#pptx-container");
+ if (!container || !this.attachment) return;
+
+ try {
+ // Display the extracted text content
+ container.innerHTML = "";
+ const wrapper = document.createElement("div");
+ wrapper.className = "p-6 overflow-auto";
+
+ // Create a pre element to preserve formatting
+ const pre = document.createElement("pre");
+ pre.className = "whitespace-pre-wrap text-sm text-foreground font-mono";
+ pre.textContent = this.attachment.extractedText || i18n("No text content available");
+
+ wrapper.appendChild(pre);
+ container.appendChild(wrapper);
+ } catch (error: any) {
+ console.error("Error rendering extracted text:", error);
+ this.error = error?.message || i18n("Failed to display text content");
+ }
+ }
+}
+
+// Register the custom element only once
+if (!customElements.get("attachment-overlay")) {
+ customElements.define("attachment-overlay", AttachmentOverlay);
+}
diff --git a/packages/browser-extension/src/AttachmentTile.ts b/packages/browser-extension/src/AttachmentTile.ts
new file mode 100644
index 00000000..e9c1b572
--- /dev/null
+++ b/packages/browser-extension/src/AttachmentTile.ts
@@ -0,0 +1,112 @@
+import { html, icon } from "@mariozechner/mini-lit";
+import { LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { FileSpreadsheet, FileText, X } from "lucide";
+import { AttachmentOverlay } from "./AttachmentOverlay.js";
+import type { Attachment } from "./utils/attachment-utils.js";
+import { i18n } from "./utils/i18n.js";
+
+@customElement("attachment-tile")
+export class AttachmentTile extends LitElement {
+ @property({ type: Object }) attachment!: Attachment;
+ @property({ type: Boolean }) showDelete = false;
+ @property() onDelete?: () => void;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ this.classList.add("max-h-16");
+ }
+
+ private handleClick = () => {
+ AttachmentOverlay.open(this.attachment);
+ };
+
+ override render() {
+ const hasPreview = !!this.attachment.preview;
+ const isImage = this.attachment.type === "image";
+ const isPdf = this.attachment.mimeType === "application/pdf";
+ const isDocx =
+ this.attachment.mimeType?.includes("wordprocessingml") ||
+ this.attachment.fileName.toLowerCase().endsWith(".docx");
+ const isPptx =
+ this.attachment.mimeType?.includes("presentationml") ||
+ this.attachment.fileName.toLowerCase().endsWith(".pptx");
+ const isExcel =
+ this.attachment.mimeType?.includes("spreadsheetml") ||
+ this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
+ this.attachment.fileName.toLowerCase().endsWith(".xls");
+
+ // Choose the appropriate icon
+ const getDocumentIcon = () => {
+ if (isExcel) return icon(FileSpreadsheet, "md");
+ return icon(FileText, "md");
+ };
+
+ return html`
+
+ ${
+ hasPreview
+ ? html`
+
+

+ ${
+ isPdf
+ ? html`
+
+
+ `
+ : ""
+ }
+
+ `
+ : html`
+
+
+ ${getDocumentIcon()}
+
+ ${
+ this.attachment.fileName.length > 10
+ ? this.attachment.fileName.substring(0, 8) + "..."
+ : this.attachment.fileName
+ }
+
+
+ `
+ }
+ ${
+ this.showDelete
+ ? html`
+
+ `
+ : ""
+ }
+
+ `;
+ }
+}
diff --git a/packages/browser-extension/src/ChatPanel.ts b/packages/browser-extension/src/ChatPanel.ts
index ed4b7d0f..c985ceaf 100644
--- a/packages/browser-extension/src/ChatPanel.ts
+++ b/packages/browser-extension/src/ChatPanel.ts
@@ -1,13 +1,68 @@
-import { html, LitElement } from "lit";
-import { customElement } from "lit/decorators.js";
+import { html } from "@mariozechner/mini-lit";
+import type { Model } from "@mariozechner/pi-ai";
+import { getModel } from "@mariozechner/pi-ai";
+import { LitElement } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { ModelSelector } from "./dialogs/ModelSelector.js";
+import "./MessageEditor.js";
+import type { Attachment } from "./utils/attachment-utils.js";
@customElement("pi-chat-panel")
export class ChatPanel extends LitElement {
+ @state() currentModel: Model | null = null;
+ @state() messageText = "";
+ @state() attachments: Attachment[] = [];
+
createRenderRoot() {
return this;
}
+ override async connectedCallback() {
+ super.connectedCallback();
+ // Set default model
+ this.currentModel = getModel("anthropic", "claude-3-5-haiku-20241022");
+ }
+
+ private handleSend = (text: string, attachments: Attachment[]) => {
+ // For now just alert and clear
+ alert(`Message: ${text}\nAttachments: ${attachments.length}`);
+ this.messageText = "";
+ this.attachments = [];
+ };
+
+ private handleModelSelect = () => {
+ ModelSelector.open(this.currentModel, (model) => {
+ this.currentModel = model;
+ });
+ };
+
render() {
- return html`Hello world
`;
+ return html`
+
+
+
+
+
+
+
+
+ {
+ this.messageText = value;
+ }}
+ .onSend=${this.handleSend}
+ .onModelSelect=${this.handleModelSelect}
+ .onFilesChange=${(files: Attachment[]) => {
+ this.attachments = files;
+ }}
+ >
+
+
+ `;
}
}
diff --git a/packages/browser-extension/src/MessageEditor.ts b/packages/browser-extension/src/MessageEditor.ts
new file mode 100644
index 00000000..362b9673
--- /dev/null
+++ b/packages/browser-extension/src/MessageEditor.ts
@@ -0,0 +1,267 @@
+import { Button, html, icon } from "@mariozechner/mini-lit";
+import type { Model } from "@mariozechner/pi-ai";
+import { LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { createRef, ref } from "lit/directives/ref.js";
+import { Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
+import "./AttachmentTile.js";
+import { type Attachment, loadAttachment } from "./utils/attachment-utils.js";
+import { i18n } from "./utils/i18n.js";
+
+@customElement("message-editor")
+export class MessageEditor extends LitElement {
+ private _value = "";
+ private textareaRef = createRef();
+
+ @property()
+ get value() {
+ return this._value;
+ }
+
+ set value(val: string) {
+ const oldValue = this._value;
+ this._value = val;
+ this.requestUpdate("value", oldValue);
+ this.updateComplete.then(() => {
+ const textarea = this.textareaRef.value;
+ if (textarea) {
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ }
+ });
+ }
+
+ @property() isStreaming = false;
+ @property() currentModel?: Model;
+ @property() showAttachmentButton = true;
+ @property() showModelSelector = true;
+ @property() showThinking = false; // Disabled for now
+ @property() onInput?: (value: string) => void;
+ @property() onSend?: (input: string, attachments: Attachment[]) => void;
+ @property() onAbort?: () => void;
+ @property() onModelSelect?: () => void;
+ @property() onFilesChange?: (files: Attachment[]) => void;
+ @property() attachments: Attachment[] = [];
+ @property() maxFiles = 10;
+ @property() maxFileSize = 20 * 1024 * 1024; // 20MB
+ @property() acceptedTypes =
+ "image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
+
+ @state() processingFiles = false;
+ private fileInputRef = createRef();
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ private handleTextareaInput = (e: Event) => {
+ const textarea = e.target as HTMLTextAreaElement;
+ this.value = textarea.value;
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ this.onInput?.(this.value);
+ };
+
+ private handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) {
+ this.handleSend();
+ }
+ } else if (e.key === "Escape" && this.isStreaming) {
+ e.preventDefault();
+ this.onAbort?.();
+ }
+ };
+
+ private handleSend = () => {
+ this.onSend?.(this.value, this.attachments);
+ };
+
+ private handleAttachmentClick = () => {
+ this.fileInputRef.value?.click();
+ };
+
+ private async handleFilesSelected(e: Event) {
+ const input = e.target as HTMLInputElement;
+ const files = Array.from(input.files || []);
+ if (files.length === 0) return;
+
+ if (files.length + this.attachments.length > this.maxFiles) {
+ alert(`Maximum ${this.maxFiles} files allowed`);
+ input.value = "";
+ return;
+ }
+
+ this.processingFiles = true;
+ const newAttachments: Attachment[] = [];
+
+ for (const file of files) {
+ try {
+ if (file.size > this.maxFileSize) {
+ alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
+ continue;
+ }
+
+ const attachment = await loadAttachment(file);
+ newAttachments.push(attachment);
+ } catch (error) {
+ console.error(`Error processing ${file.name}:`, error);
+ alert(`Failed to process ${file.name}: ${String(error)}`);
+ }
+ }
+
+ this.attachments = [...this.attachments, ...newAttachments];
+ this.onFilesChange?.(this.attachments);
+ this.processingFiles = false;
+ input.value = ""; // Reset input
+ }
+
+ private removeFile(fileId: string) {
+ this.attachments = this.attachments.filter((f) => f.id !== fileId);
+ this.onFilesChange?.(this.attachments);
+ }
+
+ private adjustTextareaHeight() {
+ const textarea = this.textareaRef.value;
+ if (textarea) {
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ }
+ }
+
+ override firstUpdated() {
+ const textarea = this.textareaRef.value;
+ if (textarea) {
+ // Set initial height properly
+ this.adjustTextareaHeight();
+ textarea.focus();
+ }
+ }
+
+ override updated() {
+ // Adjust height when component updates
+ this.adjustTextareaHeight();
+ }
+
+ override render() {
+ return html`
+
+
+ ${
+ this.attachments.length > 0
+ ? html`
+
+ ${this.attachments.map(
+ (attachment) => html`
+
this.removeFile(attachment.id)}
+ >
+ `,
+ )}
+
+ `
+ : ""
+ }
+
+
+
+
+
+
+
+
+
+
+ ${
+ this.showAttachmentButton
+ ? this.processingFiles
+ ? html`
+
+ ${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
+
+ `
+ : html`
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ className: "h-8 w-8",
+ onClick: this.handleAttachmentClick,
+ children: icon(Paperclip, "sm"),
+ })}
+ `
+ : ""
+ }
+
+
+
+
+ ${
+ this.showModelSelector && this.currentModel
+ ? html`
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ onClick: () => {
+ // Focus textarea before opening model selector so focus returns there
+ this.textareaRef.value?.focus();
+ // Wait for next frame to ensure focus takes effect before dialog captures it
+ requestAnimationFrame(() => {
+ this.onModelSelect?.();
+ });
+ },
+ children: html`
+ ${icon(Sparkles, "sm")}
+
${this.currentModel.id}
+ `,
+ className: "h-8 text-xs truncate",
+ })}
+ `
+ : ""
+ }
+ ${
+ this.isStreaming
+ ? html`
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ onClick: this.onAbort,
+ children: icon(Square, "sm"),
+ className: "h-8 w-8",
+ })}
+ `
+ : html`
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ onClick: this.handleSend,
+ disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles,
+ children: html`
${icon(Send, "sm")}
`,
+ className: "h-8 w-8",
+ })}
+ `
+ }
+
+
+
+ `;
+ }
+}
diff --git a/packages/browser-extension/src/ModeToggle.ts b/packages/browser-extension/src/ModeToggle.ts
new file mode 100644
index 00000000..043bccde
--- /dev/null
+++ b/packages/browser-extension/src/ModeToggle.ts
@@ -0,0 +1,53 @@
+import { html } from "@mariozechner/mini-lit";
+import { LitElement } from "lit";
+import { property } from "lit/decorators.js";
+
+export class ModeToggle extends LitElement {
+ @property({ type: Array }) modes: string[] = ["Mode 1", "Mode 2"];
+ @property({ type: Number }) selectedIndex = 0;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ private setMode(index: number) {
+ if (this.selectedIndex !== index && index >= 0 && index < this.modes.length) {
+ this.selectedIndex = index;
+ this.dispatchEvent(
+ new CustomEvent<{ index: number; mode: string }>("mode-change", {
+ detail: { index, mode: this.modes[index] },
+ bubbles: true,
+ }),
+ );
+ }
+ }
+
+ override render() {
+ if (this.modes.length < 2) return html``;
+
+ return html`
+
+ ${this.modes.map(
+ (mode, index) => html`
+
+ `,
+ )}
+
+ `;
+ }
+}
+
+// Register the custom element only once
+if (!customElements.get("mode-toggle")) {
+ customElements.define("mode-toggle", ModeToggle);
+}
diff --git a/packages/browser-extension/src/dialogs/ApiKeysDialog.ts b/packages/browser-extension/src/dialogs/ApiKeysDialog.ts
new file mode 100644
index 00000000..a54eec4e
--- /dev/null
+++ b/packages/browser-extension/src/dialogs/ApiKeysDialog.ts
@@ -0,0 +1,273 @@
+import { Alert, Badge, Button, DialogHeader, html, type TemplateResult } from "@mariozechner/mini-lit";
+import { type Context, complete, getModel, getProviders } from "@mariozechner/pi-ai";
+import type { PropertyValues } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { Input } from "../Input.js";
+import { keyStore } from "../state/KeyStore.js";
+import { i18n } from "../utils/i18n.js";
+import { DialogBase } from "./DialogBase.js";
+
+// Test models for each provider - known to be reliable and cheap
+const TEST_MODELS: Record = {
+ anthropic: "claude-3-5-haiku-20241022",
+ openai: "gpt-4o-mini",
+ google: "gemini-2.0-flash-exp",
+ groq: "llama-3.3-70b-versatile",
+ openrouter: "openai/gpt-4o-mini",
+ cerebras: "llama3.1-8b",
+ xai: "grok-2-1212",
+ zai: "glm-4-plus",
+};
+
+@customElement("api-keys-dialog")
+export class ApiKeysDialog extends DialogBase {
+ @state() apiKeys: Record = {}; // provider -> configured
+ @state() apiKeyInputs: Record = {};
+ @state() testResults: Record = {};
+ @state() savingProvider = "";
+ @state() testingProvider = "";
+ @state() error = "";
+
+ protected override modalWidth = "min(600px, 90vw)";
+ protected override modalHeight = "min(600px, 80vh)";
+
+ static async open() {
+ const dialog = new ApiKeysDialog();
+ dialog.open();
+ await dialog.loadKeys();
+ }
+
+ override async firstUpdated(changedProperties: PropertyValues): Promise {
+ super.firstUpdated(changedProperties);
+ await this.loadKeys();
+ }
+
+ private async loadKeys() {
+ this.apiKeys = await keyStore.getAllKeys();
+ }
+
+ private async testApiKey(provider: string, apiKey: string): Promise {
+ try {
+ // Get the test model for this provider
+ const modelId = TEST_MODELS[provider];
+ if (!modelId) {
+ this.error = `No test model configured for ${provider}`;
+ return false;
+ }
+
+ const model = getModel(provider as any, modelId);
+ if (!model) {
+ this.error = `Test model ${modelId} not found for ${provider}`;
+ return false;
+ }
+
+ // Simple test prompt
+ const context: Context = {
+ messages: [{ role: "user", content: "Reply with exactly: test successful" }],
+ };
+ const response = await complete(model, context, {
+ apiKey,
+ maxTokens: 10, // Keep it minimal for testing
+ } as any);
+
+ // Check if response contains expected text
+ const text = response.content
+ .filter((b) => b.type === "text")
+ .map((b) => b.text)
+ .join("");
+
+ return text.toLowerCase().includes("test successful");
+ } catch (error) {
+ console.error(`API key test failed for ${provider}:`, error);
+ return false;
+ }
+ }
+
+ private async saveKey(provider: string) {
+ const key = this.apiKeyInputs[provider];
+ if (!key) return;
+
+ this.savingProvider = provider;
+ this.testResults[provider] = "testing";
+ this.error = "";
+
+ try {
+ // Test the key first
+ const isValid = await this.testApiKey(provider, key);
+
+ if (isValid) {
+ await keyStore.setKey(provider, key);
+ this.apiKeyInputs[provider] = ""; // Clear input
+ await this.loadKeys();
+ this.testResults[provider] = "success";
+ } else {
+ this.testResults[provider] = "error";
+ this.error = `Invalid API key for ${provider}`;
+ }
+ } catch (err: any) {
+ this.testResults[provider] = "error";
+ this.error = `Failed to save key for ${provider}: ${err.message}`;
+ } finally {
+ this.savingProvider = "";
+
+ // Clear test result after 3 seconds
+ setTimeout(() => {
+ delete this.testResults[provider];
+ this.requestUpdate();
+ }, 3000);
+ }
+ }
+
+ private async testExistingKey(provider: string) {
+ this.testingProvider = provider;
+ this.testResults[provider] = "testing";
+ this.error = "";
+
+ try {
+ const apiKey = await keyStore.getKey(provider);
+ if (!apiKey) {
+ this.testResults[provider] = "error";
+ this.error = `No API key found for ${provider}`;
+ return;
+ }
+
+ const isValid = await this.testApiKey(provider, apiKey);
+
+ if (isValid) {
+ this.testResults[provider] = "success";
+ } else {
+ this.testResults[provider] = "error";
+ this.error = `API key for ${provider} is no longer valid`;
+ }
+ } catch (err: any) {
+ this.testResults[provider] = "error";
+ this.error = `Test failed for ${provider}: ${err.message}`;
+ } finally {
+ this.testingProvider = "";
+
+ // Clear test result after 3 seconds
+ setTimeout(() => {
+ delete this.testResults[provider];
+ this.requestUpdate();
+ }, 3000);
+ }
+ }
+
+ private async removeKey(provider: string) {
+ if (!confirm(`Remove API key for ${provider}?`)) return;
+
+ await keyStore.removeKey(provider);
+ this.apiKeyInputs[provider] = "";
+ await this.loadKeys();
+ }
+
+ protected override renderContent(): TemplateResult {
+ const providers = getProviders();
+
+ return html`
+
+
+
+ ${DialogHeader({ title: i18n("API Keys Configuration") })}
+
+ ${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
+
+
+
+
+ ${
+ this.error
+ ? html`
+
${Alert(this.error, "destructive")}
+ `
+ : ""
+ }
+
+
+
+
+ ${providers.map(
+ (provider) => html`
+
+
+ ${provider}
+ ${
+ this.apiKeys[provider]
+ ? Badge({ children: i18n("Configured"), variant: "default" })
+ : Badge({ children: i18n("Not configured"), variant: "secondary" })
+ }
+ ${
+ this.testResults[provider] === "success"
+ ? Badge({ children: i18n("✓ Valid"), variant: "default" })
+ : this.testResults[provider] === "error"
+ ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" })
+ : this.testResults[provider] === "testing"
+ ? Badge({ children: i18n("Testing..."), variant: "secondary" })
+ : ""
+ }
+
+
+
+ ${Input({
+ type: "password",
+ placeholder: this.apiKeys[provider] ? i18n("Update API key") : i18n("Enter API key"),
+ value: this.apiKeyInputs[provider] || "",
+ onInput: (e: Event) => {
+ this.apiKeyInputs[provider] = (e.target as HTMLInputElement).value;
+ this.requestUpdate();
+ },
+ className: "flex-1",
+ })}
+
+ ${Button({
+ onClick: () => this.saveKey(provider),
+ variant: "default",
+ size: "sm",
+ disabled: !this.apiKeyInputs[provider] || this.savingProvider === provider,
+ loading: this.savingProvider === provider,
+ children:
+ this.savingProvider === provider
+ ? i18n("Testing...")
+ : this.apiKeys[provider]
+ ? i18n("Update")
+ : i18n("Save"),
+ })}
+
+ ${
+ this.apiKeys[provider]
+ ? html`
+ ${Button({
+ onClick: () => this.testExistingKey(provider),
+ variant: "outline",
+ size: "sm",
+ loading: this.testingProvider === provider,
+ disabled: this.testingProvider !== "" && this.testingProvider !== provider,
+ children:
+ this.testingProvider === provider ? i18n("Testing...") : i18n("Test"),
+ })}
+ ${Button({
+ onClick: () => this.removeKey(provider),
+ variant: "ghost",
+ size: "sm",
+ children: i18n("Remove"),
+ })}
+ `
+ : ""
+ }
+
+
+ `,
+ )}
+
+
+
+
+
+
+ ${i18n("API keys are required to use AI models. Get your keys from the provider's website.")}
+
+
+
+ `;
+ }
+}
diff --git a/packages/browser-extension/src/sidepanel.html b/packages/browser-extension/src/sidepanel.html
index 637fb1c6..6a370d99 100644
--- a/packages/browser-extension/src/sidepanel.html
+++ b/packages/browser-extension/src/sidepanel.html
@@ -2,7 +2,7 @@
- Pi Reader Assistant
+ pi-ai
diff --git a/packages/browser-extension/src/sidepanel.ts b/packages/browser-extension/src/sidepanel.ts
index 3bb4f096..7b81503c 100644
--- a/packages/browser-extension/src/sidepanel.ts
+++ b/packages/browser-extension/src/sidepanel.ts
@@ -3,9 +3,9 @@ import "./ChatPanel.js";
import "./live-reload.js";
import { customElement } from "lit/decorators.js";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
-import { Button, Input, icon } from "@mariozechner/mini-lit";
+import { Button, icon } from "@mariozechner/mini-lit";
import { Settings } from "lucide";
-import { ModelSelector } from "./dialogs/ModelSelector.js";
+import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
async function getDom() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
@@ -32,16 +32,14 @@ export class Header extends LitElement {
render() {
return html`
- pi-ai webby
+ pi-ai
${Button({
variant: "ghost",
size: "icon",
children: html`${icon(Settings, "sm")}`,
onClick: async () => {
- ModelSelector.open(null, (model) => {
- console.log("Selected model:", model);
- });
+ ApiKeysDialog.open();
},
})}
diff --git a/packages/browser-extension/src/state/KeyStore.ts b/packages/browser-extension/src/state/KeyStore.ts
new file mode 100644
index 00000000..3b20cec7
--- /dev/null
+++ b/packages/browser-extension/src/state/KeyStore.ts
@@ -0,0 +1,50 @@
+import { getProviders } from "@mariozechner/pi-ai";
+
+/**
+ * Interface for API key storage
+ */
+export interface KeyStore {
+ getKey(provider: string): Promise;
+ setKey(provider: string, key: string): Promise;
+ removeKey(provider: string): Promise;
+ getAllKeys(): Promise>; // provider -> isConfigured
+}
+
+/**
+ * Chrome storage implementation of KeyStore
+ */
+class ChromeKeyStore implements KeyStore {
+ private readonly prefix = "apiKey_";
+
+ async getKey(provider: string): Promise {
+ const key = `${this.prefix}${provider}`;
+ const result = await chrome.storage.local.get(key);
+ return result[key] || null;
+ }
+
+ async setKey(provider: string, key: string): Promise {
+ const storageKey = `${this.prefix}${provider}`;
+ await chrome.storage.local.set({ [storageKey]: key });
+ }
+
+ async removeKey(provider: string): Promise {
+ const key = `${this.prefix}${provider}`;
+ await chrome.storage.local.remove(key);
+ }
+
+ async getAllKeys(): Promise> {
+ const providers = getProviders();
+ const storage = await chrome.storage.local.get();
+ const result: Record = {};
+
+ for (const provider of providers) {
+ const key = `${this.prefix}${provider}`;
+ result[provider] = !!storage[key];
+ }
+
+ return result;
+ }
+}
+
+// Export singleton instance
+export const keyStore = new ChromeKeyStore();
diff --git a/packages/browser-extension/src/utils/attachment-utils.ts b/packages/browser-extension/src/utils/attachment-utils.ts
new file mode 100644
index 00000000..7896a540
--- /dev/null
+++ b/packages/browser-extension/src/utils/attachment-utils.ts
@@ -0,0 +1,472 @@
+import { parseAsync } from "docx-preview";
+import JSZip from "jszip";
+import type { PDFDocumentProxy } from "pdfjs-dist";
+import * as pdfjsLib from "pdfjs-dist";
+import * as XLSX from "xlsx";
+import { i18n } from "./i18n.js";
+
+// Configure PDF.js worker - we'll need to bundle this
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
+
+export interface Attachment {
+ id: string;
+ type: "image" | "document";
+ fileName: string;
+ mimeType: string;
+ size: number;
+ content: string; // base64 encoded original data (without data URL prefix)
+ extractedText?: string; // For documents: text
+ preview?: string; // base64 image preview (first page for PDFs, or same as content for images)
+}
+
+/**
+ * Load an attachment from various sources
+ * @param source - URL string, File, Blob, or ArrayBuffer
+ * @param fileName - Optional filename override
+ * @returns Promise
+ * @throws Error if loading fails
+ */
+export async function loadAttachment(
+ source: string | File | Blob | ArrayBuffer,
+ fileName?: string,
+): Promise {
+ let arrayBuffer: ArrayBuffer;
+ let detectedFileName = fileName || "unnamed";
+ let mimeType = "application/octet-stream";
+ let size = 0;
+
+ // Convert source to ArrayBuffer
+ if (typeof source === "string") {
+ // It's a URL - fetch it
+ const response = await fetch(source);
+ if (!response.ok) {
+ throw new Error(i18n("Failed to fetch file"));
+ }
+ arrayBuffer = await response.arrayBuffer();
+ size = arrayBuffer.byteLength;
+ mimeType = response.headers.get("content-type") || mimeType;
+ if (!fileName) {
+ // Try to extract filename from URL
+ const urlParts = source.split("/");
+ detectedFileName = urlParts[urlParts.length - 1] || "document";
+ }
+ } else if (source instanceof File) {
+ arrayBuffer = await source.arrayBuffer();
+ size = source.size;
+ mimeType = source.type || mimeType;
+ detectedFileName = fileName || source.name;
+ } else if (source instanceof Blob) {
+ arrayBuffer = await source.arrayBuffer();
+ size = source.size;
+ mimeType = source.type || mimeType;
+ } else if (source instanceof ArrayBuffer) {
+ arrayBuffer = source;
+ size = source.byteLength;
+ } else {
+ throw new Error(i18n("Invalid source type"));
+ }
+
+ // Convert ArrayBuffer to base64 - handle large files properly
+ const uint8Array = new Uint8Array(arrayBuffer);
+ let binary = "";
+ const chunkSize = 0x8000; // Process in 32KB chunks to avoid stack overflow
+ for (let i = 0; i < uint8Array.length; i += chunkSize) {
+ const chunk = uint8Array.slice(i, i + chunkSize);
+ binary += String.fromCharCode(...chunk);
+ }
+ const base64Content = btoa(binary);
+
+ // Detect type and process accordingly
+ const id = `${detectedFileName}_${Date.now()}_${Math.random()}`;
+
+ // Check if it's a PDF
+ if (mimeType === "application/pdf" || detectedFileName.toLowerCase().endsWith(".pdf")) {
+ const { extractedText, preview } = await processPdf(arrayBuffer, detectedFileName);
+ return {
+ id,
+ type: "document",
+ fileName: detectedFileName,
+ mimeType: "application/pdf",
+ size,
+ content: base64Content,
+ extractedText,
+ preview,
+ };
+ }
+
+ // Check if it's a DOCX file
+ if (
+ mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
+ detectedFileName.toLowerCase().endsWith(".docx")
+ ) {
+ const { extractedText } = await processDocx(arrayBuffer, detectedFileName);
+ return {
+ id,
+ type: "document",
+ fileName: detectedFileName,
+ mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ size,
+ content: base64Content,
+ extractedText,
+ };
+ }
+
+ // Check if it's a PPTX file
+ if (
+ mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
+ detectedFileName.toLowerCase().endsWith(".pptx")
+ ) {
+ const { extractedText } = await processPptx(arrayBuffer, detectedFileName);
+ return {
+ id,
+ type: "document",
+ fileName: detectedFileName,
+ mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ size,
+ content: base64Content,
+ extractedText,
+ };
+ }
+
+ // Check if it's an Excel file (XLSX/XLS)
+ const excelMimeTypes = [
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-excel",
+ ];
+ if (
+ excelMimeTypes.includes(mimeType) ||
+ detectedFileName.toLowerCase().endsWith(".xlsx") ||
+ detectedFileName.toLowerCase().endsWith(".xls")
+ ) {
+ const { extractedText } = await processExcel(arrayBuffer, detectedFileName);
+ return {
+ id,
+ type: "document",
+ fileName: detectedFileName,
+ mimeType: mimeType.startsWith("application/vnd")
+ ? mimeType
+ : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ size,
+ content: base64Content,
+ extractedText,
+ };
+ }
+
+ // Check if it's an image
+ if (mimeType.startsWith("image/")) {
+ return {
+ id,
+ type: "image",
+ fileName: detectedFileName,
+ mimeType,
+ size,
+ content: base64Content,
+ preview: base64Content, // For images, preview is the same as content
+ };
+ }
+
+ // Check if it's a text document
+ const textExtensions = [
+ ".txt",
+ ".md",
+ ".json",
+ ".xml",
+ ".html",
+ ".css",
+ ".js",
+ ".ts",
+ ".jsx",
+ ".tsx",
+ ".yml",
+ ".yaml",
+ ];
+ const isTextFile =
+ mimeType.startsWith("text/") || textExtensions.some((ext) => detectedFileName.toLowerCase().endsWith(ext));
+
+ if (isTextFile) {
+ const decoder = new TextDecoder();
+ const text = decoder.decode(arrayBuffer);
+ return {
+ id,
+ type: "document",
+ fileName: detectedFileName,
+ mimeType: mimeType.startsWith("text/") ? mimeType : "text/plain",
+ size,
+ content: base64Content,
+ extractedText: text,
+ };
+ }
+
+ throw new Error(`Unsupported file type: ${mimeType}`);
+}
+
+async function processPdf(
+ arrayBuffer: ArrayBuffer,
+ fileName: string,
+): Promise<{ extractedText: string; preview?: string }> {
+ let pdf: PDFDocumentProxy | null = null;
+ try {
+ pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
+
+ // Extract text with page structure
+ let extractedText = ``;
+ for (let i = 1; i <= pdf.numPages; i++) {
+ const page = await pdf.getPage(i);
+ const textContent = await page.getTextContent();
+ const pageText = textContent.items
+ .map((item: any) => item.str)
+ .filter((str: string) => str.trim())
+ .join(" ");
+ extractedText += `\n\n${pageText}\n`;
+ }
+ extractedText += "\n";
+
+ // Generate preview from first page
+ const preview = await generatePdfPreview(pdf);
+
+ return { extractedText, preview };
+ } catch (error) {
+ console.error("Error processing PDF:", error);
+ throw new Error(`Failed to process PDF: ${String(error)}`);
+ } finally {
+ // Clean up PDF resources
+ if (pdf) {
+ pdf.destroy();
+ }
+ }
+}
+
+async function generatePdfPreview(pdf: PDFDocumentProxy): Promise {
+ try {
+ const page = await pdf.getPage(1);
+ const viewport = page.getViewport({ scale: 1.0 });
+
+ // Create canvas with reasonable size for thumbnail (160x160 max)
+ const scale = Math.min(160 / viewport.width, 160 / viewport.height);
+ const scaledViewport = page.getViewport({ scale });
+
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d");
+ if (!context) {
+ return undefined;
+ }
+
+ canvas.height = scaledViewport.height;
+ canvas.width = scaledViewport.width;
+
+ const renderContext = {
+ canvasContext: context,
+ viewport: scaledViewport,
+ canvas: canvas,
+ };
+ await page.render(renderContext).promise;
+
+ // Return base64 without data URL prefix
+ return canvas.toDataURL("image/png").split(",")[1];
+ } catch (error) {
+ console.error("Error generating PDF preview:", error);
+ return undefined;
+ }
+}
+
+async function processDocx(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
+ try {
+ // Parse document structure
+ const wordDoc = await parseAsync(arrayBuffer);
+
+ // Extract structured text from document body
+ let extractedText = `\n\n`;
+
+ const body = wordDoc.documentPart?.body;
+ if (body?.children) {
+ // Walk through document elements and extract text
+ const texts: string[] = [];
+ for (const element of body.children) {
+ const text = extractTextFromElement(element);
+ if (text) {
+ texts.push(text);
+ }
+ }
+ extractedText += texts.join("\n");
+ }
+
+ extractedText += `\n\n`;
+ return { extractedText };
+ } catch (error) {
+ console.error("Error processing DOCX:", error);
+ throw new Error(`Failed to process DOCX: ${String(error)}`);
+ }
+}
+
+function extractTextFromElement(element: any): string {
+ let text = "";
+
+ // Check type with lowercase
+ const elementType = element.type?.toLowerCase() || "";
+
+ // Handle paragraphs
+ if (elementType === "paragraph" && element.children) {
+ for (const child of element.children) {
+ const childType = child.type?.toLowerCase() || "";
+ if (childType === "run" && child.children) {
+ for (const textChild of child.children) {
+ const textType = textChild.type?.toLowerCase() || "";
+ if (textType === "text") {
+ text += textChild.text || "";
+ }
+ }
+ } else if (childType === "text") {
+ text += child.text || "";
+ }
+ }
+ }
+ // Handle tables
+ else if (elementType === "table") {
+ if (element.children) {
+ const tableTexts: string[] = [];
+ for (const row of element.children) {
+ const rowType = row.type?.toLowerCase() || "";
+ if (rowType === "tablerow" && row.children) {
+ const rowTexts: string[] = [];
+ for (const cell of row.children) {
+ const cellType = cell.type?.toLowerCase() || "";
+ if (cellType === "tablecell" && cell.children) {
+ const cellTexts: string[] = [];
+ for (const cellElement of cell.children) {
+ const cellText = extractTextFromElement(cellElement);
+ if (cellText) cellTexts.push(cellText);
+ }
+ if (cellTexts.length > 0) rowTexts.push(cellTexts.join(" "));
+ }
+ }
+ if (rowTexts.length > 0) tableTexts.push(rowTexts.join(" | "));
+ }
+ }
+ if (tableTexts.length > 0) {
+ text = "\n[Table]\n" + tableTexts.join("\n") + "\n[/Table]\n";
+ }
+ }
+ }
+ // Recursively handle other container elements
+ else if (element.children && Array.isArray(element.children)) {
+ const childTexts: string[] = [];
+ for (const child of element.children) {
+ const childText = extractTextFromElement(child);
+ if (childText) childTexts.push(childText);
+ }
+ text = childTexts.join(" ");
+ }
+
+ return text.trim();
+}
+
+async function processPptx(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
+ try {
+ // Load the PPTX file as a ZIP
+ const zip = await JSZip.loadAsync(arrayBuffer);
+
+ // PPTX slides are stored in ppt/slides/slide[n].xml
+ let extractedText = ``;
+
+ // Get all slide files and sort them numerically
+ const slideFiles = Object.keys(zip.files)
+ .filter((name) => name.match(/ppt\/slides\/slide\d+\.xml$/))
+ .sort((a, b) => {
+ const numA = Number.parseInt(a.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
+ const numB = Number.parseInt(b.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
+ return numA - numB;
+ });
+
+ // Extract text from each slide
+ for (let i = 0; i < slideFiles.length; i++) {
+ const slideFile = zip.file(slideFiles[i]);
+ if (slideFile) {
+ const slideXml = await slideFile.async("text");
+
+ // Extract text from XML (simple regex approach)
+ // Looking for tags which contain text in PPTX
+ const textMatches = slideXml.match(/]*>([^<]+)<\/a:t>/g);
+
+ if (textMatches) {
+ extractedText += `\n`;
+ const slideTexts = textMatches
+ .map((match) => {
+ const textMatch = match.match(/]*>([^<]+)<\/a:t>/);
+ return textMatch ? textMatch[1] : "";
+ })
+ .filter((t) => t.trim());
+
+ if (slideTexts.length > 0) {
+ extractedText += "\n" + slideTexts.join("\n");
+ }
+ extractedText += "\n";
+ }
+ }
+ }
+
+ // Also try to extract text from notes
+ const notesFiles = Object.keys(zip.files)
+ .filter((name) => name.match(/ppt\/notesSlides\/notesSlide\d+\.xml$/))
+ .sort((a, b) => {
+ const numA = Number.parseInt(a.match(/notesSlide(\d+)\.xml$/)?.[1] || "0", 10);
+ const numB = Number.parseInt(b.match(/notesSlide(\d+)\.xml$/)?.[1] || "0", 10);
+ return numA - numB;
+ });
+
+ if (notesFiles.length > 0) {
+ extractedText += "\n";
+ for (const noteFile of notesFiles) {
+ const file = zip.file(noteFile);
+ if (file) {
+ const noteXml = await file.async("text");
+ const textMatches = noteXml.match(/]*>([^<]+)<\/a:t>/g);
+ if (textMatches) {
+ const noteTexts = textMatches
+ .map((match) => {
+ const textMatch = match.match(/]*>([^<]+)<\/a:t>/);
+ return textMatch ? textMatch[1] : "";
+ })
+ .filter((t) => t.trim());
+
+ if (noteTexts.length > 0) {
+ const slideNum = noteFile.match(/notesSlide(\d+)\.xml$/)?.[1];
+ extractedText += `\n[Slide ${slideNum} notes]: ${noteTexts.join(" ")}`;
+ }
+ }
+ }
+ }
+ extractedText += "\n";
+ }
+
+ extractedText += "\n";
+ return { extractedText };
+ } catch (error) {
+ console.error("Error processing PPTX:", error);
+ throw new Error(`Failed to process PPTX: ${String(error)}`);
+ }
+}
+
+async function processExcel(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
+ try {
+ // Read the workbook
+ const workbook = XLSX.read(arrayBuffer, { type: "array" });
+
+ let extractedText = ``;
+
+ // Process each sheet
+ for (const [index, sheetName] of workbook.SheetNames.entries()) {
+ const worksheet = workbook.Sheets[sheetName];
+
+ // Extract text as CSV for the extractedText field
+ const csvText = XLSX.utils.sheet_to_csv(worksheet);
+ extractedText += `\n\n${csvText}\n`;
+ }
+
+ extractedText += "\n";
+
+ return { extractedText };
+ } catch (error) {
+ console.error("Error processing Excel:", error);
+ throw new Error(`Failed to process Excel: ${String(error)}`);
+ }
+}
diff --git a/packages/browser-extension/src/utils/i18n.ts b/packages/browser-extension/src/utils/i18n.ts
index 40f3ec71..6edead88 100644
--- a/packages/browser-extension/src/utils/i18n.ts
+++ b/packages/browser-extension/src/utils/i18n.ts
@@ -11,6 +11,39 @@ declare module "@mariozechner/mini-lit" {
Format: string;
Thinking: string;
Vision: string;
+ You: string;
+ Assistant: string;
+ "Thinking...": string;
+ "Type your message...": string;
+ "API Keys Configuration": string;
+ "Configure API keys for LLM providers. Keys are stored locally in your browser.": string;
+ Configured: string;
+ "Not configured": string;
+ "✓ Valid": string;
+ "✗ Invalid": string;
+ "Testing...": string;
+ Update: string;
+ Test: string;
+ Remove: string;
+ Save: string;
+ "Update API key": string;
+ "Enter API key": string;
+ "Type a message...": string;
+ "Failed to fetch file": string;
+ "Invalid source type": string;
+ PDF: string;
+ Document: string;
+ Presentation: string;
+ Spreadsheet: string;
+ Text: string;
+ "Error loading file": string;
+ "No text content available": string;
+ "Failed to load PDF": string;
+ "Failed to load document": string;
+ "Failed to load spreadsheet": string;
+ "No content available": string;
+ "Failed to display text content": string;
+ "API keys are required to use AI models. Get your keys from the provider's website.": string;
}
}
@@ -26,6 +59,41 @@ const translations = {
Format: "Format",
Thinking: "Thinking",
Vision: "Vision",
+ You: "You",
+ Assistant: "Assistant",
+ "Thinking...": "Thinking...",
+ "Type your message...": "Type your message...",
+ "API Keys Configuration": "API Keys Configuration",
+ "Configure API keys for LLM providers. Keys are stored locally in your browser.":
+ "Configure API keys for LLM providers. Keys are stored locally in your browser.",
+ Configured: "Configured",
+ "Not configured": "Not configured",
+ "✓ Valid": "✓ Valid",
+ "✗ Invalid": "✗ Invalid",
+ "Testing...": "Testing...",
+ Update: "Update",
+ Test: "Test",
+ Remove: "Remove",
+ Save: "Save",
+ "Update API key": "Update API key",
+ "Enter API key": "Enter API key",
+ "Type a message...": "Type a message...",
+ "Failed to fetch file": "Failed to fetch file",
+ "Invalid source type": "Invalid source type",
+ PDF: "PDF",
+ Document: "Document",
+ Presentation: "Presentation",
+ Spreadsheet: "Spreadsheet",
+ Text: "Text",
+ "Error loading file": "Error loading file",
+ "No text content available": "No text content available",
+ "Failed to load PDF": "Failed to load PDF",
+ "Failed to load document": "Failed to load document",
+ "Failed to load spreadsheet": "Failed to load spreadsheet",
+ "No content available": "No content available",
+ "Failed to display text content": "Failed to display text content",
+ "API keys are required to use AI models. Get your keys from the provider's website.":
+ "API keys are required to use AI models. Get your keys from the provider's website.",
},
de: {
...defaultGerman,
@@ -38,6 +106,41 @@ const translations = {
Format: "Formatieren",
Thinking: "Thinking",
Vision: "Vision",
+ You: "Sie",
+ Assistant: "Assistent",
+ "Thinking...": "Denkt nach...",
+ "Type your message...": "Geben Sie Ihre Nachricht ein...",
+ "API Keys Configuration": "API-Schlüssel-Konfiguration",
+ "Configure API keys for LLM providers. Keys are stored locally in your browser.":
+ "Konfigurieren Sie API-Schlüssel für LLM-Anbieter. Schlüssel werden lokal in Ihrem Browser gespeichert.",
+ Configured: "Konfiguriert",
+ "Not configured": "Nicht konfiguriert",
+ "✓ Valid": "✓ Gültig",
+ "✗ Invalid": "✗ Ungültig",
+ "Testing...": "Testet...",
+ Update: "Aktualisieren",
+ Test: "Testen",
+ Remove: "Entfernen",
+ Save: "Speichern",
+ "Update API key": "API-Schlüssel aktualisieren",
+ "Enter API key": "API-Schlüssel eingeben",
+ "Type a message...": "Nachricht eingeben...",
+ "Failed to fetch file": "Datei konnte nicht abgerufen werden",
+ "Invalid source type": "Ungültiger Quellentyp",
+ PDF: "PDF",
+ Document: "Dokument",
+ Presentation: "Präsentation",
+ Spreadsheet: "Tabelle",
+ Text: "Text",
+ "Error loading file": "Fehler beim Laden der Datei",
+ "No text content available": "Kein Textinhalt verfügbar",
+ "Failed to load PDF": "PDF konnte nicht geladen werden",
+ "Failed to load document": "Dokument konnte nicht geladen werden",
+ "Failed to load spreadsheet": "Tabelle konnte nicht geladen werden",
+ "No content available": "Kein Inhalt verfügbar",
+ "Failed to display text content": "Textinhalt konnte nicht angezeigt werden",
+ "API keys are required to use AI models. Get your keys from the provider's website.":
+ "API-Schlüssel sind erforderlich, um KI-Modelle zu verwenden. Holen Sie sich Ihre Schlüssel von der Website des Anbieters.",
},
};
diff --git a/pi-mono.code-workspace b/pi-mono.code-workspace
new file mode 100644
index 00000000..f8d507ed
--- /dev/null
+++ b/pi-mono.code-workspace
@@ -0,0 +1,14 @@
+{
+ "folders": [
+ {
+ "path": "."
+ },
+ {
+ "path": "../mini-lit"
+ },
+ {
+ "path": "../genai-workshop-new"
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file