mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
Port PDF/Office support, message editor, overlays, key setter
This commit is contained in:
parent
b67c10dfb1
commit
b3a7b35ec5
17 changed files with 2462 additions and 18 deletions
404
package-lock.json
generated
404
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
};
|
||||
|
||||
|
|
|
|||
635
packages/browser-extension/src/AttachmentOverlay.ts
Normal file
635
packages/browser-extension/src/AttachmentOverlay.ts
Normal file
|
|
@ -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`
|
||||
<!-- Full screen overlay -->
|
||||
<div class="fixed inset-0 bg-black/90 z-50 flex flex-col" @click=${this.handleBackdropClick}>
|
||||
<!-- Compact header bar -->
|
||||
<div class="bg-background/95 backdrop-blur border-b border-border" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="px-4 py-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="text-sm font-medium text-foreground truncate">${this.attachment.fileName}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${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",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content container -->
|
||||
<div class="flex-1 flex items-center justify-center overflow-auto" @click=${(e: Event) => e.stopPropagation()}>
|
||||
${this.renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<mode-toggle
|
||||
.modes=${[fileTypeLabel, i18n("Text")]}
|
||||
.selectedIndex=${this.showExtractedText ? 1 : 0}
|
||||
@mode-change=${(e: CustomEvent<{ index: number; mode: string }>) => {
|
||||
e.stopPropagation();
|
||||
this.showExtractedText = e.detail.index === 1;
|
||||
this.error = null;
|
||||
}}
|
||||
></mode-toggle>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
if (!this.attachment) return html``;
|
||||
|
||||
// Error state
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl">
|
||||
<div class="font-medium mb-1">${i18n("Error loading file")}</div>
|
||||
<div class="text-sm opacity-90">${this.error}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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`
|
||||
<div class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto">
|
||||
<pre class="whitespace-pre-wrap font-mono text-xs leading-relaxed">${
|
||||
this.attachment.extractedText || i18n("No text content available")
|
||||
}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render based on file type
|
||||
switch (fileType) {
|
||||
case "image": {
|
||||
const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`;
|
||||
return html`
|
||||
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg shadow-lg" alt="${this.attachment.fileName}" />
|
||||
`;
|
||||
}
|
||||
|
||||
case "pdf":
|
||||
return html`
|
||||
<div
|
||||
id="pdf-container"
|
||||
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
|
||||
></div>
|
||||
`;
|
||||
|
||||
case "docx":
|
||||
return html`
|
||||
<div
|
||||
id="docx-container"
|
||||
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
|
||||
></div>
|
||||
`;
|
||||
|
||||
case "excel":
|
||||
return html` <div id="excel-container" class="bg-card text-foreground overflow-auto w-full h-full"></div> `;
|
||||
|
||||
case "pptx":
|
||||
return html`
|
||||
<div
|
||||
id="pptx-container"
|
||||
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
|
||||
></div>
|
||||
`;
|
||||
|
||||
default:
|
||||
return html`
|
||||
<div class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto">
|
||||
<pre class="whitespace-pre-wrap font-mono text-sm">${
|
||||
this.attachment.extractedText || i18n("No content available")
|
||||
}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
override async updated(changedProperties: Map<string, any>) {
|
||||
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);
|
||||
}
|
||||
112
packages/browser-extension/src/AttachmentTile.ts
Normal file
112
packages/browser-extension/src/AttachmentTile.ts
Normal file
|
|
@ -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`
|
||||
<div class="relative group inline-block">
|
||||
${
|
||||
hasPreview
|
||||
? html`
|
||||
<div class="relative">
|
||||
<img
|
||||
src="data:${isImage ? this.attachment.mimeType : "image/png"};base64,${this.attachment.preview}"
|
||||
class="w-16 h-16 object-cover rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity"
|
||||
alt="${this.attachment.fileName}"
|
||||
title="${this.attachment.fileName}"
|
||||
@click=${this.handleClick}
|
||||
/>
|
||||
${
|
||||
isPdf
|
||||
? html`
|
||||
<!-- PDF badge overlay -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-background/90 px-1 py-0.5 rounded-b-lg">
|
||||
<div class="text-[10px] text-muted-foreground text-center font-medium">${i18n("PDF")}</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<!-- Fallback: document icon + filename -->
|
||||
<div
|
||||
class="w-16 h-16 rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity bg-muted text-muted-foreground flex flex-col items-center justify-center p-2"
|
||||
@click=${this.handleClick}
|
||||
title="${this.attachment.fileName}"
|
||||
>
|
||||
${getDocumentIcon()}
|
||||
<div class="text-[10px] text-center truncate w-full">
|
||||
${
|
||||
this.attachment.fileName.length > 10
|
||||
? this.attachment.fileName.substring(0, 8) + "..."
|
||||
: this.attachment.fileName
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
${
|
||||
this.showDelete
|
||||
? html`
|
||||
<button
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.onDelete?.();
|
||||
}}
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-background hover:bg-muted text-muted-foreground hover:text-foreground rounded-full flex items-center justify-center opacity-100 hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity border border-input shadow-sm"
|
||||
title="${i18n("Remove")}"
|
||||
>
|
||||
${icon(X, "xs")}
|
||||
</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<any> | 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`<h1>Hello world</h1>`;
|
||||
return html`
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Messages area (empty for now) -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<!-- Messages will go here -->
|
||||
</div>
|
||||
|
||||
<!-- Message editor at the bottom -->
|
||||
<div class="p-4 border-t border-border">
|
||||
<message-editor
|
||||
.value=${this.messageText}
|
||||
.currentModel=${this.currentModel}
|
||||
.attachments=${this.attachments}
|
||||
.showAttachmentButton=${true}
|
||||
.showThinking=${false}
|
||||
.onInput=${(value: string) => {
|
||||
this.messageText = value;
|
||||
}}
|
||||
.onSend=${this.handleSend}
|
||||
.onModelSelect=${this.handleModelSelect}
|
||||
.onFilesChange=${(files: Attachment[]) => {
|
||||
this.attachments = files;
|
||||
}}
|
||||
></message-editor>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
267
packages/browser-extension/src/MessageEditor.ts
Normal file
267
packages/browser-extension/src/MessageEditor.ts
Normal file
|
|
@ -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<HTMLTextAreaElement>();
|
||||
|
||||
@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<any>;
|
||||
@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<HTMLInputElement>();
|
||||
|
||||
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`
|
||||
<div class="bg-card rounded-xl border border-border shadow-sm">
|
||||
<!-- Attachments -->
|
||||
${
|
||||
this.attachments.length > 0
|
||||
? html`
|
||||
<div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
|
||||
${this.attachments.map(
|
||||
(attachment) => html`
|
||||
<attachment-tile
|
||||
.attachment=${attachment}
|
||||
.showDelete=${true}
|
||||
.onDelete=${() => this.removeFile(attachment.id)}
|
||||
></attachment-tile>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<textarea
|
||||
class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
|
||||
placeholder=${i18n("Type a message...")}
|
||||
rows="1"
|
||||
style="max-height: 200px;"
|
||||
.value=${this.value}
|
||||
@input=${this.handleTextareaInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
${ref(this.textareaRef)}
|
||||
></textarea>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
type="file"
|
||||
${ref(this.fileInputRef)}
|
||||
@change=${this.handleFilesSelected}
|
||||
accept=${this.acceptedTypes}
|
||||
multiple
|
||||
style="display: none;"
|
||||
/>
|
||||
|
||||
<!-- Button Row -->
|
||||
<div class="px-2 pb-2 flex items-center justify-between">
|
||||
<!-- Left side - attachment button -->
|
||||
<div class="flex gap-2 items-center">
|
||||
${
|
||||
this.showAttachmentButton
|
||||
? this.processingFiles
|
||||
? html`
|
||||
<div class="h-8 w-8 flex items-center justify-center">
|
||||
${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
className: "h-8 w-8",
|
||||
onClick: this.handleAttachmentClick,
|
||||
children: icon(Paperclip, "sm"),
|
||||
})}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Model selector and send on the right -->
|
||||
<div class="flex gap-2 items-center">
|
||||
${
|
||||
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")}
|
||||
<span class="ml-1">${this.currentModel.id}</span>
|
||||
`,
|
||||
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`<div style="transform: rotate(-45deg)">${icon(Send, "sm")}</div>`,
|
||||
className: "h-8 w-8",
|
||||
})}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
53
packages/browser-extension/src/ModeToggle.ts
Normal file
53
packages/browser-extension/src/ModeToggle.ts
Normal file
|
|
@ -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`
|
||||
<div class="inline-flex items-center h-7 rounded-md overflow-hidden border border-border bg-muted/60">
|
||||
${this.modes.map(
|
||||
(mode, index) => html`
|
||||
<button
|
||||
class="px-3 h-full flex items-center text-sm font-medium transition-colors ${
|
||||
index === this.selectedIndex
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-accent-foreground"
|
||||
}"
|
||||
@click=${() => this.setMode(index)}
|
||||
title="${mode}"
|
||||
>
|
||||
${mode}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element only once
|
||||
if (!customElements.get("mode-toggle")) {
|
||||
customElements.define("mode-toggle", ModeToggle);
|
||||
}
|
||||
273
packages/browser-extension/src/dialogs/ApiKeysDialog.ts
Normal file
273
packages/browser-extension/src/dialogs/ApiKeysDialog.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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<string, boolean> = {}; // provider -> configured
|
||||
@state() apiKeyInputs: Record<string, string> = {};
|
||||
@state() testResults: Record<string, "success" | "error" | "testing"> = {};
|
||||
@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<void> {
|
||||
super.firstUpdated(changedProperties);
|
||||
await this.loadKeys();
|
||||
}
|
||||
|
||||
private async loadKeys() {
|
||||
this.apiKeys = await keyStore.getAllKeys();
|
||||
}
|
||||
|
||||
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
|
||||
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`
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="p-6 pb-4 border-b border-border flex-shrink-0">
|
||||
${DialogHeader({ title: i18n("API Keys Configuration") })}
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
${
|
||||
this.error
|
||||
? html`
|
||||
<div class="px-6 pt-4">${Alert(this.error, "destructive")}</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="space-y-6">
|
||||
${providers.map(
|
||||
(provider) => html`
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium capitalize">${provider}</span>
|
||||
${
|
||||
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" })
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
${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"),
|
||||
})}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with help text -->
|
||||
<div class="p-6 pt-4 border-t border-border flex-shrink-0">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
${i18n("API keys are required to use AI models. Get your keys from the provider's website.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pi Reader Assistant</title>
|
||||
<title>pi-ai</title>
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
</head>
|
||||
<body class="h-full w-full">
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<div class="flex items-center px-4 py-2 border-b border-border mb-4">
|
||||
<span class="text-muted-foreground">pi-ai webby</span>
|
||||
<span class="text-muted-foreground">pi-ai</span>
|
||||
<theme-toggle class="ml-auto"></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
children: html`${icon(Settings, "sm")}`,
|
||||
onClick: async () => {
|
||||
ModelSelector.open(null, (model) => {
|
||||
console.log("Selected model:", model);
|
||||
});
|
||||
ApiKeysDialog.open();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
50
packages/browser-extension/src/state/KeyStore.ts
Normal file
50
packages/browser-extension/src/state/KeyStore.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { getProviders } from "@mariozechner/pi-ai";
|
||||
|
||||
/**
|
||||
* Interface for API key storage
|
||||
*/
|
||||
export interface KeyStore {
|
||||
getKey(provider: string): Promise<string | null>;
|
||||
setKey(provider: string, key: string): Promise<void>;
|
||||
removeKey(provider: string): Promise<void>;
|
||||
getAllKeys(): Promise<Record<string, boolean>>; // provider -> isConfigured
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome storage implementation of KeyStore
|
||||
*/
|
||||
class ChromeKeyStore implements KeyStore {
|
||||
private readonly prefix = "apiKey_";
|
||||
|
||||
async getKey(provider: string): Promise<string | null> {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
const result = await chrome.storage.local.get(key);
|
||||
return result[key] || null;
|
||||
}
|
||||
|
||||
async setKey(provider: string, key: string): Promise<void> {
|
||||
const storageKey = `${this.prefix}${provider}`;
|
||||
await chrome.storage.local.set({ [storageKey]: key });
|
||||
}
|
||||
|
||||
async removeKey(provider: string): Promise<void> {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
await chrome.storage.local.remove(key);
|
||||
}
|
||||
|
||||
async getAllKeys(): Promise<Record<string, boolean>> {
|
||||
const providers = getProviders();
|
||||
const storage = await chrome.storage.local.get();
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
result[provider] = !!storage[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const keyStore = new ChromeKeyStore();
|
||||
472
packages/browser-extension/src/utils/attachment-utils.ts
Normal file
472
packages/browser-extension/src/utils/attachment-utils.ts
Normal file
|
|
@ -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: <pdf filename="..."><page number="1">text</page></pdf>
|
||||
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<Attachment>
|
||||
* @throws Error if loading fails
|
||||
*/
|
||||
export async function loadAttachment(
|
||||
source: string | File | Blob | ArrayBuffer,
|
||||
fileName?: string,
|
||||
): Promise<Attachment> {
|
||||
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 = `<pdf filename="${fileName}">`;
|
||||
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<page number="${i}">\n${pageText}\n</page>`;
|
||||
}
|
||||
extractedText += "\n</pdf>";
|
||||
|
||||
// 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<string | undefined> {
|
||||
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 = `<docx filename="${fileName}">\n<page number="1">\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</page>\n</docx>`;
|
||||
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 = `<pptx filename="${fileName}">`;
|
||||
|
||||
// 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 <a:t> tags which contain text in PPTX
|
||||
const textMatches = slideXml.match(/<a:t[^>]*>([^<]+)<\/a:t>/g);
|
||||
|
||||
if (textMatches) {
|
||||
extractedText += `\n<slide number="${i + 1}">`;
|
||||
const slideTexts = textMatches
|
||||
.map((match) => {
|
||||
const textMatch = match.match(/<a:t[^>]*>([^<]+)<\/a:t>/);
|
||||
return textMatch ? textMatch[1] : "";
|
||||
})
|
||||
.filter((t) => t.trim());
|
||||
|
||||
if (slideTexts.length > 0) {
|
||||
extractedText += "\n" + slideTexts.join("\n");
|
||||
}
|
||||
extractedText += "\n</slide>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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<notes>";
|
||||
for (const noteFile of notesFiles) {
|
||||
const file = zip.file(noteFile);
|
||||
if (file) {
|
||||
const noteXml = await file.async("text");
|
||||
const textMatches = noteXml.match(/<a:t[^>]*>([^<]+)<\/a:t>/g);
|
||||
if (textMatches) {
|
||||
const noteTexts = textMatches
|
||||
.map((match) => {
|
||||
const textMatch = match.match(/<a:t[^>]*>([^<]+)<\/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</notes>";
|
||||
}
|
||||
|
||||
extractedText += "\n</pptx>";
|
||||
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 = `<excel filename="${fileName}">`;
|
||||
|
||||
// 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<sheet name="${sheetName}" index="${index + 1}">\n${csvText}\n</sheet>`;
|
||||
}
|
||||
|
||||
extractedText += "\n</excel>";
|
||||
|
||||
return { extractedText };
|
||||
} catch (error) {
|
||||
console.error("Error processing Excel:", error);
|
||||
throw new Error(`Failed to process Excel: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
14
pi-mono.code-workspace
Normal file
14
pi-mono.code-workspace
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../mini-lit"
|
||||
},
|
||||
{
|
||||
"path": "../genai-workshop-new"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue