From 4a172fce565e158b06db41f22002d5c69c32528a Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 3 Aug 2026 14:25:10 -0500 Subject: [PATCH 1/2] Add message actions and grounded citation Sources (#97, #98) Wire copy/thumbs/export on chat bubbles and render live + hydrated Sources from citations frames so users can rate, export, and inspect grounded answers. --- llm-fe/ANALYTICS.md | 4 + llm-fe/package-lock.json | 536 ++++++++++++++++-- llm-fe/package.json | 7 + llm-fe/src/App.tsx | 2 + .../ConversationDetailCard.citations.test.tsx | 80 +++ .../ConversationDetailCard.tsx | 200 ++++++- .../CustomCodeBlock/CustomCodeBlock.tsx | 81 ++- .../CustomPreBlock/CustomPreBlock.tsx | 30 +- .../MessageActions/MessageActions.tsx | 362 ++++++++++++ .../components/SourcesList/SourcesList.tsx | 138 +++++ .../llm-fe/components/ToastHost/ToastHost.tsx | 40 ++ llm-fe/src/llm-fe/contexts/MessageContext.tsx | 66 ++- llm-fe/src/llm-fe/data.ts | 19 +- .../pages/AsyncDashboard2/AsyncDashboard2.tsx | 135 ++++- llm-fe/src/llm-fe/utils/analytics.ts | 5 + llm-fe/src/llm-fe/utils/clipboard.test.ts | 51 ++ llm-fe/src/llm-fe/utils/clipboard.ts | 33 ++ llm-fe/src/llm-fe/utils/downloadFile.ts | 24 + .../llm-fe/utils/export/exportChat.test.ts | 67 +++ llm-fe/src/llm-fe/utils/export/exportChat.ts | 477 ++++++++++++++++ .../src/llm-fe/utils/export/markdownUtils.ts | 134 +++++ .../src/llm-fe/utils/promptFeedback.test.ts | 45 ++ llm-fe/src/llm-fe/utils/promptFeedback.ts | 39 ++ llm-fe/src/llm-fe/utils/toastBus.ts | 17 + llm-fe/src/llm-fe/utils/wsFrames.test.ts | 52 ++ llm-fe/src/llm-fe/utils/wsFrames.ts | 56 ++ 26 files changed, 2588 insertions(+), 112 deletions(-) create mode 100644 llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.citations.test.tsx create mode 100644 llm-fe/src/llm-fe/components/MessageActions/MessageActions.tsx create mode 100644 llm-fe/src/llm-fe/components/SourcesList/SourcesList.tsx create mode 100644 llm-fe/src/llm-fe/components/ToastHost/ToastHost.tsx create mode 100644 llm-fe/src/llm-fe/utils/clipboard.test.ts create mode 100644 llm-fe/src/llm-fe/utils/clipboard.ts create mode 100644 llm-fe/src/llm-fe/utils/downloadFile.ts create mode 100644 llm-fe/src/llm-fe/utils/export/exportChat.test.ts create mode 100644 llm-fe/src/llm-fe/utils/export/exportChat.ts create mode 100644 llm-fe/src/llm-fe/utils/export/markdownUtils.ts create mode 100644 llm-fe/src/llm-fe/utils/promptFeedback.test.ts create mode 100644 llm-fe/src/llm-fe/utils/promptFeedback.ts create mode 100644 llm-fe/src/llm-fe/utils/toastBus.ts create mode 100644 llm-fe/src/llm-fe/utils/wsFrames.test.ts create mode 100644 llm-fe/src/llm-fe/utils/wsFrames.ts diff --git a/llm-fe/ANALYTICS.md b/llm-fe/ANALYTICS.md index 2536ead..a5a84a8 100644 --- a/llm-fe/ANALYTICS.md +++ b/llm-fe/ANALYTICS.md @@ -27,6 +27,10 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta. | Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — | | Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — | | Account Delete Failed | `ACCOUNT_DELETE_FAILED` | DeleteAccountSection | — | +| Message Copied | `MESSAGE_COPIED` | MessageActions | `{ role }` | +| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` | +| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` | +| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` | ## Identify diff --git a/llm-fe/package-lock.json b/llm-fe/package-lock.json index c1b5967..068dcf3 100644 --- a/llm-fe/package-lock.json +++ b/llm-fe/package-lock.json @@ -25,15 +25,21 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.14", "@types/node": "^22.10.2", + "@types/papaparse": "^5.5.2", + "@types/pdfmake": "^0.3.3", "axios": "^1.13.2", "babel-loader": "^9.2.1", "bootstrap": "^5.3.3", + "brace-expansion": "file:vendor/brace-expansion-compat", "chroma-js": "^3.1.2", + "docx": "^9.7.1", "formik": "^2.4.6", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", "markdown-to-jsx": "^7.7.2", "mini.css": "^3.0.1", + "papaparse": "^5.5.4", + "pdfmake": "^0.3.11", "react-bootstrap": "^2.10.6", "react-code-blocks": "^0.1.6", "react-github-btn": "^1.4.0", @@ -46,11 +52,12 @@ "web-vitals": "^4.2.4", "webpack": "^5.97.1", "webpack-cli": "^5.1.4", - "yup": "^1.5.0", - "brace-expansion": "file:vendor/brace-expansion-compat" + "xlsx": "^0.18.5", + "yup": "^1.5.0" }, "devDependencies": { "@capacitor/assets": "^3.0.5", + "@testing-library/dom": "^10.4.1", "@types/bootstrap": "~5.2.10", "@types/lodash": "~4.17.13", "@types/react": "^18.3.16", @@ -2489,7 +2496,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -2502,7 +2509,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -5009,6 +5016,30 @@ "node": ">=4.0" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5546,18 +5577,19 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "peer": true, + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", - "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { @@ -5941,35 +5973,36 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6315,11 +6348,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pdfmake": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@types/pdfmake/-/pdfmake-0.3.3.tgz", + "integrity": "sha512-ufbceB4Q3dKpmFUMKEbZvJcfLi8+C5b1+rTFVG6urtkS3FHwaTQH6NCh/iEEDINlApCxH/W3/rsq99pP5E9/JA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pdfkit": "*" + } + }, "node_modules/@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -6353,7 +6414,7 @@ "version": "18.3.5", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", - "devOptional": true, + "dev": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -7048,6 +7109,15 @@ "node": ">=8.9" } }, + "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": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -7972,11 +8042,29 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -8206,6 +8294,19 @@ "node": ">=4" } }, + "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/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8380,6 +8481,15 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -8410,6 +8520,15 @@ "node": ">= 0.12.0" } }, + "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/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -8890,11 +9009,23 @@ "node": ">=10" } }, + "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/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -9696,6 +9827,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -9757,11 +9894,62 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/dom-converter": { "version": "0.2.0", @@ -11390,6 +11578,23 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -11613,6 +11818,15 @@ "node": ">= 0.6" } }, + "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/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -12226,6 +12440,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -12622,6 +12846,12 @@ "node": ">= 4" } }, + "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/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -15349,6 +15579,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15533,6 +15769,54 @@ "node": ">=4.0" } }, + "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/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/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/jwt-decode": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", @@ -15619,6 +15903,15 @@ "node": ">= 0.8.0" } }, + "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/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -15627,6 +15920,25 @@ "node": ">=10" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -15797,7 +16109,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "peer": true, + "dev": true, + "license": "MIT", "bin": { "lz-string": "bin/bin.js" } @@ -15839,7 +16152,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/makeerror": { @@ -16882,6 +17195,18 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, + "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/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -17021,6 +17346,34 @@ "node": ">=8" } }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, + "node_modules/pdfmake": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.3.11.tgz", + "integrity": "sha512-Uc49J9hUMyuqJk+U+PxlpBpPr96A4HOOfesGx609EPr2ue82+5/Smq/KTAkEqh0/jUGSi1fumvqZ5yAWijJTJg==", + "license": "MIT", + "dependencies": { + "linebreak": "^1.1.0", + "pdfkit": "^0.19.1", + "xmldoc": "^2.0.3" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -17160,6 +17513,14 @@ "node": ">=10.4.0" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -18701,6 +19062,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -18911,6 +19273,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -20116,6 +20479,12 @@ "node": ">=10" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -20352,6 +20721,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -20619,6 +20989,12 @@ "node": ">= 0.4" } }, + "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/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -21040,6 +21416,18 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "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/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -21430,12 +21818,6 @@ "postcss": "^8.2.15" } }, - "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "peer": true - }, "node_modules/stylis-plugin-rtl": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz", @@ -21899,6 +22281,12 @@ "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -22014,7 +22402,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -22058,7 +22446,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -22071,14 +22459,14 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/ts-node/node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -22263,6 +22651,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22349,6 +22738,16 @@ "node": ">=4" } }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/unicode-property-aliases-ecmascript": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", @@ -22357,6 +22756,22 @@ "node": ">=4" } }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -22484,7 +22899,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -23053,6 +23468,24 @@ "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==" }, + "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/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -23449,11 +23882,37 @@ "node": ">=10.0.0" } }, + "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/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, "node_modules/xml-js": { "version": "1.6.11", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", - "dev": true, "license": "MIT", "dependencies": { "sax": "^1.2.4" @@ -23503,6 +23962,18 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "node_modules/xmldoc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz", + "integrity": "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==", + "license": "MIT", + "dependencies": { + "sax": "^1.4.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/xpath": { "version": "0.0.32", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", @@ -23582,7 +24053,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -23622,6 +24093,7 @@ } }, "vendor/brace-expansion-compat": { + "name": "brace-expansion", "version": "5.0.8", "license": "MIT" } diff --git a/llm-fe/package.json b/llm-fe/package.json index 9131e0a..d6ece11 100644 --- a/llm-fe/package.json +++ b/llm-fe/package.json @@ -20,16 +20,21 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.14", "@types/node": "^22.10.2", + "@types/papaparse": "^5.5.2", + "@types/pdfmake": "^0.3.3", "axios": "^1.13.2", "babel-loader": "^9.2.1", "bootstrap": "^5.3.3", "brace-expansion": "file:vendor/brace-expansion-compat", "chroma-js": "^3.1.2", + "docx": "^9.7.1", "formik": "^2.4.6", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", "markdown-to-jsx": "^7.7.2", "mini.css": "^3.0.1", + "papaparse": "^5.5.4", + "pdfmake": "^0.3.11", "react-bootstrap": "^2.10.6", "react-code-blocks": "^0.1.6", "react-github-btn": "^1.4.0", @@ -42,6 +47,7 @@ "web-vitals": "^4.2.4", "webpack": "^5.97.1", "webpack-cli": "^5.1.4", + "xlsx": "^0.18.5", "yup": "^1.5.0" }, "scripts": { @@ -77,6 +83,7 @@ }, "devDependencies": { "@capacitor/assets": "^3.0.5", + "@testing-library/dom": "^10.4.1", "@types/bootstrap": "~5.2.10", "@types/lodash": "~4.17.13", "@types/react": "^18.3.16", diff --git a/llm-fe/src/App.tsx b/llm-fe/src/App.tsx index 7424497..53e2567 100644 --- a/llm-fe/src/App.tsx +++ b/llm-fe/src/App.tsx @@ -24,6 +24,7 @@ import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentCont import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner'; import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession'; import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary'; +import ToastHost from './llm-fe/components/ToastHost/ToastHost'; const ProtectedRoutes = () => { const { authenticated, loading } = useContext(AuthContext); @@ -48,6 +49,7 @@ class App extends Component { +
diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.citations.test.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.citations.test.tsx new file mode 100644 index 0000000..ac020e3 --- /dev/null +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.citations.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; +import ConversationDetailCard, { + injectCitationMarkers, +} from './ConversationDetailCard'; + +const theme = { + main: '#336699', + focus: '#224466', + darkMode: true, + colors: { + text: '#ffffff', + cardBackground: 'rgba(0,0,0,0.3)', + cardBorder: 'rgba(255,255,255,0.1)', + }, +}; + +const renderCard = (props: React.ComponentProps) => + render( + + + + + , + ); + +describe('ConversationDetailCard citations (#98)', () => { + it('injects citation markers outside code fences', () => { + const md = 'See [1] and [2, 3]\n\n```\n[9]\n```'; + const out = injectCitationMarkers(md); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain('```\n[9]\n```'); + }); + + it('renders Sources list from citations prop', () => { + renderCard({ + message: 'Answer with [1]', + user_created: false, + citations: [ + { + index: 1, + title: 'Example Source', + url: 'https://example.com/a', + published_at: '2026-07-03', + }, + ], + }); + expect(screen.getByLabelText('Sources')).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'Example Source' }); + expect(link).toHaveAttribute('href', 'https://example.com/a'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('renders nothing for Sources when citations empty', () => { + renderCard({ + message: 'No sources here', + user_created: false, + citations: [], + }); + expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument(); + }); + + it('highlights source when inline citation clicked', async () => { + const user = userEvent.setup(); + renderCard({ + message: 'See [1]', + user_created: false, + citations: [ + { index: 1, title: 'Src', url: 'https://example.com', published_at: null }, + ], + }); + await user.click(screen.getByRole('button', { name: 'Source 1' })); + expect(document.getElementById('citation-source-1')).toBeInTheDocument(); + }); +}); diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx index f65b23c..2498fc6 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx @@ -1,8 +1,13 @@ -import React from "react"; -import Markdown from "markdown-to-jsx"; -import { Link } from "react-router-dom"; -import styled, { keyframes } from "styled-components"; -import { isRagFeatureNotAllowed, parseChatErrorPayload } from "../../utils/chatErrors"; +import React, { useCallback, useMemo, useState } from 'react'; +import Markdown from 'markdown-to-jsx'; +import { Link } from 'react-router-dom'; +import styled, { keyframes } from 'styled-components'; +import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors'; +import type { Citation } from '../../utils/wsFrames'; +import type { PromptRating } from '../../utils/promptFeedback'; +import CustomPreBlock from '../CustomPreBlock/CustomPreBlock'; +import SourcesList from '../SourcesList/SourcesList'; +import MessageActions from '../MessageActions/MessageActions'; const fadeIn = keyframes` from { opacity: 0; transform: translateY(10px); } @@ -12,10 +17,16 @@ const fadeIn = keyframes` const MessageContainer = styled.div<{ $isUser: boolean }>` display: flex; flex-direction: column; - align-items: ${(props) => (props.$isUser ? "flex-end" : "flex-start")}; + align-items: ${(props) => (props.$isUser ? 'flex-end' : 'flex-start')}; margin-bottom: 1.5rem; width: 100%; animation: ${fadeIn} 0.3s ease-out; + + &:hover [data-message-actions], + &:focus-within [data-message-actions] { + opacity: 1 !important; + pointer-events: auto !important; + } `; const Bubble = styled.div<{ $isUser: boolean }>` @@ -28,33 +39,33 @@ const Bubble = styled.div<{ $isUser: boolean }>` props.$isUser ? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)` : props.theme.darkMode - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.06)"}; + ? 'rgba(255, 255, 255, 0.1)' + : 'rgba(0, 0, 0, 0.06)'}; color: ${(props) => - props.$isUser || props.theme.darkMode ? "#fff" : props.theme.colors.text}; + props.$isUser || props.theme.darkMode ? '#fff' : props.theme.colors.text}; backdrop-filter: blur(10px); border: 1px solid ${(props) => props.$isUser ? props.theme.darkMode - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)" + ? 'rgba(255, 255, 255, 0.1)' + : 'rgba(0, 0, 0, 0.1)' : props.theme.darkMode - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.08)"}; + ? 'rgba(255, 255, 255, 0.1)' + : 'rgba(0, 0, 0, 0.08)'}; box-shadow: ${(props) => props.$isUser || props.theme.darkMode - ? "0 4px 15px rgba(0, 0, 0, 0.2)" - : "0 2px 10px rgba(0, 0, 0, 0.08)"}; + ? '0 4px 15px rgba(0, 0, 0, 0.2)' + : '0 2px 10px rgba(0, 0, 0, 0.08)'}; font-size: 1rem; line-height: 1.6; - border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")}; - border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")}; + border-bottom-right-radius: ${(props) => (props.$isUser ? '0.2rem' : '1.2rem')}; + border-bottom-left-radius: ${(props) => (props.$isUser ? '1.2rem' : '0.2rem')}; & pre { background: ${(props) => props.$isUser || props.theme.darkMode - ? "rgba(0, 0, 0, 0.3)" - : "rgba(0, 0, 0, 0.06)"}; + ? 'rgba(0, 0, 0, 0.3)' + : 'rgba(0, 0, 0, 0.06)'}; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; @@ -68,7 +79,7 @@ const Bubble = styled.div<{ $isUser: boolean }>` & a { color: ${(props) => - props.$isUser || props.theme.darkMode ? "#a0c4ff" : props.theme.main}; + props.$isUser || props.theme.darkMode ? '#a0c4ff' : props.theme.main}; text-decoration: underline; } @@ -129,9 +140,54 @@ const UpgradeLink = styled(Link)` } `; +const CitationButton = styled.button` + display: inline; + margin: 0 0.1rem; + padding: 0 0.25rem; + border: none; + border-radius: 0.25rem; + background: ${({ theme }) => + theme.darkMode ? 'rgba(160, 196, 255, 0.2)' : 'rgba(51, 102, 153, 0.12)'}; + color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)}; + font: inherit; + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + vertical-align: baseline; + + &:hover, + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.main}; + outline-offset: 1px; + } +`; + +/** Linkify [n] / [1, 2] outside fenced code blocks. */ +export function injectCitationMarkers(markdown: string): string { + const parts = markdown.split(/(```[\s\S]*?```)/g); + return parts + .map((part) => { + if (part.startsWith('```')) return part; + return part.replace( + /\[(\d+(?:\s*,\s*\d+)*)\]/g, + (_match, nums: string) => + ``, + ); + }) + .join(''); +} + type ConversationDetailCardProps = { message: string; user_created: boolean; + promptId?: number; + citations?: Citation[]; + isStreaming?: boolean; + isLast?: boolean; + conversationTitle?: string; + createdTimestamp?: Date | string | null; + initialRating?: PromptRating | null; + onRatingChange?: (rating: PromptRating | null) => void; }; const MyPlot = ({ format, image }: { format: string; image: string }) => { @@ -139,7 +195,7 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => { return ( plot ); @@ -147,7 +203,7 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => { const MyError = ({ content }: { content: string }) => { return ( - + Error: {content} ); @@ -156,7 +212,59 @@ const MyError = ({ content }: { content: string }) => { const ConversationDetailCard = ({ message, user_created, + promptId, + citations = [], + isStreaming = false, + isLast = false, + conversationTitle = 'conversation', + createdTimestamp = null, + initialRating = null, + onRatingChange, }: ConversationDetailCardProps): JSX.Element => { + const [highlightIndex, setHighlightIndex] = useState(null); + + const CitationMark = useCallback( + ({ indices }: { indices?: string }) => { + const list = (indices || '') + .split(',') + .map((n) => Number(n.trim())) + .filter((n) => !Number.isNaN(n)); + if (!list.length) return null; + return ( + <> + {list.map((index, i) => { + const citation = citations.find((c) => c.index === index); + return ( + { + setHighlightIndex(index); + if (citation?.url) { + // Prefer scroll/highlight; URL still available from Sources list + } + const el = document.getElementById(`citation-source-${index}`); + el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); + }} + > + [{index}] + + ); + })} + + ); + }, + [citations], + ); + + const displayMarkdown = useMemo(() => { + if (!message) return message; + if (!citations.length || user_created) return message; + return injectCitationMarkers(message); + }, [message, citations, user_created]); + if (message.length === 0) { return ( @@ -188,36 +296,45 @@ const ConversationDetailCard = ({ ); } - let contentToAdd = message; + let contentToAdd = displayMarkdown; + let rawForCopy = message; try { const parsedMessage = JSON.parse(message); if ( parsedMessage && - typeof parsedMessage === "object" && + typeof parsedMessage === 'object' && parsedMessage.type ) { switch (parsedMessage.type) { - case "text": - contentToAdd = parsedMessage.content; + case 'text': + rawForCopy = parsedMessage.content; + contentToAdd = + citations.length && !user_created + ? injectCitationMarkers(parsedMessage.content) + : parsedMessage.content; break; - case "plot": + case 'plot': contentToAdd = ``; + rawForCopy = message; break; - case "error": + case 'error': contentToAdd = ``; + rawForCopy = message; break; default: break; } } - } catch { } + } catch { + /* plain markdown */ + } return ( {contentToAdd} + + {!user_created && citations.length > 0 && ( + + )} + + ); }; diff --git a/llm-fe/src/llm-fe/components/CustomCodeBlock/CustomCodeBlock.tsx b/llm-fe/src/llm-fe/components/CustomCodeBlock/CustomCodeBlock.tsx index 226f890..91c1e82 100644 --- a/llm-fe/src/llm-fe/components/CustomCodeBlock/CustomCodeBlock.tsx +++ b/llm-fe/src/llm-fe/components/CustomCodeBlock/CustomCodeBlock.tsx @@ -1,25 +1,72 @@ -import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter"; -import tsx from "react-syntax-highlighter/dist/cjs/languages/prism/tsx"; -import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; +import tsx from 'react-syntax-highlighter/dist/cjs/languages/prism/tsx'; +import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'; +import ContentCopy from '@mui/icons-material/ContentCopy'; +import Check from '@mui/icons-material/Check'; +import { IconButton, Tooltip } from '@mui/material'; +import { copyTextToClipboard } from '../../utils/clipboard'; +import { showToast } from '../../utils/toastBus'; -SyntaxHighlighter.registerLanguage("tsx", tsx); +SyntaxHighlighter.registerLanguage('tsx', tsx); -type CustomCodeBlock = { - children: string, - className: string +const Wrapper = styled.div` + position: relative; + max-width: 100%; +`; -} +const CopyBtn = styled(IconButton)` + && { + position: absolute; + top: 0.35rem; + right: 0.35rem; + z-index: 1; + color: #fff; + background: rgba(0, 0, 0, 0.35); + padding: 0.25rem; + &:hover { + background: rgba(0, 0, 0, 0.55); + } + } +`; +type CustomCodeBlockProps = { + children: string; + className?: string; +}; -const CustomCodeBlock = ({children, className}: CustomCodeBlock): JSX.Element => { - const language = className?.replace("lang-",""); - return ( - - {children} - - ) -} +const CustomCodeBlock = ({ + children, + className, +}: CustomCodeBlockProps): JSX.Element => { + const [copied, setCopied] = useState(false); + const language = className?.replace('lang-', '').replace('language-', '') || undefined; + const code = typeof children === 'string' ? children : String(children ?? ''); + + const handleCopy = async () => { + try { + await copyTextToClipboard(code.replace(/\n$/, '')); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + showToast('Could not copy code', 'error'); + } + }; + + return ( + + + void handleCopy()}> + {copied ? : } + + + + {code} + + + ); +}; export default CustomCodeBlock; - diff --git a/llm-fe/src/llm-fe/components/CustomPreBlock/CustomPreBlock.tsx b/llm-fe/src/llm-fe/components/CustomPreBlock/CustomPreBlock.tsx index 72ab5f0..474faec 100644 --- a/llm-fe/src/llm-fe/components/CustomPreBlock/CustomPreBlock.tsx +++ b/llm-fe/src/llm-fe/components/CustomPreBlock/CustomPreBlock.tsx @@ -1,14 +1,22 @@ -import CustomCodeBlock from "../CustomCodeBlock/CustomCodeBlock" +import CustomCodeBlock from '../CustomCodeBlock/CustomCodeBlock'; type CustomPreBlockProps = { - children: JSX.Element | JSX.Element[] -} + children: JSX.Element | JSX.Element[]; +}; -const CustomPreBlock = ({children, ...rest}: CustomPreBlockProps): JSX.Element => { - if ("type" in children && children["type"] === "code") { - return CustomCodeBlock({children: children["props"]["children"], className: children["props"]["className"] }); - } - - return
{children}
-} -export default CustomPreBlock; \ No newline at end of file +const CustomPreBlock = ({ children, ...rest }: CustomPreBlockProps): JSX.Element => { + const child = Array.isArray(children) ? children[0] : children; + if (child && typeof child === 'object' && 'type' in child && child.type === 'code') { + return ( + + {(child.props as { children?: string }).children as string} + + ); + } + + return
{children}
; +}; + +export default CustomPreBlock; diff --git a/llm-fe/src/llm-fe/components/MessageActions/MessageActions.tsx b/llm-fe/src/llm-fe/components/MessageActions/MessageActions.tsx new file mode 100644 index 0000000..7dcf13e --- /dev/null +++ b/llm-fe/src/llm-fe/components/MessageActions/MessageActions.tsx @@ -0,0 +1,362 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { + IconButton, + Tooltip, + Menu, + MenuItem, + Popover, + Chip, + TextField, + Button, + Stack, +} from '@mui/material'; +import ContentCopy from '@mui/icons-material/ContentCopy'; +import Check from '@mui/icons-material/Check'; +import ThumbUp from '@mui/icons-material/ThumbUp'; +import ThumbUpOutlined from '@mui/icons-material/ThumbUpOutlined'; +import ThumbDown from '@mui/icons-material/ThumbDown'; +import ThumbDownOutlined from '@mui/icons-material/ThumbDownOutlined'; +import FileDownload from '@mui/icons-material/FileDownload'; +import { copyTextToClipboard } from '../../utils/clipboard'; +import { showToast } from '../../utils/toastBus'; +import { + clearPromptFeedback, + upsertPromptFeedback, + type PromptRating, +} from '../../utils/promptFeedback'; +import { + exportChat, + type ExportFormat, + type ExportTurn, +} from '../../utils/export/exportChat'; +import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; +import type { Citation } from '../../utils/wsFrames'; + +const DOWN_REASONS: { code: string; label: string }[] = [ + { code: 'incorrect', label: 'Incorrect' }, + { code: 'out_of_date', label: 'Out of date' }, + { code: 'didnt_follow_instructions', label: "Didn't follow instructions" }, + { code: 'unsafe', label: 'Unsafe' }, + { code: 'other', label: 'Other' }, +]; + +const ActionsRow = styled.div<{ $alwaysVisible: boolean }>` + display: flex; + align-items: center; + gap: 0.15rem; + margin-top: 0.35rem; + min-height: 2rem; + max-width: 100%; + flex-wrap: wrap; + opacity: ${({ $alwaysVisible }) => ($alwaysVisible ? 1 : 0)}; + pointer-events: ${({ $alwaysVisible }) => ($alwaysVisible ? 'auto' : 'none')}; + transition: opacity 0.15s ease; + + @media (hover: hover) and (pointer: fine) { + /* Desktop: revealed via parent :hover / :focus-within when not forced visible */ + } + + @media (hover: none), (pointer: coarse) { + opacity: 1; + pointer-events: auto; + } +`; + +const ActionIconButton = styled(IconButton)` + && { + color: ${({ theme }) => theme.colors.text}; + opacity: 0.7; + padding: 0.35rem; + + &:hover, + &:focus-visible { + opacity: 1; + background: ${({ theme }) => + theme.darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}; + } + + &.Mui-disabled { + opacity: 0.35; + } + } +`; + +type MessageActionsProps = { + promptId?: number; + rawMarkdown: string; + userCreated: boolean; + isStreaming?: boolean; + isLast?: boolean; + conversationTitle: string; + createdTimestamp?: Date | string | null; + citations?: Citation[]; + initialRating?: PromptRating | null; + onRatingChange?: (rating: PromptRating | null) => void; +}; + +const MessageActions = ({ + promptId, + rawMarkdown, + userCreated, + isStreaming = false, + isLast = false, + conversationTitle, + createdTimestamp, + citations = [], + initialRating = null, + onRatingChange, +}: MessageActionsProps): JSX.Element | null => { + const [copied, setCopied] = useState(false); + const [rating, setRating] = useState(initialRating); + const [exportAnchor, setExportAnchor] = useState(null); + const [reasonAnchor, setReasonAnchor] = useState(null); + const [reasonCode, setReasonCode] = useState(null); + const [reasonComment, setReasonComment] = useState(''); + const [focused, setFocused] = useState(false); + const copyTimer = useRef | null>(null); + const rowRef = useRef(null); + + useEffect(() => { + setRating(initialRating); + }, [initialRating, promptId]); + + useEffect(() => { + return () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }; + }, []); + + if (isStreaming) return null; + + const alwaysVisible = isLast || focused || Boolean(exportAnchor) || Boolean(reasonAnchor); + + const handleCopy = async () => { + try { + await copyTextToClipboard(rawMarkdown); + setCopied(true); + trackEvent(AnalyticsEvents.MESSAGE_COPIED, { + role: userCreated ? 'user' : 'assistant', + }); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 2000); + } catch { + showToast('Could not copy message', 'error'); + } + }; + + const applyRating = async (next: PromptRating | null) => { + if (!promptId) { + showToast('Rating unavailable until message is saved', 'info'); + return; + } + const prev = rating; + setRating(next); + onRatingChange?.(next); + try { + if (next == null) { + await clearPromptFeedback(promptId); + } else { + await upsertPromptFeedback(promptId, { rating: next }); + } + trackEvent(AnalyticsEvents.MESSAGE_RATED, { + rating: next ?? 'cleared', + }); + } catch { + setRating(prev); + onRatingChange?.(prev); + showToast('Could not save rating', 'error'); + } + }; + + const handleThumb = async ( + next: PromptRating, + event: React.MouseEvent, + ) => { + if (rating === next) { + await applyRating(null); + return; + } + await applyRating(next); + if (next === 'down') { + setReasonAnchor(event.currentTarget); + setReasonCode(null); + setReasonComment(''); + } + }; + + const submitReason = async () => { + if (!promptId || rating !== 'down') { + setReasonAnchor(null); + return; + } + try { + await upsertPromptFeedback(promptId, { + rating: 'down', + reason: reasonCode ?? undefined, + comment: reasonComment.trim() || undefined, + }); + trackEvent(AnalyticsEvents.MESSAGE_RATING_REASON, { + reason: reasonCode ?? 'none', + hasComment: Boolean(reasonComment.trim()), + }); + } catch { + showToast('Could not save feedback reason', 'error'); + } finally { + setReasonAnchor(null); + } + }; + + const handleExport = async (format: ExportFormat) => { + setExportAnchor(null); + const turn: ExportTurn = { + role: userCreated ? 'user' : 'assistant', + message: rawMarkdown, + timestamp: createdTimestamp ?? null, + citations: userCreated ? [] : citations, + }; + try { + await exportChat({ + format, + scope: 'message', + title: conversationTitle, + turns: [turn], + }); + trackEvent(AnalyticsEvents.MESSAGE_EXPORTED, { + format, + scope: 'message', + }); + } catch (err) { + console.error(err); + showToast('Export failed', 'error'); + } + }; + + return ( + setFocused(true)} + onBlurCapture={(e) => { + if (!rowRef.current?.contains(e.relatedTarget as Node)) { + setFocused(false); + } + }} + > + + void handleCopy()} + > + {copied ? : } + + + + {!userCreated && ( + <> + + + void handleThumb('up', e)} + > + {rating === 'up' ? ( + + ) : ( + + )} + + + + + + void handleThumb('down', e)} + > + {rating === 'down' ? ( + + ) : ( + + )} + + + + + )} + + + setExportAnchor(e.currentTarget)} + > + + + + + setExportAnchor(null)} + > + {( + [ + ['pdf', 'PDF'], + ['docx', 'Word (.docx)'], + ['csv', 'CSV'], + ['xlsx', 'Excel (.xlsx)'], + ['txt', 'Plain text'], + ] as [ExportFormat, string][] + ).map(([format, label]) => ( + void handleExport(format)}> + {label} + + ))} + + + setReasonAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + +
+ What went wrong? (optional) +
+ + {DOWN_REASONS.map((reason) => ( + setReasonCode(reason.code)} + /> + ))} + + setReasonComment(e.target.value)} + /> + +
+
+
+ ); +}; + +export default MessageActions; diff --git a/llm-fe/src/llm-fe/components/SourcesList/SourcesList.tsx b/llm-fe/src/llm-fe/components/SourcesList/SourcesList.tsx new file mode 100644 index 0000000..84e6798 --- /dev/null +++ b/llm-fe/src/llm-fe/components/SourcesList/SourcesList.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import type { Citation } from '../../utils/wsFrames'; + +const SourcesRoot = styled.aside` + margin-top: 0.65rem; + max-width: 80%; + min-width: 0; + width: 100%; + padding: 0.65rem 0.85rem; + border-radius: 0.75rem; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + background: ${({ theme }) => + theme.darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)'}; + color: ${({ theme }) => theme.colors.text}; + + @media (max-width: 768px) { + max-width: 92%; + } +`; + +const SourcesTitle = styled.h4` + margin: 0 0 0.5rem; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + opacity: 0.75; +`; + +const SourceList = styled.ol` + margin: 0; + padding-left: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +`; + +const SourceItem = styled.li<{ $highlight: boolean }>` + font-size: 0.85rem; + line-height: 1.4; + border-radius: 0.35rem; + padding: 0.15rem 0.25rem; + outline: ${({ $highlight, theme }) => + $highlight ? `2px solid ${theme.main}` : 'none'}; + background: ${({ $highlight, theme }) => + $highlight + ? theme.darkMode + ? 'rgba(255,255,255,0.08)' + : 'rgba(0,0,0,0.06)' + : 'transparent'}; + transition: background 0.2s ease, outline 0.2s ease; +`; + +const SourceLink = styled.a` + color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)}; + text-decoration: underline; + word-break: break-word; +`; + +const SourceMeta = styled.span` + display: block; + font-size: 0.75rem; + opacity: 0.65; + margin-top: 0.1rem; +`; + +function domainFromUrl(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return ''; + } +} + +type SourcesListProps = { + citations: Citation[]; + highlightIndex?: number | null; +}; + +const SourcesList = ({ + citations, + highlightIndex = null, +}: SourcesListProps): JSX.Element | null => { + const itemRefs = useRef>({}); + const [active, setActive] = useState(highlightIndex); + + useEffect(() => { + setActive(highlightIndex ?? null); + if (highlightIndex == null) return; + const el = itemRefs.current[highlightIndex]; + el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); + }, [highlightIndex]); + + if (!citations.length) return null; + + const sorted = [...citations].sort((a, b) => a.index - b.index); + + return ( + + Sources + + {sorted.map((citation) => { + const domain = domainFromUrl(citation.url); + return ( + { + itemRefs.current[citation.index] = node; + }} + > + {citation.url ? ( + + {citation.title || `Source ${citation.index}`} + + ) : ( + {citation.title || `Source ${citation.index}`} + )} + {(citation.published_at || domain) && ( + + {[citation.published_at, domain].filter(Boolean).join(' · ')} + + )} + + ); + })} + + + ); +}; + +export default SourcesList; diff --git a/llm-fe/src/llm-fe/components/ToastHost/ToastHost.tsx b/llm-fe/src/llm-fe/components/ToastHost/ToastHost.tsx new file mode 100644 index 0000000..ae2ab36 --- /dev/null +++ b/llm-fe/src/llm-fe/components/ToastHost/ToastHost.tsx @@ -0,0 +1,40 @@ +import React, { useEffect, useState } from 'react'; +import { Snackbar, Alert } from '@mui/material'; +import { subscribeToast } from '../../utils/toastBus'; + +/** + * Global snackbar host for copy/export/feedback errors (#97). + */ +const ToastHost = (): JSX.Element => { + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(''); + const [severity, setSeverity] = useState<'error' | 'success' | 'info'>('info'); + + useEffect(() => { + return subscribeToast((nextMessage, nextSeverity) => { + setMessage(nextMessage); + setSeverity(nextSeverity); + setOpen(true); + }); + }, []); + + return ( + setOpen(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setOpen(false)} + severity={severity} + variant="filled" + sx={{ width: '100%' }} + > + {message} + + + ); +}; + +export default ToastHost; diff --git a/llm-fe/src/llm-fe/contexts/MessageContext.tsx b/llm-fe/src/llm-fe/contexts/MessageContext.tsx index bceb53c..87eeca4 100644 --- a/llm-fe/src/llm-fe/contexts/MessageContext.tsx +++ b/llm-fe/src/llm-fe/contexts/MessageContext.tsx @@ -6,6 +6,7 @@ import { ConversationPrompt, ConversationPromptType } from "../data"; import { axiosInstance } from "../../axiosApi"; import { AxiosResponse } from "axios"; import { AnalyticsEvents, trackEvent } from "../utils/analytics"; +import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames"; type MessageProviderProps ={ children? : ReactNode; @@ -53,6 +54,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => { const selectedConversationRef = useRef(undefined) const isGeneratingRef = useRef(false) const prevReconnectGenerationRef = useRef(0) + const refetchTimerRef = useRef | null>(null) const clearStreamInterrupted = () => setStreamInterrupted(false) @@ -76,8 +78,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => { message: item.message, user_created: item.user_created, created_timestamp: item.created_timestamp, + created: item.created, tokens_in: item.tokens_in ?? null, tokens_out: item.tokens_out ?? null, + citations: item.citations ?? [], + feedback: item.feedback ?? null, }), ) if (tempConversations.length === 1) { @@ -93,10 +98,27 @@ const MessageProvider = ( {children}: MessageProviderProps) => { } } + const schedulePostStreamRefetch = () => { + if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current) + const conversationId = selectedConversationRef.current + if (!conversationId) return + // Backend saves the assistant prompt after END (+ citations frame); brief delay + // hydrates prompt ids, persisted citations, and feedback. + refetchTimerRef.current = setTimeout(() => { + void GetConversationDetails(conversationId) + }, 800) + } + useEffect(() => { GetConversationDetails(selectedConversation) }, [selectedConversation]) + useEffect(() => { + return () => { + if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current) + } + }, []) + // Streaming recovery: on reconnect after a drop mid-stream, refetch conversation // and mark the in-flight assistant turn interrupted (no resume protocol on backend). useEffect(() => { @@ -147,21 +169,46 @@ const MessageProvider = ( {children}: MessageProviderProps) => { /* subscribe to channel and register callback */ subscribe(channelName, (message: string) => { - /* when a message is received just add it to the UI */ - + /* Versioned frames (citations #98, status #96) — ignore unknown types safely */ + const frame = parseVersionedFrame(message) + if (frame) { + if (frame.type === 'citations') { + const citations = parseCitationsData(frame.data) + const details = [...conversationRef.current] + for (let i = details.length - 1; i >= 0; i -= 1) { + if (!details[i].user_created && details[i].message) { + details[i] = new ConversationPrompt({ + ...details[i], + citations, + }) + break + } + } + conversationRef.current = details + setConversationDetails(details) + schedulePostStreamRefetch() + } + // status / unknown types: no-op (compatible with #96) + return + } + if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){ messageResponsePart.current = 0 conversationRef.current.pop() - //handleAssistantPrompt({prompt: messageRef.current}) - setConversationDetails([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})]) - console.log([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})]) + const finalized = new ConversationPrompt({ + message: `${messageRef.current}`, + user_created: false, + }) + conversationRef.current = [...conversationRef.current, finalized] + setConversationDetails([...conversationRef.current]) messageRef.current = '' setStateMessage('') isGeneratingRef.current = false setIsGeneratingMessage(false) setStreamInterrupted(false) + schedulePostStreamRefetch() } else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){ conversationRef.current = conversationDetails @@ -176,23 +223,26 @@ const MessageProvider = ( {children}: MessageProviderProps) => { setStreamInterrupted(false) messageResponsePart.current = 1 }else{ - isGeneratingRef.current = true - setIsGeneratingMessage(true) if (messageResponsePart.current === 1){ + isGeneratingRef.current = true + setIsGeneratingMessage(true) // this has to do with the conversation id if(!selectedConversation){ const conversationId = Number(message); setSelectedConversation(conversationId) + selectedConversationRef.current = conversationId trackEvent(AnalyticsEvents.CONVERSATION_CREATED, { conversationId, }); } } else if (messageResponsePart.current === 2){ + isGeneratingRef.current = true + setIsGeneratingMessage(true) messageRef.current += message setStateMessage(messageRef.current) - } + // ignore stray frames outside an active stream phase } }) diff --git a/llm-fe/src/llm-fe/data.ts b/llm-fe/src/llm-fe/data.ts index 8f61fe3..7205d8e 100644 --- a/llm-fe/src/llm-fe/data.ts +++ b/llm-fe/src/llm-fe/data.ts @@ -1,11 +1,23 @@ +import type { Citation } from './utils/wsFrames'; +import type { PromptRating } from './utils/promptFeedback'; + +export type PromptFeedbackState = { + rating: PromptRating; + reason?: string | null; + comment?: string | null; +}; + /* Classes for the project */ export interface ConversationPromptType { id: number, message: string, user_created: boolean, - created_timestamp: Date, + created_timestamp?: Date, + created?: Date | string, tokens_in?: number | null, tokens_out?: number | null, + citations?: Citation[], + feedback?: PromptFeedbackState | null, } export class ConversationPrompt{ @@ -15,6 +27,8 @@ export class ConversationPrompt{ created_timestamp: Date = new Date(); tokens_in: number | null = null; tokens_out: number | null = null; + citations: Citation[] = []; + feedback: PromptFeedbackState | null = null; constructor(initializer?: any){ if(!initializer) return; @@ -22,8 +36,11 @@ export class ConversationPrompt{ if (initializer.message) this.message = initializer.message; if (initializer.user_created) this.user_created = initializer.user_created; if (initializer.created_timestamp) this.created_timestamp = initializer.created_timestamp; + else if (initializer.created) this.created_timestamp = new Date(initializer.created); if (initializer.tokens_in !== undefined) this.tokens_in = initializer.tokens_in; if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out; + if (Array.isArray(initializer.citations)) this.citations = initializer.citations; + if (initializer.feedback !== undefined) this.feedback = initializer.feedback; } } diff --git a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx index 61a9a9f..ecef0eb 100644 --- a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx +++ b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx @@ -2,8 +2,8 @@ import React, { useContext, useEffect, useRef, useState } from "react"; import styled, { ThemeContext } from "styled-components"; import { Formik, Form, Field } from "formik"; import * as Yup from "yup"; -import { AttachFile, Delete, Send, Close } from "@mui/icons-material"; // Keeping icons for now, can replace later if needed -import { Tooltip } from "@mui/material"; +import { AttachFile, Delete, Send, Close, FileDownload } from "@mui/icons-material"; +import { Tooltip, Menu, MenuItem, IconButton as MuiIconButton } from "@mui/material"; import Markdown from "markdown-to-jsx"; import { @@ -17,6 +17,9 @@ import ParticleBackground from "../../components/ParticleBackground/ParticleBack import Header2 from "../../components/Header2/Header2"; import { AnalyticsEvents, trackEvent } from "../../utils/analytics"; +import { exportChat, type ExportFormat } from "../../utils/export/exportChat"; +import { showToast } from "../../utils/toastBus"; +import type { PromptRating } from "../../utils/promptFeedback"; // Styled Components const PageContainer = styled.div` @@ -152,6 +155,18 @@ const ChatArea = styled.div` } `; +const ChatToolbar = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + padding: 0.25rem 2rem 0; + flex-shrink: 0; + + @media (max-width: 768px) { + padding: 0.25rem 0.85rem 0; + } +`; + const InputArea = styled.div` flex-shrink: 0; box-sizing: border-box; @@ -361,6 +376,57 @@ const AsyncDashboardInner = (): JSX.Element => { const theme = useContext(ThemeContext); const textareaRef = useRef(null); const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [exportAnchor, setExportAnchor] = useState(null); + + const selectedTitle = + conversations.find((c) => c.id === selectedConversation)?.title || + 'conversation'; + + const handleConversationExport = async (format: ExportFormat) => { + setExportAnchor(null); + const turns = conversationDetails + .filter((d) => d.message.length > 0) + .map((d) => ({ + role: (d.user_created ? 'user' : 'assistant') as 'user' | 'assistant', + message: d.message, + timestamp: d.created_timestamp, + citations: d.citations, + })); + if (!turns.length) { + showToast('Nothing to export yet', 'info'); + return; + } + try { + await exportChat({ + format, + scope: 'conversation', + title: selectedTitle, + turns, + }); + trackEvent(AnalyticsEvents.MESSAGE_EXPORTED, { + format, + scope: 'conversation', + }); + } catch (err) { + console.error(err); + showToast('Export failed', 'error'); + } + }; + + const updatePromptRating = (promptId: number | undefined, rating: PromptRating | null) => { + if (!promptId) return; + const next = conversationDetails.map((detail) => { + if (detail.id !== promptId) return detail; + return new ConversationPrompt({ + ...detail, + feedback: rating + ? { rating, reason: detail.feedback?.reason, comment: detail.feedback?.comment } + : null, + }); + }); + conversationRef.current = next; + setConversationDetails(next); + }; const connectionBanner = (() => { if (connectionStatus === ConnectionStatus.CONNECTED || isConnected) { @@ -483,23 +549,66 @@ const AsyncDashboardInner = (): JSX.Element => { + {conversationDetails.length > 0 && ( + + + setExportAnchor(e.currentTarget)} + sx={{ color: theme?.colors?.text }} + > + + + + setExportAnchor(null)} + > + {( + [ + ['pdf', 'PDF'], + ['docx', 'Word (.docx)'], + ['csv', 'CSV'], + ['xlsx', 'Excel (.xlsx)'], + ['txt', 'Plain text'], + ] as [ExportFormat, string][] + ).map(([format, label]) => ( + void handleConversationExport(format)}> + {label} + + ))} + + + )} {conversationDetails.length > 0 ? ( - conversationDetails.map((convo_detail, index) => - convo_detail.message.length > 0 ? ( + conversationDetails.map((convo_detail, index) => { + const isLast = index === conversationDetails.length - 1; + const isLiveStream = + !convo_detail.user_created && convo_detail.message.length === 0; + const displayMessage = isLiveStream + ? stateMessage + : convo_detail.message; + return ( - ) : ( - + updatePromptRating(convo_detail.id, rating) + } /> - ) - ) + ); + }) ) : (
{ + const originalClipboard = navigator.clipboard; + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); + jest.restoreAllMocks(); + }); + + it('uses navigator.clipboard.writeText when available', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + await copyTextToClipboard('hello'); + expect(writeText).toHaveBeenCalledWith('hello'); + }); + + it('falls back to execCommand when clipboard API fails', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: jest.fn().mockRejectedValue(new Error('denied')), + }, + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: jest.fn().mockReturnValue(true), + }); + await copyTextToClipboard('fallback text'); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); + + it('falls back when clipboard API is missing', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: jest.fn().mockReturnValue(true), + }); + await copyTextToClipboard('legacy'); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/clipboard.ts b/llm-fe/src/llm-fe/utils/clipboard.ts new file mode 100644 index 0000000..1b8fea4 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/clipboard.ts @@ -0,0 +1,33 @@ +/** + * Clipboard helper with execCommand fallback for Capacitor / insecure contexts (#97). + */ +export async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // fall through to legacy path + } + } + fallbackCopyText(text); +} + +function fallbackCopyText(text: string): void { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '0'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!ok) { + throw new Error('Copy command failed'); + } +} diff --git a/llm-fe/src/llm-fe/utils/downloadFile.ts b/llm-fe/src/llm-fe/utils/downloadFile.ts new file mode 100644 index 0000000..6b78a74 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/downloadFile.ts @@ -0,0 +1,24 @@ +/** + * Client-side file download. Uses blob URL + anchor click. + * Capacitor Android WebView: plain downloads often work for blob URLs; + * if not, consider @capacitor/filesystem + Share (#97). + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.rel = 'noopener'; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } finally { + setTimeout(() => URL.revokeObjectURL(url), 1000); + } +} + +export function downloadText(text: string, filename: string, mime = 'text/plain;charset=utf-8'): void { + downloadBlob(new Blob([text], { type: mime }), filename); +} diff --git a/llm-fe/src/llm-fe/utils/export/exportChat.test.ts b/llm-fe/src/llm-fe/utils/export/exportChat.test.ts new file mode 100644 index 0000000..19d789c --- /dev/null +++ b/llm-fe/src/llm-fe/utils/export/exportChat.test.ts @@ -0,0 +1,67 @@ +import { + buildExportFilename, + extractMarkdownTables, + parseMarkdownBlocks, + sanitizeFilenamePart, + stripMarkdown, +} from './markdownUtils'; +import { selectTabularStrategy } from './exportChat'; + +describe('export markdown utils', () => { + it('sanitises filenames with title + ISO date', () => { + expect(sanitizeFilenamePart('Hesychia Taylor Swift Question!')).toBe( + 'hesychia-taylor-swift-question', + ); + expect( + buildExportFilename('Hesychia Taylor Swift Question', 'pdf', new Date('2026-08-02T12:00:00Z')), + ).toBe('hesychia-taylor-swift-question-2026-08-02.pdf'); + }); + + it('strips markdown for plain text', () => { + expect(stripMarkdown('# Hello\n\n**world**')).toContain('Hello'); + expect(stripMarkdown('# Hello\n\n**world**')).not.toContain('**'); + }); + + it('parses headings, lists, tables, and code', () => { + const blocks = parseMarkdownBlocks( + [ + '# Title', + '', + '- a', + '- b', + '', + '```js', + 'console.log(1)', + '```', + '', + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + ].join('\n'), + ); + expect(blocks.find((b) => b.type === 'heading')).toMatchObject({ + level: 1, + text: 'Title', + }); + expect(blocks.find((b) => b.type === 'list')).toMatchObject({ + ordered: false, + items: ['a', 'b'], + }); + expect(blocks.find((b) => b.type === 'code')).toMatchObject({ + language: 'js', + text: 'console.log(1)', + }); + expect(blocks.find((b) => b.type === 'table')).toMatchObject({ + headers: ['A', 'B'], + rows: [['1', '2']], + }); + }); + + it('selects tabular strategy per CSV/XLSX rules', () => { + const withTable = '| A | B |\n| --- | --- |\n| 1 | 2 |'; + expect(selectTabularStrategy('message', withTable)).toBe('tables'); + expect(selectTabularStrategy('message', 'no table here')).toBe('turns'); + expect(selectTabularStrategy('conversation', withTable)).toBe('turns'); + expect(extractMarkdownTables(withTable)).toHaveLength(1); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/export/exportChat.ts b/llm-fe/src/llm-fe/utils/export/exportChat.ts new file mode 100644 index 0000000..e01597d --- /dev/null +++ b/llm-fe/src/llm-fe/utils/export/exportChat.ts @@ -0,0 +1,477 @@ +import { downloadBlob, downloadText } from '../downloadFile'; +import { Citation } from '../wsFrames'; +import { + buildExportFilename, + extractMarkdownTables, + parseMarkdownBlocks, + stripMarkdown, + type MdBlock, +} from './markdownUtils'; + +export type ExportFormat = 'pdf' | 'docx' | 'csv' | 'xlsx' | 'txt'; + +export type ExportTurn = { + role: 'user' | 'assistant'; + message: string; + timestamp?: string | Date | null; + citations?: Citation[]; +}; + +export type ExportScope = 'message' | 'conversation'; + +export type ExportOptions = { + format: ExportFormat; + scope: ExportScope; + title: string; + turns: ExportTurn[]; +}; + +function yieldToUi(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function formatTs(value?: string | Date | null): string { + if (!value) return ''; + try { + return new Date(value).toISOString(); + } catch { + return String(value); + } +} + +function roleLabel(role: ExportTurn['role']): string { + return role === 'user' ? 'User' : 'Assistant'; +} + +function citationFootnotes(citations?: Citation[]): string { + if (!citations?.length) return ''; + return citations + .map((c) => `[${c.index}] ${c.title}${c.url ? ` — ${c.url}` : ''}`) + .join('\n'); +} + +export async function exportChat(options: ExportOptions): Promise { + const { format, scope, title, turns } = options; + const filename = buildExportFilename(title, format === 'docx' ? 'docx' : format); + const exportedAt = new Date().toISOString(); + + // Yield so large conversations don't freeze the main thread (#97). + await yieldToUi(); + + switch (format) { + case 'txt': + downloadText(buildTxt(title, turns, scope), filename); + break; + case 'csv': + await exportCsv(title, turns, scope, filename); + break; + case 'xlsx': + await exportXlsx(title, turns, scope, filename); + break; + case 'pdf': + await exportPdf(title, turns, exportedAt, filename); + break; + case 'docx': + await exportDocx(title, turns, exportedAt, filename); + break; + default: + throw new Error(`Unsupported export format: ${format}`); + } +} + +function buildTxt(title: string, turns: ExportTurn[], scope: ExportScope): string { + if (scope === 'message' && turns.length === 1) { + const turn = turns[0]; + const body = stripMarkdown(turn.message); + const notes = citationFootnotes(turn.citations); + return notes ? `${body}\n\nSources:\n${notes}` : body; + } + const parts = [`# ${title}`, '']; + for (const turn of turns) { + const ts = formatTs(turn.timestamp); + parts.push(`[${roleLabel(turn.role)}${ts ? ` · ${ts}` : ''}]`); + parts.push(stripMarkdown(turn.message)); + const notes = citationFootnotes(turn.citations); + if (notes) { + parts.push('Sources:'); + parts.push(notes); + } + parts.push(''); + } + return parts.join('\n'); +} + +function turnsAsRows(turns: ExportTurn[]): string[][] { + const header = ['timestamp', 'role', 'message', 'citations']; + const rows = turns.map((turn) => [ + formatTs(turn.timestamp), + roleLabel(turn.role), + stripMarkdown(turn.message), + citationFootnotes(turn.citations).replace(/\n/g, ' | '), + ]); + return [header, ...rows]; +} + +async function exportCsv( + _title: string, + turns: ExportTurn[], + scope: ExportScope, + filename: string, +): Promise { + const Papa = (await import('papaparse')).default; + let matrix: string[][]; + + if (scope === 'message' && turns.length === 1) { + const tables = extractMarkdownTables(turns[0].message); + if (tables.length > 0) { + const table = tables[0]; + matrix = [table.headers, ...table.rows]; + } else { + matrix = turnsAsRows(turns); + } + } else { + matrix = turnsAsRows(turns); + } + + const csv = Papa.unparse(matrix); + downloadText(csv, filename, 'text/csv;charset=utf-8'); +} + +async function exportXlsx( + _title: string, + turns: ExportTurn[], + scope: ExportScope, + filename: string, +): Promise { + const XLSX = await import('xlsx'); + const workbook = XLSX.utils.book_new(); + + if (scope === 'message' && turns.length === 1) { + const tables = extractMarkdownTables(turns[0].message); + if (tables.length > 0) { + tables.forEach((table, idx) => { + const sheet = XLSX.utils.aoa_to_sheet([table.headers, ...table.rows]); + XLSX.utils.book_append_sheet(workbook, sheet, `Table ${idx + 1}`); + }); + } else { + const sheet = XLSX.utils.aoa_to_sheet(turnsAsRows(turns)); + XLSX.utils.book_append_sheet(workbook, sheet, 'Messages'); + } + } else { + const sheet = XLSX.utils.aoa_to_sheet(turnsAsRows(turns)); + XLSX.utils.book_append_sheet(workbook, sheet, 'Messages'); + } + + const arrayBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); + downloadBlob( + new Blob([arrayBuffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + filename, + ); +} + +function blocksToPdfContent(blocks: MdBlock[]): unknown[] { + const content: unknown[] = []; + for (const block of blocks) { + switch (block.type) { + case 'heading': + content.push({ + text: block.text, + style: `h${Math.min(block.level, 3)}`, + margin: [0, 8, 0, 4], + }); + break; + case 'paragraph': + content.push({ text: block.text, margin: [0, 2, 0, 6] }); + break; + case 'code': + content.push({ + text: block.text, + fontSize: 9, + preserveLeadingSpaces: true, + margin: [0, 4, 0, 8], + background: '#f5f5f5', + }); + break; + case 'list': + content.push( + block.ordered + ? { ol: block.items, margin: [0, 2, 0, 6] } + : { ul: block.items, margin: [0, 2, 0, 6] }, + ); + break; + case 'table': + content.push({ + table: { + headerRows: 1, + widths: block.headers.map(() => '*'), + body: [ + block.headers.map((h) => ({ text: h, bold: true })), + ...block.rows.map((row) => + block.headers.map((_, col) => row[col] ?? ''), + ), + ], + }, + margin: [0, 4, 0, 8], + }); + break; + default: + break; + } + } + return content; +} + +async function exportPdf( + title: string, + turns: ExportTurn[], + exportedAt: string, + filename: string, +): Promise { + const pdfMakeModule = await import('pdfmake/build/pdfmake'); + const pdfFonts = await import('pdfmake/build/vfs_fonts'); + const pdfMake = pdfMakeModule.default || pdfMakeModule; + // vfs_fonts may export vfs on default or as pdfMake.vfs + const vfs = + (pdfFonts as { pdfMake?: { vfs?: unknown }; default?: { pdfMake?: { vfs?: unknown } } }) + .pdfMake?.vfs || + (pdfFonts as { default?: { pdfMake?: { vfs?: unknown } } }).default?.pdfMake?.vfs || + (pdfFonts as { default?: unknown }).default; + if (vfs) { + (pdfMake as { vfs?: unknown }).vfs = vfs; + } + + const content: unknown[] = [ + { text: title, style: 'title' }, + { text: `Exported ${exportedAt}`, style: 'meta', margin: [0, 0, 0, 16] }, + ]; + + for (let i = 0; i < turns.length; i += 1) { + if (i > 0 && i % 20 === 0) await yieldToUi(); + const turn = turns[i]; + if (turns.length > 1) { + content.push({ + text: `${roleLabel(turn.role)}${turn.timestamp ? ` · ${formatTs(turn.timestamp)}` : ''}`, + style: 'role', + margin: [0, 12, 0, 4], + }); + } + content.push(...blocksToPdfContent(parseMarkdownBlocks(turn.message))); + if (turn.citations?.length) { + content.push({ text: 'Sources', style: 'h3', margin: [0, 8, 0, 4] }); + content.push({ + ol: turn.citations + .slice() + .sort((a, b) => a.index - b.index) + .map((c) => `${c.title}${c.url ? ` (${c.url})` : ''}`), + }); + } + } + + const docDefinition = { + content, + styles: { + title: { fontSize: 18, bold: true }, + meta: { fontSize: 9, color: '#666666' }, + role: { fontSize: 11, bold: true, color: '#333333' }, + h1: { fontSize: 16, bold: true }, + h2: { fontSize: 14, bold: true }, + h3: { fontSize: 12, bold: true }, + }, + defaultStyle: { fontSize: 11 }, + header: { + text: `${title} · ${exportedAt}`, + fontSize: 8, + color: '#888888', + margin: [40, 20, 40, 0] as [number, number, number, number], + }, + }; + + await new Promise((resolve, reject) => { + try { + // pdfmake typings vary across 0.2/0.3 builds; keep runtime call flexible. + const pdf = (pdfMake as { createPdf: (def: unknown) => { getBlob: (cb: (blob: Blob) => void) => void } }).createPdf( + docDefinition, + ); + pdf.getBlob((blob: Blob) => { + downloadBlob(blob, filename); + resolve(); + }); + } catch (err) { + reject(err); + } + }); +} + +async function exportDocx( + title: string, + turns: ExportTurn[], + exportedAt: string, + filename: string, +): Promise { + const { + Document, + Packer, + Paragraph, + TextRun, + HeadingLevel, + Table, + TableRow, + TableCell, + WidthType, + Header, + } = await import('docx'); + + const children: InstanceType[] = [ + new Paragraph({ + text: title, + heading: HeadingLevel.TITLE, + }), + new Paragraph({ + children: [ + new TextRun({ text: `Exported ${exportedAt}`, italics: true, size: 18, color: '666666' }), + ], + }), + ]; + + for (let i = 0; i < turns.length; i += 1) { + if (i > 0 && i % 20 === 0) await yieldToUi(); + const turn = turns[i]; + if (turns.length > 1) { + children.push( + new Paragraph({ + children: [ + new TextRun({ + text: `${roleLabel(turn.role)}${turn.timestamp ? ` · ${formatTs(turn.timestamp)}` : ''}`, + bold: true, + }), + ], + spacing: { before: 240 }, + }), + ); + } + + for (const block of parseMarkdownBlocks(turn.message)) { + switch (block.type) { + case 'heading': { + const level = + block.level === 1 + ? HeadingLevel.HEADING_1 + : block.level === 2 + ? HeadingLevel.HEADING_2 + : HeadingLevel.HEADING_3; + children.push(new Paragraph({ text: block.text, heading: level })); + break; + } + case 'paragraph': + children.push(new Paragraph({ text: block.text })); + break; + case 'code': + children.push( + new Paragraph({ + children: [new TextRun({ text: block.text, font: 'Courier New', size: 18 })], + }), + ); + break; + case 'list': + block.items.forEach((item, idx) => { + children.push( + new Paragraph({ + text: block.ordered ? `${idx + 1}. ${item}` : `• ${item}`, + }), + ); + }); + break; + case 'table': + children.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: block.headers.map( + (h) => + new TableCell({ + children: [ + new Paragraph({ + children: [new TextRun({ text: h, bold: true })], + }), + ], + }), + ), + }), + ...block.rows.map( + (row) => + new TableRow({ + children: block.headers.map( + (_, col) => + new TableCell({ + children: [new Paragraph({ text: row[col] ?? '' })], + }), + ), + }), + ), + ], + }), + ); + break; + default: + break; + } + } + + if (turn.citations?.length) { + children.push( + new Paragraph({ + text: 'Sources', + heading: HeadingLevel.HEADING_3, + }), + ); + turn.citations + .slice() + .sort((a, b) => a.index - b.index) + .forEach((c) => { + children.push( + new Paragraph({ + text: `[${c.index}] ${c.title}${c.url ? ` — ${c.url}` : ''}`, + }), + ); + }); + } + } + + const doc = new Document({ + sections: [ + { + headers: { + default: new Header({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: `${title} · ${exportedAt}`, + size: 16, + color: '888888', + }), + ], + }), + ], + }), + }, + children, + }, + ], + }); + + const blob = await Packer.toBlob(doc); + downloadBlob(blob, filename); +} + +/** Decide CSV/XLSX matrix strategy — exported for unit tests. */ +export function selectTabularStrategy( + scope: ExportScope, + messageMarkdown: string, +): 'tables' | 'turns' { + if (scope === 'conversation') return 'turns'; + return extractMarkdownTables(messageMarkdown).length > 0 ? 'tables' : 'turns'; +} diff --git a/llm-fe/src/llm-fe/utils/export/markdownUtils.ts b/llm-fe/src/llm-fe/utils/export/markdownUtils.ts new file mode 100644 index 0000000..621a024 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/export/markdownUtils.ts @@ -0,0 +1,134 @@ +export function sanitizeFilenamePart(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'conversation'; +} + +export function buildExportFilename( + conversationTitle: string, + extension: string, + date = new Date(), +): string { + const day = date.toISOString().slice(0, 10); + const base = sanitizeFilenamePart(conversationTitle || 'conversation'); + const ext = extension.replace(/^\./, ''); + return `${base}-${day}.${ext}`; +} + +/** Strip markdown to plain text for TXT / tabular message cells. */ +export function stripMarkdown(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, (block) => + block.replace(/```\w*\n?/, '').replace(/```$/, ''), + ) + .replace(/!\[[^\]]*]\([^)]+\)/g, '') + .replace(/\[([^\]]+)]\([^)]+\)/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/^\s*[-*+]\s+/gm, '') + .replace(/^\s*\d+\.\s+/gm, '') + .replace(/[*_~`]+/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export type MdBlock = + | { type: 'heading'; level: number; text: string } + | { type: 'paragraph'; text: string } + | { type: 'code'; language: string; text: string } + | { type: 'list'; ordered: boolean; items: string[] } + | { type: 'table'; headers: string[]; rows: string[][] }; + +/** Lightweight markdown → block AST for PDF/DOCX exporters. */ +export function parseMarkdownBlocks(markdown: string): MdBlock[] { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const blocks: MdBlock[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (/^```/.test(line)) { + const language = line.replace(/^```/, '').trim(); + const body: string[] = []; + i += 1; + while (i < lines.length && !/^```/.test(lines[i])) { + body.push(lines[i]); + i += 1; + } + blocks.push({ type: 'code', language, text: body.join('\n') }); + i += 1; + continue; + } + + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + blocks.push({ + type: 'heading', + level: heading[1].length, + text: heading[2].trim(), + }); + i += 1; + continue; + } + + if (/^\|.+\|$/.test(line.trim()) && i + 1 < lines.length && /^\|?\s*[-:| ]+\|?$/.test(lines[i + 1].trim())) { + const headers = splitTableRow(line); + i += 2; + const rows: string[][] = []; + while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) { + rows.push(splitTableRow(lines[i])); + i += 1; + } + blocks.push({ type: 'table', headers, rows }); + continue; + } + + if (/^\s*[-*+]\s+/.test(line) || /^\s*\d+\.\s+/.test(line)) { + const ordered = /^\s*\d+\.\s+/.test(line); + const items: string[] = []; + while ( + i < lines.length && + (ordered ? /^\s*\d+\.\s+/.test(lines[i]) : /^\s*[-*+]\s+/.test(lines[i])) + ) { + items.push(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, '').trim()); + i += 1; + } + blocks.push({ type: 'list', ordered, items }); + continue; + } + + if (!line.trim()) { + i += 1; + continue; + } + + const para: string[] = []; + while (i < lines.length && lines[i].trim() && !/^```/.test(lines[i]) && !/^(#{1,6})\s+/.test(lines[i])) { + if (/^\|.+\|$/.test(lines[i].trim())) break; + if (/^\s*[-*+]\s+/.test(lines[i]) || /^\s*\d+\.\s+/.test(lines[i])) break; + para.push(lines[i]); + i += 1; + } + blocks.push({ type: 'paragraph', text: para.join(' ').trim() }); + } + + return blocks; +} + +function splitTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); +} + +/** Extract markdown tables from a single message (for CSV/XLSX tabular rules). */ +export function extractMarkdownTables(markdown: string): { headers: string[]; rows: string[][] }[] { + return parseMarkdownBlocks(markdown) + .filter((b): b is Extract => b.type === 'table') + .map(({ headers, rows }) => ({ headers, rows })); +} diff --git a/llm-fe/src/llm-fe/utils/promptFeedback.test.ts b/llm-fe/src/llm-fe/utils/promptFeedback.test.ts new file mode 100644 index 0000000..cd33dc2 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/promptFeedback.test.ts @@ -0,0 +1,45 @@ +import { upsertPromptFeedback, clearPromptFeedback } from './promptFeedback'; +import { axiosInstance } from '../../axiosApi'; + +jest.mock('../../axiosApi', () => ({ + axiosInstance: { + post: jest.fn(), + delete: jest.fn(), + }, +})); + +describe('promptFeedback optimistic helpers', () => { + const post = axiosInstance.post as jest.Mock; + const del = axiosInstance.delete as jest.Mock; + + beforeEach(() => { + post.mockReset(); + del.mockReset(); + }); + + it('posts upsert payload', async () => { + post.mockResolvedValue({ data: { rating: 'up' } }); + await upsertPromptFeedback(42, { rating: 'up' }); + expect(post).toHaveBeenCalledWith('prompt_feedback', { + prompt_id: 42, + rating: 'up', + reason: undefined, + comment: undefined, + }); + }); + + it('deletes vote by prompt_id', async () => { + del.mockResolvedValue({}); + await clearPromptFeedback(42); + expect(del).toHaveBeenCalledWith('prompt_feedback', { + params: { prompt_id: 42 }, + }); + }); + + it('surfaces request failures for rollback callers', async () => { + post.mockRejectedValue(new Error('network')); + await expect(upsertPromptFeedback(1, { rating: 'down' })).rejects.toThrow( + 'network', + ); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/promptFeedback.ts b/llm-fe/src/llm-fe/utils/promptFeedback.ts new file mode 100644 index 0000000..dc34b3f --- /dev/null +++ b/llm-fe/src/llm-fe/utils/promptFeedback.ts @@ -0,0 +1,39 @@ +import { axiosInstance } from '../../axiosApi'; + +export type PromptRating = 'up' | 'down'; + +export type PromptFeedbackPayload = { + rating: PromptRating; + reason?: string | null; + comment?: string | null; +}; + +export type PromptFeedbackResponse = PromptFeedbackPayload & { + id?: number; + prompt_id?: number; +}; + +/** + * Upsert thumbs rating for an assistant prompt (chat_backend#67). + */ +export async function upsertPromptFeedback( + promptId: number, + payload: PromptFeedbackPayload, +): Promise { + const { data } = await axiosInstance.post('prompt_feedback', { + prompt_id: promptId, + rating: payload.rating, + reason: payload.reason ?? undefined, + comment: payload.comment ?? undefined, + }); + return data; +} + +/** + * Clear a vote for an assistant prompt. + */ +export async function clearPromptFeedback(promptId: number): Promise { + await axiosInstance.delete('prompt_feedback', { + params: { prompt_id: promptId }, + }); +} diff --git a/llm-fe/src/llm-fe/utils/toastBus.ts b/llm-fe/src/llm-fe/utils/toastBus.ts new file mode 100644 index 0000000..678e1a6 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/toastBus.ts @@ -0,0 +1,17 @@ +type ToastListener = (message: string, severity: 'error' | 'success' | 'info') => void; + +const listeners = new Set(); + +export function subscribeToast(listener: ToastListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function showToast( + message: string, + severity: 'error' | 'success' | 'info' = 'info', +): void { + listeners.forEach((listener) => listener(message, severity)); +} diff --git a/llm-fe/src/llm-fe/utils/wsFrames.test.ts b/llm-fe/src/llm-fe/utils/wsFrames.test.ts new file mode 100644 index 0000000..73647b8 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/wsFrames.test.ts @@ -0,0 +1,52 @@ +import { + parseCitationsData, + parseVersionedFrame, +} from './wsFrames'; + +describe('wsFrames', () => { + it('parses a citations frame', () => { + const raw = JSON.stringify({ + v: 1, + type: 'citations', + data: [ + { + index: 1, + title: 'Example', + url: 'https://example.com', + published_at: '2026-07-03', + }, + ], + }); + const frame = parseVersionedFrame(raw); + expect(frame).toEqual({ + v: 1, + type: 'citations', + data: [ + { + index: 1, + title: 'Example', + url: 'https://example.com', + published_at: '2026-07-03', + }, + ], + }); + expect(parseCitationsData(frame!.data)).toHaveLength(1); + }); + + it('ignores unknown types without throwing', () => { + const frame = parseVersionedFrame( + JSON.stringify({ v: 1, type: 'status', data: { stage: 'search' } }), + ); + expect(frame?.type).toBe('status'); + }); + + it('returns null for sentinels and plain text', () => { + expect(parseVersionedFrame('END_OF_THE_STREAM_ENDER_GAME_42')).toBeNull(); + expect(parseVersionedFrame('hello world')).toBeNull(); + }); + + it('returns empty citations for bad data', () => { + expect(parseCitationsData(null)).toEqual([]); + expect(parseCitationsData('nope')).toEqual([]); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/wsFrames.ts b/llm-fe/src/llm-fe/utils/wsFrames.ts new file mode 100644 index 0000000..fb82a29 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/wsFrames.ts @@ -0,0 +1,56 @@ +/** + * Versioned WebSocket frame envelopes shared by citations (#98) and status (#96). + * Shape: { v: 1, type: string, data: unknown } + */ + +export type VersionedFrame = { + v: number; + type: string; + data: unknown; +}; + +export type Citation = { + index: number; + title: string; + url: string; + published_at?: string | null; +}; + +export function parseVersionedFrame(raw: string): VersionedFrame | null { + const trimmed = raw?.trim?.() ?? ''; + if (!trimmed.startsWith('{')) return null; + try { + const parsed = JSON.parse(trimmed); + if ( + parsed && + typeof parsed === 'object' && + typeof parsed.v === 'number' && + typeof parsed.type === 'string' + ) { + return { + v: parsed.v, + type: parsed.type, + data: parsed.data, + }; + } + } catch { + // not JSON — fall through to sentinel / stream text handling + } + return null; +} + +export function parseCitationsData(data: unknown): Citation[] { + if (!Array.isArray(data)) return []; + return data + .filter( + (item): item is Record => + !!item && typeof item === 'object' && typeof (item as { index?: unknown }).index === 'number', + ) + .map((item) => ({ + index: item.index as number, + title: typeof item.title === 'string' ? item.title : `Source ${item.index}`, + url: typeof item.url === 'string' ? item.url : '', + published_at: + typeof item.published_at === 'string' ? item.published_at : null, + })); +} -- 2.54.0 From 49397f33f302ae96830369eff40ec39c56fb3fe4 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Tue, 4 Aug 2026 05:15:27 -0500 Subject: [PATCH 2/2] Restore missing stylis peer in package-lock after merge. npm ci failed on PR #101 because the lockfile dropped the top-level stylis peer required by stylis-plugin-rtl during the master merge. --- llm-fe/package-lock.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/llm-fe/package-lock.json b/llm-fe/package-lock.json index 73d8435..bc64a71 100644 --- a/llm-fe/package-lock.json +++ b/llm-fe/package-lock.json @@ -24106,6 +24106,12 @@ "name": "brace-expansion", "version": "5.0.8", "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "peer": true } } } -- 2.54.0