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` + ${this.attachment.fileName} + `; + } + + 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` +
+ ${this.attachment.fileName} + ${ + isPdf + ? html` + +
+
${i18n("PDF")}
+
+ ` + : "" + } +
+ ` + : 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