Add message actions and grounded citation Sources (#97, #98)
Unit Tests / test (pull_request) Failing after 4s

Wire copy/thumbs/export on chat bubbles and render live + hydrated Sources from citations frames so users can rate, export, and inspect grounded answers.
This commit is contained in:
2026-08-03 14:25:10 -05:00
parent 4501ee8f3d
commit 4a172fce56
26 changed files with 2588 additions and 112 deletions
+4
View File
@@ -27,6 +27,10 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
| Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — | | Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — |
| Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — | | Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — |
| Account Delete Failed | `ACCOUNT_DELETE_FAILED` | 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 ## Identify
+504 -32
View File
@@ -25,15 +25,21 @@
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/papaparse": "^5.5.2",
"@types/pdfmake": "^0.3.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"brace-expansion": "file:vendor/brace-expansion-compat",
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"docx": "^9.7.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2", "markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1", "mini.css": "^3.0.1",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6", "react-bootstrap": "^2.10.6",
"react-code-blocks": "^0.1.6", "react-code-blocks": "^0.1.6",
"react-github-btn": "^1.4.0", "react-github-btn": "^1.4.0",
@@ -46,11 +52,12 @@
"web-vitals": "^4.2.4", "web-vitals": "^4.2.4",
"webpack": "^5.97.1", "webpack": "^5.97.1",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"yup": "^1.5.0", "xlsx": "^0.18.5",
"brace-expansion": "file:vendor/brace-expansion-compat" "yup": "^1.5.0"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@testing-library/dom": "^10.4.1",
"@types/bootstrap": "~5.2.10", "@types/bootstrap": "~5.2.10",
"@types/lodash": "~4.17.13", "@types/lodash": "~4.17.13",
"@types/react": "^18.3.16", "@types/react": "^18.3.16",
@@ -2489,7 +2496,7 @@
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/trace-mapping": "0.3.9" "@jridgewell/trace-mapping": "0.3.9"
@@ -2502,7 +2509,7 @@
"version": "0.3.9", "version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/resolve-uri": "^3.0.3",
@@ -5009,6 +5016,30 @@
"node": ">=4.0" "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5546,18 +5577,19 @@
} }
}, },
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.0", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"peer": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1", "@types/aria-query": "^5.0.1",
"aria-query": "5.3.0", "aria-query": "5.3.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9", "dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0", "lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2" "pretty-format": "^27.0.2"
}, },
"engines": { "engines": {
@@ -5941,35 +5973,36 @@
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node12": { "node_modules/@tsconfig/node12": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node14": { "node_modules/@tsconfig/node14": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node16": { "node_modules/@tsconfig/node16": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/aria-query": { "node_modules/@types/aria-query": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"peer": true "dev": true,
"license": "MIT"
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -6315,11 +6348,39 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" "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": { "node_modules/@types/prettier": {
"version": "2.7.3", "version": "2.7.3",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz",
@@ -6353,7 +6414,7 @@
"version": "18.3.5", "version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
"devOptional": true, "dev": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -7048,6 +7109,15 @@
"node": ">=8.9" "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": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -7972,11 +8042,29 @@
"node": ">=8" "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": { "node_modules/browser-process-hrtime": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" "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": { "node_modules/browserslist": {
"version": "4.28.7", "version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
@@ -8206,6 +8294,19 @@
"node": ">=4" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -8380,6 +8481,15 @@
"wrap-ansi": "^7.0.0" "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": { "node_modules/clone-deep": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -8410,6 +8520,15 @@
"node": ">= 0.12.0" "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": { "node_modules/collect-v8-coverage": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
@@ -8890,11 +9009,23 @@
"node": ">=10" "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": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
@@ -9696,6 +9827,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" "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": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -9757,11 +9894,62 @@
"node": ">=6.0.0" "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": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"peer": true "dev": true,
"license": "MIT"
}, },
"node_modules/dom-converter": { "node_modules/dom-converter": {
"version": "0.2.0", "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": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -11613,6 +11818,15 @@
"node": ">= 0.6" "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": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -12226,6 +12440,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -12622,6 +12846,12 @@
"node": ">= 4" "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": { "node_modules/immer": {
"version": "9.0.21", "version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -15349,6 +15579,12 @@
"jiti": "bin/jiti.js" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -15533,6 +15769,54 @@
"node": ">=4.0" "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": { "node_modules/jwt-decode": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
@@ -15619,6 +15903,15 @@
"node": ">= 0.8.0" "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": { "node_modules/lilconfig": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -15627,6 +15920,25 @@
"node": ">=10" "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": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -15797,7 +16109,8 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"peer": true, "dev": true,
"license": "MIT",
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -15839,7 +16152,7 @@
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"devOptional": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/makeerror": { "node_modules/makeerror": {
@@ -16882,6 +17195,18 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" "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": { "node_modules/param-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -17021,6 +17346,34 @@
"node": ">=8" "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": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -17160,6 +17513,14 @@
"node": ">=10.4.0" "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": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "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", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -18911,6 +19273,7 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -20116,6 +20479,12 @@
"node": ">=10" "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": { "node_modules/retry": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -20352,6 +20721,7 @@
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@@ -20619,6 +20989,12 @@
"node": ">= 0.4" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" "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": { "node_modules/stable": {
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@@ -21430,12 +21818,6 @@
"postcss": "^8.2.15" "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": { "node_modules/stylis-plugin-rtl": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz", "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", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" "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": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -22014,7 +22402,7 @@
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
@@ -22058,7 +22446,7 @@
"version": "8.3.5", "version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"acorn": "^8.11.0" "acorn": "^8.11.0"
@@ -22071,14 +22459,14 @@
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ts-node/node_modules/diff": { "node_modules/ts-node/node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"devOptional": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.3.1" "node": ">=0.3.1"
@@ -22263,6 +22651,7 @@
"version": "4.9.5", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -22349,6 +22738,16 @@
"node": ">=4" "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": { "node_modules/unicode-property-aliases-ecmascript": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
@@ -22357,6 +22756,22 @@
"node": ">=4" "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": { "node_modules/unique-string": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
@@ -22484,7 +22899,7 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
@@ -23053,6 +23468,24 @@
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -23449,11 +23882,37 @@
"node": ">=10.0.0" "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": { "node_modules/xml-js": {
"version": "1.6.11", "version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"sax": "^1.2.4" "sax": "^1.2.4"
@@ -23503,6 +23962,18 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" "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": { "node_modules/xpath": {
"version": "0.0.32", "version": "0.0.32",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
@@ -23582,7 +24053,7 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -23622,6 +24093,7 @@
} }
}, },
"vendor/brace-expansion-compat": { "vendor/brace-expansion-compat": {
"name": "brace-expansion",
"version": "5.0.8", "version": "5.0.8",
"license": "MIT" "license": "MIT"
} }
+7
View File
@@ -20,16 +20,21 @@
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/papaparse": "^5.5.2",
"@types/pdfmake": "^0.3.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"brace-expansion": "file:vendor/brace-expansion-compat", "brace-expansion": "file:vendor/brace-expansion-compat",
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"docx": "^9.7.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2", "markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1", "mini.css": "^3.0.1",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6", "react-bootstrap": "^2.10.6",
"react-code-blocks": "^0.1.6", "react-code-blocks": "^0.1.6",
"react-github-btn": "^1.4.0", "react-github-btn": "^1.4.0",
@@ -42,6 +47,7 @@
"web-vitals": "^4.2.4", "web-vitals": "^4.2.4",
"webpack": "^5.97.1", "webpack": "^5.97.1",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"xlsx": "^0.18.5",
"yup": "^1.5.0" "yup": "^1.5.0"
}, },
"scripts": { "scripts": {
@@ -77,6 +83,7 @@
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@testing-library/dom": "^10.4.1",
"@types/bootstrap": "~5.2.10", "@types/bootstrap": "~5.2.10",
"@types/lodash": "~4.17.13", "@types/lodash": "~4.17.13",
"@types/react": "^18.3.16", "@types/react": "^18.3.16",
+2
View File
@@ -24,6 +24,7 @@ import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentCont
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner'; import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession'; import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary'; import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
import ToastHost from './llm-fe/components/ToastHost/ToastHost';
const ProtectedRoutes = () => { const ProtectedRoutes = () => {
const { authenticated, loading } = useContext(AuthContext); const { authenticated, loading } = useContext(AuthContext);
@@ -48,6 +49,7 @@ class App extends Component {
<AnalyticsConsentProvider> <AnalyticsConsentProvider>
<Tracker /> <Tracker />
<AnalyticsSession /> <AnalyticsSession />
<ToastHost />
<AnalyticsConsentBanner /> <AnalyticsConsentBanner />
<div className='site'> <div className='site'>
<main> <main>
@@ -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<typeof ConversationDetailCard>) =>
render(
<MemoryRouter>
<ThemeProvider theme={theme as never}>
<ConversationDetailCard {...props} />
</ThemeProvider>
</MemoryRouter>,
);
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('<citation indices="1"></citation>');
expect(out).toContain('<citation indices="2,3"></citation>');
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();
});
});
@@ -1,8 +1,13 @@
import React from "react"; import React, { useCallback, useMemo, useState } from 'react';
import Markdown from "markdown-to-jsx"; import Markdown from 'markdown-to-jsx';
import { Link } from "react-router-dom"; import { Link } from 'react-router-dom';
import styled, { keyframes } from "styled-components"; import styled, { keyframes } from 'styled-components';
import { isRagFeatureNotAllowed, parseChatErrorPayload } from "../../utils/chatErrors"; 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` const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
@@ -12,10 +17,16 @@ const fadeIn = keyframes`
const MessageContainer = styled.div<{ $isUser: boolean }>` const MessageContainer = styled.div<{ $isUser: boolean }>`
display: flex; display: flex;
flex-direction: column; 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; margin-bottom: 1.5rem;
width: 100%; width: 100%;
animation: ${fadeIn} 0.3s ease-out; 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 }>` const Bubble = styled.div<{ $isUser: boolean }>`
@@ -28,33 +39,33 @@ const Bubble = styled.div<{ $isUser: boolean }>`
props.$isUser props.$isUser
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)` ? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
: props.theme.darkMode : props.theme.darkMode
? "rgba(255, 255, 255, 0.1)" ? 'rgba(255, 255, 255, 0.1)'
: "rgba(0, 0, 0, 0.06)"}; : 'rgba(0, 0, 0, 0.06)'};
color: ${(props) => 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); backdrop-filter: blur(10px);
border: 1px solid ${(props) => border: 1px solid ${(props) =>
props.$isUser props.$isUser
? props.theme.darkMode ? props.theme.darkMode
? "rgba(255, 255, 255, 0.1)" ? 'rgba(255, 255, 255, 0.1)'
: "rgba(0, 0, 0, 0.1)" : 'rgba(0, 0, 0, 0.1)'
: props.theme.darkMode : props.theme.darkMode
? "rgba(255, 255, 255, 0.1)" ? 'rgba(255, 255, 255, 0.1)'
: "rgba(0, 0, 0, 0.08)"}; : 'rgba(0, 0, 0, 0.08)'};
box-shadow: ${(props) => box-shadow: ${(props) =>
props.$isUser || props.theme.darkMode props.$isUser || props.theme.darkMode
? "0 4px 15px rgba(0, 0, 0, 0.2)" ? '0 4px 15px rgba(0, 0, 0, 0.2)'
: "0 2px 10px rgba(0, 0, 0, 0.08)"}; : '0 2px 10px rgba(0, 0, 0, 0.08)'};
font-size: 1rem; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")}; border-bottom-right-radius: ${(props) => (props.$isUser ? '0.2rem' : '1.2rem')};
border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")}; border-bottom-left-radius: ${(props) => (props.$isUser ? '1.2rem' : '0.2rem')};
& pre { & pre {
background: ${(props) => background: ${(props) =>
props.$isUser || props.theme.darkMode props.$isUser || props.theme.darkMode
? "rgba(0, 0, 0, 0.3)" ? 'rgba(0, 0, 0, 0.3)'
: "rgba(0, 0, 0, 0.06)"}; : 'rgba(0, 0, 0, 0.06)'};
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow-x: auto; overflow-x: auto;
@@ -68,7 +79,7 @@ const Bubble = styled.div<{ $isUser: boolean }>`
& a { & a {
color: ${(props) => color: ${(props) =>
props.$isUser || props.theme.darkMode ? "#a0c4ff" : props.theme.main}; props.$isUser || props.theme.darkMode ? '#a0c4ff' : props.theme.main};
text-decoration: underline; 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) =>
`<citation indices="${nums.replace(/\s+/g, '')}"></citation>`,
);
})
.join('');
}
type ConversationDetailCardProps = { type ConversationDetailCardProps = {
message: string; message: string;
user_created: boolean; 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 }) => { const MyPlot = ({ format, image }: { format: string; image: string }) => {
@@ -139,7 +195,7 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => {
return ( return (
<img <img
src={imageSrc} src={imageSrc}
style={{ maxWidth: "100%", height: "auto", borderRadius: "8px", marginTop: "10px" }} style={{ maxWidth: '100%', height: 'auto', borderRadius: '8px', marginTop: '10px' }}
alt="plot" alt="plot"
/> />
); );
@@ -147,7 +203,7 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => {
const MyError = ({ content }: { content: string }) => { const MyError = ({ content }: { content: string }) => {
return ( return (
<span style={{ color: "#ff6b6b", fontWeight: "bold", display: "block", marginTop: "0.5rem" }}> <span style={{ color: '#ff6b6b', fontWeight: 'bold', display: 'block', marginTop: '0.5rem' }}>
Error: {content} Error: {content}
</span> </span>
); );
@@ -156,7 +212,59 @@ const MyError = ({ content }: { content: string }) => {
const ConversationDetailCard = ({ const ConversationDetailCard = ({
message, message,
user_created, user_created,
promptId,
citations = [],
isStreaming = false,
isLast = false,
conversationTitle = 'conversation',
createdTimestamp = null,
initialRating = null,
onRatingChange,
}: ConversationDetailCardProps): JSX.Element => { }: ConversationDetailCardProps): JSX.Element => {
const [highlightIndex, setHighlightIndex] = useState<number | null>(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 (
<CitationButton
key={`${index}-${i}`}
type="button"
aria-label={`Source ${index}`}
title={citation?.title || `Source ${index}`}
onClick={() => {
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}]
</CitationButton>
);
})}
</>
);
},
[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) { if (message.length === 0) {
return ( return (
<MessageContainer $isUser={false}> <MessageContainer $isUser={false}>
@@ -188,36 +296,45 @@ const ConversationDetailCard = ({
); );
} }
let contentToAdd = message; let contentToAdd = displayMarkdown;
let rawForCopy = message;
try { try {
const parsedMessage = JSON.parse(message); const parsedMessage = JSON.parse(message);
if ( if (
parsedMessage && parsedMessage &&
typeof parsedMessage === "object" && typeof parsedMessage === 'object' &&
parsedMessage.type parsedMessage.type
) { ) {
switch (parsedMessage.type) { switch (parsedMessage.type) {
case "text": case 'text':
contentToAdd = parsedMessage.content; rawForCopy = parsedMessage.content;
contentToAdd =
citations.length && !user_created
? injectCitationMarkers(parsedMessage.content)
: parsedMessage.content;
break; break;
case "plot": case 'plot':
contentToAdd = `<plot format="${parsedMessage.format}" image="${parsedMessage.image}"></plot>`; contentToAdd = `<plot format="${parsedMessage.format}" image="${parsedMessage.image}"></plot>`;
rawForCopy = message;
break; break;
case "error": case 'error':
contentToAdd = `<error content="${parsedMessage.content}"></error>`; contentToAdd = `<error content="${parsedMessage.content}"></error>`;
rawForCopy = message;
break; break;
default: default:
break; break;
} }
} }
} catch { } } catch {
/* plain markdown */
}
return ( return (
<MessageContainer $isUser={user_created}> <MessageContainer $isUser={user_created}>
<Bubble $isUser={user_created}> <Bubble $isUser={user_created}>
<Markdown <Markdown
className="display-linebreak" className="display-linebreak"
style={{ whiteSpace: "pre-line" }} style={{ whiteSpace: 'pre-line' }}
options={{ options={{
overrides: { overrides: {
plot: { plot: {
@@ -226,12 +343,35 @@ const ConversationDetailCard = ({
error: { error: {
component: MyError, component: MyError,
}, },
pre: {
component: CustomPreBlock,
},
citation: {
component: CitationMark,
},
}, },
}} }}
> >
{contentToAdd} {contentToAdd}
</Markdown> </Markdown>
</Bubble> </Bubble>
{!user_created && citations.length > 0 && (
<SourcesList citations={citations} highlightIndex={highlightIndex} />
)}
<MessageActions
promptId={promptId}
rawMarkdown={rawForCopy}
userCreated={user_created}
isStreaming={isStreaming}
isLast={isLast}
conversationTitle={conversationTitle}
createdTimestamp={createdTimestamp}
citations={citations}
initialRating={initialRating}
onRatingChange={onRatingChange}
/>
</MessageContainer> </MessageContainer>
); );
}; };
@@ -1,25 +1,72 @@
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter"; import React, { useState } from 'react';
import tsx from "react-syntax-highlighter/dist/cjs/languages/prism/tsx"; import styled from 'styled-components';
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; 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 = { const Wrapper = styled.div`
children: string, position: relative;
className: string 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,
}: 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');
}
};
const CustomCodeBlock = ({children, className}: CustomCodeBlock): JSX.Element => {
const language = className?.replace("lang-","");
return ( return (
<Wrapper>
<Tooltip title={copied ? 'Copied' : 'Copy code'}>
<CopyBtn aria-label="Copy code block" size="small" onClick={() => void handleCopy()}>
{copied ? <Check fontSize="inherit" /> : <ContentCopy fontSize="inherit" />}
</CopyBtn>
</Tooltip>
<SyntaxHighlighter language={language} style={oneDark}> <SyntaxHighlighter language={language} style={oneDark}>
{children} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
) </Wrapper>
} );
};
export default CustomCodeBlock; export default CustomCodeBlock;
@@ -1,14 +1,22 @@
import CustomCodeBlock from "../CustomCodeBlock/CustomCodeBlock" import CustomCodeBlock from '../CustomCodeBlock/CustomCodeBlock';
type CustomPreBlockProps = { type CustomPreBlockProps = {
children: JSX.Element | JSX.Element[] children: JSX.Element | JSX.Element[];
} };
const CustomPreBlock = ({children, ...rest}: CustomPreBlockProps): JSX.Element => { const CustomPreBlock = ({ children, ...rest }: CustomPreBlockProps): JSX.Element => {
if ("type" in children && children["type"] === "code") { const child = Array.isArray(children) ? children[0] : children;
return CustomCodeBlock({children: children["props"]["children"], className: children["props"]["className"] }); if (child && typeof child === 'object' && 'type' in child && child.type === 'code') {
return (
<CustomCodeBlock
className={(child.props as { className?: string }).className}
>
{(child.props as { children?: string }).children as string}
</CustomCodeBlock>
);
} }
return <pre {...rest}>{children}</pre> return <pre {...rest}>{children}</pre>;
} };
export default CustomPreBlock; export default CustomPreBlock;
@@ -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<PromptRating | null>(initialRating);
const [exportAnchor, setExportAnchor] = useState<null | HTMLElement>(null);
const [reasonAnchor, setReasonAnchor] = useState<null | HTMLElement>(null);
const [reasonCode, setReasonCode] = useState<string | null>(null);
const [reasonComment, setReasonComment] = useState('');
const [focused, setFocused] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const rowRef = useRef<HTMLDivElement | null>(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<HTMLElement>,
) => {
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 (
<ActionsRow
ref={rowRef}
$alwaysVisible={alwaysVisible}
data-message-actions
onFocusCapture={() => setFocused(true)}
onBlurCapture={(e) => {
if (!rowRef.current?.contains(e.relatedTarget as Node)) {
setFocused(false);
}
}}
>
<Tooltip title={copied ? 'Copied' : 'Copy markdown'}>
<ActionIconButton
aria-label="Copy message as markdown"
size="small"
onClick={() => void handleCopy()}
>
{copied ? <Check fontSize="small" /> : <ContentCopy fontSize="small" />}
</ActionIconButton>
</Tooltip>
{!userCreated && (
<>
<Tooltip title="Thumbs up">
<span>
<ActionIconButton
aria-label="Thumbs up"
size="small"
disabled={!promptId}
onClick={(e) => void handleThumb('up', e)}
>
{rating === 'up' ? (
<ThumbUp fontSize="small" />
) : (
<ThumbUpOutlined fontSize="small" />
)}
</ActionIconButton>
</span>
</Tooltip>
<Tooltip title="Thumbs down">
<span>
<ActionIconButton
aria-label="Thumbs down"
size="small"
disabled={!promptId}
onClick={(e) => void handleThumb('down', e)}
>
{rating === 'down' ? (
<ThumbDown fontSize="small" />
) : (
<ThumbDownOutlined fontSize="small" />
)}
</ActionIconButton>
</span>
</Tooltip>
</>
)}
<Tooltip title="Export">
<ActionIconButton
aria-label="Export message"
size="small"
onClick={(e) => setExportAnchor(e.currentTarget)}
>
<FileDownload fontSize="small" />
</ActionIconButton>
</Tooltip>
<Menu
anchorEl={exportAnchor}
open={Boolean(exportAnchor)}
onClose={() => setExportAnchor(null)}
>
{(
[
['pdf', 'PDF'],
['docx', 'Word (.docx)'],
['csv', 'CSV'],
['xlsx', 'Excel (.xlsx)'],
['txt', 'Plain text'],
] as [ExportFormat, string][]
).map(([format, label]) => (
<MenuItem key={format} onClick={() => void handleExport(format)}>
{label}
</MenuItem>
))}
</Menu>
<Popover
open={Boolean(reasonAnchor)}
anchorEl={reasonAnchor}
onClose={() => setReasonAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Stack spacing={1.25} sx={{ p: 1.5, width: 280, maxWidth: '90vw' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>
What went wrong? (optional)
</div>
<Stack direction="row" flexWrap="wrap" gap={0.75}>
{DOWN_REASONS.map((reason) => (
<Chip
key={reason.code}
label={reason.label}
size="small"
color={reasonCode === reason.code ? 'primary' : 'default'}
onClick={() => setReasonCode(reason.code)}
/>
))}
</Stack>
<TextField
size="small"
multiline
minRows={2}
placeholder="Additional details (optional)"
value={reasonComment}
onChange={(e) => setReasonComment(e.target.value)}
/>
<Button variant="contained" size="small" onClick={() => void submitReason()}>
Done
</Button>
</Stack>
</Popover>
</ActionsRow>
);
};
export default MessageActions;
@@ -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<Record<number, HTMLLIElement | null>>({});
const [active, setActive] = useState<number | null>(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 (
<SourcesRoot aria-label="Sources">
<SourcesTitle>Sources</SourcesTitle>
<SourceList>
{sorted.map((citation) => {
const domain = domainFromUrl(citation.url);
return (
<SourceItem
key={citation.index}
id={`citation-source-${citation.index}`}
$highlight={active === citation.index}
ref={(node) => {
itemRefs.current[citation.index] = node;
}}
>
{citation.url ? (
<SourceLink
href={citation.url}
target="_blank"
rel="noopener noreferrer"
>
{citation.title || `Source ${citation.index}`}
</SourceLink>
) : (
<span>{citation.title || `Source ${citation.index}`}</span>
)}
{(citation.published_at || domain) && (
<SourceMeta>
{[citation.published_at, domain].filter(Boolean).join(' · ')}
</SourceMeta>
)}
</SourceItem>
);
})}
</SourceList>
</SourcesRoot>
);
};
export default SourcesList;
@@ -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 (
<Snackbar
open={open}
autoHideDuration={4000}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setOpen(false)}
severity={severity}
variant="filled"
sx={{ width: '100%' }}
>
{message}
</Alert>
</Snackbar>
);
};
export default ToastHost;
+56 -6
View File
@@ -6,6 +6,7 @@ import { ConversationPrompt, ConversationPromptType } from "../data";
import { axiosInstance } from "../../axiosApi"; import { axiosInstance } from "../../axiosApi";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { AnalyticsEvents, trackEvent } from "../utils/analytics"; import { AnalyticsEvents, trackEvent } from "../utils/analytics";
import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames";
type MessageProviderProps ={ type MessageProviderProps ={
children? : ReactNode; children? : ReactNode;
@@ -53,6 +54,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const selectedConversationRef = useRef<undefined | number>(undefined) const selectedConversationRef = useRef<undefined | number>(undefined)
const isGeneratingRef = useRef(false) const isGeneratingRef = useRef(false)
const prevReconnectGenerationRef = useRef(0) const prevReconnectGenerationRef = useRef(0)
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearStreamInterrupted = () => setStreamInterrupted(false) const clearStreamInterrupted = () => setStreamInterrupted(false)
@@ -76,8 +78,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
message: item.message, message: item.message,
user_created: item.user_created, user_created: item.user_created,
created_timestamp: item.created_timestamp, created_timestamp: item.created_timestamp,
created: item.created,
tokens_in: item.tokens_in ?? null, tokens_in: item.tokens_in ?? null,
tokens_out: item.tokens_out ?? null, tokens_out: item.tokens_out ?? null,
citations: item.citations ?? [],
feedback: item.feedback ?? null,
}), }),
) )
if (tempConversations.length === 1) { 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(() => { useEffect(() => {
GetConversationDetails(selectedConversation) GetConversationDetails(selectedConversation)
}, [selectedConversation]) }, [selectedConversation])
useEffect(() => {
return () => {
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
}
}, [])
// Streaming recovery: on reconnect after a drop mid-stream, refetch conversation // Streaming recovery: on reconnect after a drop mid-stream, refetch conversation
// and mark the in-flight assistant turn interrupted (no resume protocol on backend). // and mark the in-flight assistant turn interrupted (no resume protocol on backend).
useEffect(() => { useEffect(() => {
@@ -147,21 +169,46 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
/* subscribe to channel and register callback */ /* subscribe to channel and register callback */
subscribe(channelName, (message: string) => { 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'){ if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
messageResponsePart.current = 0 messageResponsePart.current = 0
conversationRef.current.pop() conversationRef.current.pop()
//handleAssistantPrompt({prompt: messageRef.current}) const finalized = new ConversationPrompt({
setConversationDetails([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})]) message: `${messageRef.current}`,
console.log([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})]) user_created: false,
})
conversationRef.current = [...conversationRef.current, finalized]
setConversationDetails([...conversationRef.current])
messageRef.current = '' messageRef.current = ''
setStateMessage('') setStateMessage('')
isGeneratingRef.current = false isGeneratingRef.current = false
setIsGeneratingMessage(false) setIsGeneratingMessage(false)
setStreamInterrupted(false) setStreamInterrupted(false)
schedulePostStreamRefetch()
} }
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){ else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
conversationRef.current = conversationDetails conversationRef.current = conversationDetails
@@ -176,23 +223,26 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setStreamInterrupted(false) setStreamInterrupted(false)
messageResponsePart.current = 1 messageResponsePart.current = 1
}else{ }else{
if (messageResponsePart.current === 1){
isGeneratingRef.current = true isGeneratingRef.current = true
setIsGeneratingMessage(true) setIsGeneratingMessage(true)
if (messageResponsePart.current === 1){
// this has to do with the conversation id // this has to do with the conversation id
if(!selectedConversation){ if(!selectedConversation){
const conversationId = Number(message); const conversationId = Number(message);
setSelectedConversation(conversationId) setSelectedConversation(conversationId)
selectedConversationRef.current = conversationId
trackEvent(AnalyticsEvents.CONVERSATION_CREATED, { trackEvent(AnalyticsEvents.CONVERSATION_CREATED, {
conversationId, conversationId,
}); });
} }
} }
else if (messageResponsePart.current === 2){ else if (messageResponsePart.current === 2){
isGeneratingRef.current = true
setIsGeneratingMessage(true)
messageRef.current += message messageRef.current += message
setStateMessage(messageRef.current) setStateMessage(messageRef.current)
} }
// ignore stray frames outside an active stream phase
} }
}) })
+18 -1
View File
@@ -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 */ /* Classes for the project */
export interface ConversationPromptType { export interface ConversationPromptType {
id: number, id: number,
message: string, message: string,
user_created: boolean, user_created: boolean,
created_timestamp: Date, created_timestamp?: Date,
created?: Date | string,
tokens_in?: number | null, tokens_in?: number | null,
tokens_out?: number | null, tokens_out?: number | null,
citations?: Citation[],
feedback?: PromptFeedbackState | null,
} }
export class ConversationPrompt{ export class ConversationPrompt{
@@ -15,6 +27,8 @@ export class ConversationPrompt{
created_timestamp: Date = new Date(); created_timestamp: Date = new Date();
tokens_in: number | null = null; tokens_in: number | null = null;
tokens_out: number | null = null; tokens_out: number | null = null;
citations: Citation[] = [];
feedback: PromptFeedbackState | null = null;
constructor(initializer?: any){ constructor(initializer?: any){
if(!initializer) return; if(!initializer) return;
@@ -22,8 +36,11 @@ export class ConversationPrompt{
if (initializer.message) this.message = initializer.message; if (initializer.message) this.message = initializer.message;
if (initializer.user_created) this.user_created = initializer.user_created; if (initializer.user_created) this.user_created = initializer.user_created;
if (initializer.created_timestamp) this.created_timestamp = initializer.created_timestamp; 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_in !== undefined) this.tokens_in = initializer.tokens_in;
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out; 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;
} }
} }
@@ -2,8 +2,8 @@ import React, { useContext, useEffect, useRef, useState } from "react";
import styled, { ThemeContext } from "styled-components"; import styled, { ThemeContext } from "styled-components";
import { Formik, Form, Field } from "formik"; import { Formik, Form, Field } from "formik";
import * as Yup from "yup"; import * as Yup from "yup";
import { AttachFile, Delete, Send, Close } from "@mui/icons-material"; // Keeping icons for now, can replace later if needed import { AttachFile, Delete, Send, Close, FileDownload } from "@mui/icons-material";
import { Tooltip } from "@mui/material"; import { Tooltip, Menu, MenuItem, IconButton as MuiIconButton } from "@mui/material";
import Markdown from "markdown-to-jsx"; import Markdown from "markdown-to-jsx";
import { import {
@@ -17,6 +17,9 @@ import ParticleBackground from "../../components/ParticleBackground/ParticleBack
import Header2 from "../../components/Header2/Header2"; import Header2 from "../../components/Header2/Header2";
import { AnalyticsEvents, trackEvent } from "../../utils/analytics"; 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 // Styled Components
const PageContainer = styled.div` 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` const InputArea = styled.div`
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box; box-sizing: border-box;
@@ -361,6 +376,57 @@ const AsyncDashboardInner = (): JSX.Element => {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [exportAnchor, setExportAnchor] = useState<null | HTMLElement>(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 = (() => { const connectionBanner = (() => {
if (connectionStatus === ConnectionStatus.CONNECTED || isConnected) { if (connectionStatus === ConnectionStatus.CONNECTED || isConnected) {
@@ -483,23 +549,66 @@ const AsyncDashboardInner = (): JSX.Element => {
</Sidebar> </Sidebar>
<MainContent> <MainContent>
{conversationDetails.length > 0 && (
<ChatToolbar>
<Tooltip title="Export conversation">
<MuiIconButton
aria-label="Export conversation"
size="small"
onClick={(e) => setExportAnchor(e.currentTarget)}
sx={{ color: theme?.colors?.text }}
>
<FileDownload fontSize="small" />
</MuiIconButton>
</Tooltip>
<Menu
anchorEl={exportAnchor}
open={Boolean(exportAnchor)}
onClose={() => setExportAnchor(null)}
>
{(
[
['pdf', 'PDF'],
['docx', 'Word (.docx)'],
['csv', 'CSV'],
['xlsx', 'Excel (.xlsx)'],
['txt', 'Plain text'],
] as [ExportFormat, string][]
).map(([format, label]) => (
<MenuItem key={format} onClick={() => void handleConversationExport(format)}>
{label}
</MenuItem>
))}
</Menu>
</ChatToolbar>
)}
<ChatArea> <ChatArea>
{conversationDetails.length > 0 ? ( {conversationDetails.length > 0 ? (
conversationDetails.map((convo_detail, index) => conversationDetails.map((convo_detail, index) => {
convo_detail.message.length > 0 ? ( 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 (
<ConversationDetailCard <ConversationDetailCard
message={convo_detail.message}
user_created={convo_detail.user_created}
key={convo_detail.id || index} key={convo_detail.id || index}
/> message={displayMessage}
) : (
<ConversationDetailCard
message={stateMessage}
user_created={convo_detail.user_created} user_created={convo_detail.user_created}
key={convo_detail.id || index} promptId={convo_detail.id}
citations={convo_detail.citations}
isStreaming={isLiveStream}
isLast={isLast}
conversationTitle={selectedTitle}
createdTimestamp={convo_detail.created_timestamp}
initialRating={convo_detail.feedback?.rating ?? null}
onRatingChange={(rating) =>
updatePromptRating(convo_detail.id, rating)
}
/> />
) );
) })
) : ( ) : (
<div style={{ <div style={{
display: 'flex', display: 'flex',
+5
View File
@@ -25,6 +25,7 @@ const queue: QueuedCall[] = [];
* | Plan Change Started | Change-plan intent (#75) | * | Plan Change Started | Change-plan intent (#75) |
* | Subscription Cancel Started | Cancel intent (#75) | * | Subscription Cancel Started | Cancel intent (#75) |
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) | * | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
*/ */
export const AnalyticsEvents = { export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success', LOGIN_SUCCESS: 'Login Success',
@@ -46,6 +47,10 @@ export const AnalyticsEvents = {
ACCOUNT_DELETE_STARTED: 'Account Delete Started', ACCOUNT_DELETE_STARTED: 'Account Delete Started',
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success', ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
ACCOUNT_DELETE_FAILED: 'Account Delete Failed', ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
MESSAGE_COPIED: 'Message Copied',
MESSAGE_RATED: 'Message Rated',
MESSAGE_RATING_REASON: 'Message Rating Reason',
MESSAGE_EXPORTED: 'Message Exported',
} as const; } as const;
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents]; export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
+51
View File
@@ -0,0 +1,51 @@
import { copyTextToClipboard } from './clipboard';
describe('clipboard', () => {
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');
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Clipboard helper with execCommand fallback for Capacitor / insecure contexts (#97).
*/
export async function copyTextToClipboard(text: string): Promise<void> {
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');
}
}
+24
View File
@@ -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);
}
@@ -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);
});
});
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void>((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<void> {
const {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
Table,
TableRow,
TableCell,
WidthType,
Header,
} = await import('docx');
const children: InstanceType<typeof Paragraph | typeof Table>[] = [
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';
}
@@ -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<MdBlock, { type: 'table' }> => b.type === 'table')
.map(({ headers, rows }) => ({ headers, rows }));
}
@@ -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',
);
});
});
+39
View File
@@ -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<PromptFeedbackResponse> {
const { data } = await axiosInstance.post<PromptFeedbackResponse>('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<void> {
await axiosInstance.delete('prompt_feedback', {
params: { prompt_id: promptId },
});
}
+17
View File
@@ -0,0 +1,17 @@
type ToastListener = (message: string, severity: 'error' | 'success' | 'info') => void;
const listeners = new Set<ToastListener>();
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));
}
+52
View File
@@ -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([]);
});
});
+56
View File
@@ -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<string, unknown> =>
!!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,
}));
}