mirror of
https://github.com/compiler-explorer/compiler-explorer.git
synced 2025-12-27 05:53:49 -05:00
[Not Live; disabled by default] Add Claude Explain feature for AI-powered assembly explanations (#7749)
Add Claude Explain feature for AI-powered code explanations This PR introduces Claude Explain, a new feature that provides AI-powered explanations of compiler output directly within Compiler Explorer. Key features: Claude Explain functionality: - New explain view pane - Explains compiler output with full context of source code and compilation output - Configurable audience level and explanation type - Response caching to improve performance and reduce API calls - Usage statistics display showing requests used and token counts User experience: - Consent flow on first use explaining data handling and privacy - AI disclaimer banner warning about potential LLM inaccuracies - Respects "no-ai" directive in source code for users who don't want AI processing Privacy and security: - Data sent to Anthropic's Claude API as documented in privacy policy - No data used for model training - Clear consent required before first use - Support for opting out via "no-ai" directive The feature is marked as beta and can be enabled via configuration. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import {defineConfig} from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
viewportWidth: 1000,
|
||||
viewportHeight: 700,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 1024,
|
||||
e2e: {
|
||||
baseUrl: 'http://127.0.0.1:10240/',
|
||||
supportFile: 'cypress/support/utils.ts',
|
||||
|
||||
@@ -19,6 +19,7 @@ const PANE_DATA_MAP = {
|
||||
tree: {name: 'Tree', selector: 'view-gnatdebugtree'},
|
||||
debug: {name: 'Debug', selector: 'view-gnatdebug'},
|
||||
cfg: {name: 'CFG', selector: 'view-cfg'},
|
||||
explain: {name: 'Claude Explain', selector: 'view-explain'},
|
||||
};
|
||||
|
||||
describe('Individual pane testing', () => {
|
||||
@@ -68,6 +69,7 @@ describe('Individual pane testing', () => {
|
||||
addPaneOpenTest(PANE_DATA_MAP.tree);
|
||||
addPaneOpenTest(PANE_DATA_MAP.debug);
|
||||
addPaneOpenTest(PANE_DATA_MAP.stackusage);
|
||||
addPaneOpenTest(PANE_DATA_MAP.explain);
|
||||
// TODO: Bring back once #3899 lands
|
||||
// addPaneOpenTest(PaneDataMap.cfg);
|
||||
|
||||
@@ -92,8 +94,7 @@ describe('Known good state test', () => {
|
||||
cy.visit(
|
||||
// This URL manually created. If you need to update, run a local server like the docs/UsingCypress.md say,
|
||||
// then paste this into your browser, make the changes, and then re-share as a full link.
|
||||
|
||||
'/#z:OYLghAFBqd5TB8IAsQGMD2ATApgUWwEsAXTAJwBoiQIAzIgG1wDsBDAW1xAHIBGHpTqYWJAMro2zEHwAsQkSQCqAZ1wAFAB68ADIIBWMyozYtQ6AKQAmAELWblNc3QkiI2q2wBhTIwCuHCwgVpSeADJELLgAcgEARrjkIPIADpgqpG4sPv6BwZRpGa4iEVGxHAlJ8k64LlliJGzkJDkBQSE1dSINTSSlMfGJyY6Nza15HaN9kQMVQ7IAlI6YfuTo3DwA9JsA1AAqAJ4puDsHK%2BQ7WHg7KIm4lDsUO4yYbNg7pju4mpwpzAB0Fh0AEFIiQdioAI5%2BJq4CBgnYsAILHYWADsdhBO2xO3IuBIqxYiICOwAVMSOBYAMyY4HogAiPCWjF4AFZBEEeHpKJheF57PYIed1qirFSBJQSLomUsANYgVk6Yy8WSCDgKpWc7m8niCFQgJVSrlMyhwWBoLAsYTkDimdbUDzEMjkIjYIwmMwASTdlls9mWq3WvG2%2ByOJzOq0uOBOtzxDyeLzeHyJ31%2BAKBoNEEOhsPhWaRHBR6NpONx%2BMJFLJFOptIZJpZPHZlC1gh1PitFFtLBFADUiLgAO6JHYQQikJ7WcULQRGvQLJa3N5DCDMlVq4Ks5vSnm8PUGyXSpZmqBoch%2BFQkFBEKg0CBYDgpJiJaKcDbAAW2HZ4OhsPyMEiCLgToUK6RjCKIEhSNwcgKKIqgaNoxqUIYfCOLgzjFEEECeOMQSoeEMzlJURiFJkIi4SR6RkSw/REUMqGdJhPRjL4bRGIx9RTLRgxJAxUwUXxvTcXMvFLCQeK4KBOiro2HLbjqABK57ggAEp6Ck7H2g7Du%2Bfqft%2Bv7/iOY7OqK4oPD4D5Phck58NOB7GvOlCLngSQrsqPCqpQ6pWJuLY7rqjj7rOMqUPKshKg2VJyUhOozoepqIMeqAYJgVnMDetD3o%2BGUgMABl/iQlB4AAbkQ6xaQOADyxz%2BQw/6JPqEBxNucSRE0By8BKbXsOQBxVXE%2Bi1EaEr3lwohVSwjCdUheBxH4wBeFIjD6vwgh4F2wDSLN17DUQJW4Kt3LfLUfgkBsEpguh26MEQcTkB1Ph4Nu4lEOqa1LHQJjAColU1awXWCOB4iSNIMHA/BWjbihximOYH4OLdcT6pASyYCkmGra2B3kC6eAo%2B5HHuNhLDeKxeT4aTwnEahpGYQJBRUZh1P0WhGGcb0DNEywzHTGUPHsfx5N4SMQmEQLdkBms0GUAOpgkDVJCeh2CUNk2/k6jsul2PpuA/oVxnATZYp2fFjkLrgS5uXKGoeV5Pl%2BfJu5BYaCXJWgF5sOgsrnmwwD3Le2XWS%2BXC8NrgoFf%2BgFG6BqEwyowobCGhzHKc5xRtcsb3I8FyJu8nypg%2B6YggiUIwniebggWRYYhmpZ4gS5BEgWVYFjWGZ1pKElSTJ6tOzwPTezsqh%2ByclU6QjX564Z4KjkbZmoTslk5cOtn2SFTkucuMn2xuW6xc7%2Bqu45iXIGgKwkCkZ0OneaUr%2BQIdvpPkcAaEMduqhwOQWD8gQ2oUNIRhgOB6KRAa9xitqXgVUzpX3BJgOgWtn7TwNhAZe1kF7r0PBbK2tAd7rl8vvSBgUj4OTnKfc0IAzwXltOgcgmBvgpBvkHDKj8w5IP1lHN%2B448ZgUUN/aCv9FCQ0QtyeO6E9rExwsLIwBF%2BYiUokULIDM6ZZBZrxNmEieZC1yCLbmvM1GC05tIwSzQDGS3ErgSSbppIeT7gfHgSkLw7AALJezoTsfAmgUimAyCITS/YhwXHDrrDhs8TIThNhZO%2B6C15mznNg1yuC7b4MdvYvcx94lhRAAANkirwaKhDWzOw3jbKkaJ/hijFGiVkABONENS%2BB8CsFYNEHkrDrj4DoTU/c4kmmSklAZZ8QAnXQGdCgN8mg/XUKYdCYgUCYAHJyUa0STCYWmVERgcyFn%2BWYUMfKyD/yUF2Q/V8ztjnQPEPMxZ24RnAnID9Z2IyGjEE5EDPhoMBGwWUP/ERBh3RwwwAjYwd0CZowxlkLGPIcZ40OvAJYCdAwywvC89ZsyrlLJnBJQGssQFgNsRAopPBPGnVMsEmwU9Qkjn5HpBwHj56xNIaFLe1ssmKmSd5PeGtD7BTdklNAwB2AkDmgtCxAcsorOfKcngZKKUz2jtw2OXz%2BEyEEXBH50N2LiK6FhKROiZFU3FvI2mTMlHGMZookohqaYaO1bzLmWqmJcStazT2LE9UmL5rMGmYlu7WPAYUgKABxaIwI9g7HpLgeawB9gSX8dpIJ7CZ6G24QvKJ6VV4m0webZyltEnuQbLvAhXLiE8pPu7EA14mESpOaHaVibCryudIq%2BOidgy7BTuGdOVwYx3HjLnV4%2BcUw/CLrgQEJcsxl1zAiauqJa5YhxA3CsLdyRtxpB3NEjIu6WNAh9fFAadRhDCD2ZxOx1JxsCYgmlsqUHhONuZJe1aMG9M3rm7e7KHb7u5RkvpfKQAoDYCoWU6FGA%2BDjIHatrC61Xpfo2kCH8lUfJVV84RGqGIOqyB4UmDNZFetZio8iZr8M0WdeovR2i2JofZt0J1cjrWupaGa%2BjZifXbr9Xu4tgaVJeAfXic9E9oMHLCfSyJD7013tNoyl9OD81rg5UWnpLtJM2zZVFAlAVn3kKQBQ9GRVwNicgzKmDXCm3wZbYittoZU4RguN2m4vac7PAHcmL4w6/ijozKXHMFdp3IlnSWBd5Ym6VhXQEduIJO6ip3QIf1xbFZ8YTQJylc8U22TTffJ9Smc3SeU3kzyKTP0lu/UeX9Aq2BCsjQtKt%2BmpWGcE7Bnhn93lQSQ3/BCqGbWYUw2Td1oQDW0bwyagjPWiNmI6xzN1FGxvUbFv10j5GKai1MSRmQLGrEGhi/3YNobw0VejePBLOtyUv2TaZVLon0sMpKVlvNeC5OpKIekyTmmUqMEYCVDgit1BEGOLdKIlUb48AALR0BYJgQHxAVCSHINgQHJUpB%2BFwID9gXAVA8G3Mcgz9bOEbThswQHKgDgVF8EddaAQUjA7/IwQHHAcB/guvVxV9VzrkEB5ES0ahoSsFcFIQH3iVBqFWhKL%2BiHwZCPVUhNAr33ufe%2B%2BhGY/2xEYSAgAMT8N2TrxCHVAWVngbQSoMhzSaAAdVdJeDchpfV4rVmpg9R6T1xa%2Bz9mY8XL2HevUZZLp2RNoIyhlq7zKkkFvy8Wx7G9ntoH/YB4DDRgBVfvpjxLcrjNwd4RBEXqrvltcAZqqjOqsNmpw3RdRRHlGDeI7NnPmi7WMfQ9NpbFePVcxo7h0SW61s2OtwVzj3GxB7EDS72rSXb2pvOzEzNz6ElvqD3dgroesGstywUkPmX5SslkP8Pg2St/b539v%2BQDZ2kcs6d0tJT3%2BlDJSIwwOHpgDA87GV7FQEFXwaZ4kQHzKScIea6LtVWfRFGEizdF3U72LXUDxBSDoXWH5yeAuVgVdwjkExOwiXvR9wzSnAn2uzfXlCsBqQ3133wK3wAA531OUFMSEw9y10A6BY89N48assdX4n8TMjBFdhoVc1dtVeBJ1vNRAFheA28e52N%2B4vBld%2B9B8k1PdkDF5UDxMs1MkA8ZM8sZ9l9yDeUhlSpyoxUIANCgxAomCU9Gs09v8M8UNs9KNNEutsM%2BsW8FFqJS8LVy8bDzDbV5tdFa8tEZsnDFsGMesmNltzFLdgC2QbdeAI0ypewAl%2BM3djtJDxM0sx90DMsFDbsP0VDS0yFy1I8gNXsvAOAOA49g56DE8G1k8Gsv8f5kNxd/9nDOsSZutJtC8JZzU7DCMy9RsyMjFfD3D9F/DvCm9PCi8VsBC2MQDNsuMl48iB8GCkC4jR9fdLt59kiSD5NT9VDs1sDCCN8rBGlCDZAykdi9i0QqQPIl8FMKDBkKEANdNxVqta1xCSj9CyizNpYLMO005IxbMs4%2B1HMkwC5XNi5MxwRuC4QfNCw/M64AtG5m4SQQtKQ11wsN1MVWMrdgiCtgQnF9t4CQkJDh8zsZC/dFjX0WVp91Q2U0iitw9KFlIOBK1aDCi7iGCGcX8msKjWsAFqips896iFtGijVmj6ZWiHD2jujXDK8XCBimj6N%2Bj68nDVtBDRj7FHFwRnEz1MT7iPdcTvdH0Fjs0liSTghsl/gdAxQqQGlCCmlCDsk18aliDySz8St0B0BsAycJlsBsA8R%2BdFYMM1pKBlovT3AfS4gXhvYVB/SMUjk0ouxsAAB9CAvWIgbQdyDHIo6IurYqfENgJgUM8FAMiUR4xnJ8ZHXAaMlQPwOgBgRMz6Fkz5Nk35agbxekMndrX7VgTAMM7FIspJB6aqHM8M5oRgRsh8bcRiICaZfnJJbmSwgvawwY41Bw%2Bw6iYU3PavLo5c5vWcvoxjdciWeFW6D7Xs7FT2EgbMjXCUUVQc0BJCQA/cPwV0dsn0kqdGE8706LIQ%2BxQNLwbjPYCSTYBSPYMIKY4ojU4TFA7U8fJIokwPWTHyKkf4OpLfHQc0wgtEPgKkWQRUEIO0q7eUPgcpFC7Y2QQgmpbJAioikik4kIwKbCnJKwf4NEbJeiqwCKKwRC5CtEffXgQ/dUY/WfTLA/SijAnGXxIIWQIAA%3D%3D',
|
||||
'http://localhost:10240/#z:OYLghAFBqd5TB8IAsQGMD2ATApgUWwEsAXTAJwBoiQIAzIgG1wDsBDAW1xAHIBGHpTqYWJAMro2zEHwAsQkSQCqAZ1wAFAB68ADIIBWMyozYtQ6AKQAmAELWblNc3QkiI2q2wBhTIwCuHCwgVpSeADJELLgAcgEARrjkIPIADpgqpG4sPv6BwZRpGa4iEVGxHAlJ8k64LlliJGzkJDkBQSE1dSINTSSlMfGJyY6Nza15HaN9kQMVQ7IAlI6YfuTo3DwA9JsA1AAqAJ4puDsHK%2BQ7WHg7KIm4lDsUO4yYbNg7pju4mpwpzAB0Fh0AEFIiQdioAI5%2BJq4CBgnYsAILHYWADsdhBO2xO3IuBIqxYiICOwAVMSOBYAMyY4HogAiPCWjF4AFZBEEeHpKJheF57PYIed1qirFSBJQSLomUsANYgVk6Yy8WSCDgKpWc7m8niCFQgJVSrlMyhwWBoLAsYTkDimdbUDzEMjkIjYIwmMwASTdlls9mWq3WvG2%2ByOJzOq0uOBOtzxDyeLzeHyJ31%2BAKBoNEEOhsPhWaRHBR6NpONx%2BMJFLJFOptIZJpZPHZlC1gh1PitFFtLBFADUiLgAO6JHYQQikJ7WcULQRGvQLJa3N5DCDMlVq4Ks5vSnm8PUGyXSpZmqBoch%2BFQkFBEKg0CBYDgpJiJaKcDbAAW2HZ4OhsPyMEiCLgToUK6RjCKIEhSNwcgKKIqgaNoxqUIYfCOLgzjFEEECeOMQSoeEMzlJURiFJkIi4SR6RkSw/REUMqGdJhPRjL4bRGIx9RTLRgxJAxUwUXxvTcXMvFLCQeK4KBOiro2HLbjqABK57ggAEp6Ck7H2g7Du%2Bfqft%2Bv7/iOY7OqK4oPD4D5Phck58NOB7GvOlCLngSQrsqPCqpQ6pWJuLY7rqjj7rOMqUPKshKg2VJyUhOozoepqIMeqAYJgVnMDetD3o%2BGUgMABl/iQQhMCQiT6hAcTbnEkRNAcvAStV7DkAcADycT6LURoSveXCiC1LCMHVSF4HEfjAF4UiMPq/CCHgXbANIw3Xp1RAAG64NN3LfLUfilfVghguh26MEQcTkLVPh4Nu4lEOqM2UOt5BxOkuD0rg80nWYCV0CYwAqFpA4tccnISuB4iSNIMFg/BWjbihximOYH4OCdcT6pASyYCkmHTa2j0ung6PuRx7jYSw3isXk%2BHk8JxGoaRmECQUVGYbT9FoRhnG9EzJMsMx0xlDx7H8ZTeEjEJhFC3ZAZrNBlADqYJBAyQnodglDZNv5Oo7Lpdj6bgP6FcZwE2WKdnxY5C64EublyhqHleT5fnybuQWGglyVoBebDoLK55sMA9y3tl1kvlwvC64KBX/oBJugahYOQZD8jQ2osNIfDvMeOTTMEYLImUUUWRMwzWRs7xHMrd0Iu5GLvP8%2BXwvc6LTfNI30vibgklutJHmay7PA9L7OyqAHJwAzpyNfgbhngqOJtmahOyWTlw62fZIVOS5y4yY7G5brFrv6u7jmJcgaArCQKS7Q6d5pav5Bh2%2BU/RwBoRx26CeKEn0Ep4oMOIW5PDAc50Uj7Rkv3Q%2BPAWq7WvuCTAdAdYvxnkbCAK9rKLw3oeK2NtaC73XL5A%2B2oj7BQ9klU8ylbToHIJgb4KRb4hwyk/COyDDYx3fuOAmYFv4Q1/rBZQadAEGHYuhKuWEcIt2ptgduzMi7kUkbI6iMj641zYgxURXQ%2BZcUlgXQSLFa6twFrMOmYkJJSQgTFYhPAlIXh2AAWR9jQnY%2BBNApFMBkEQml%2BxDguJHfWbC54mQnGbCy98MHrwtnOHBrk8EOwIc7KBe4T5RLCiAAAbJFXg0UiGtldpvO2VI0T/DFGKNErIACcaJyl8D4FYKwaIPJWHXHwHQmoB6RJNMlJK3Tz4gG2ugXaFBb5ND%2BuoUw6ExAoEwAOEGghGFsEwmMqIjBJnTP8owoY%2BUUH/koBsx%2Br5XZ7JgeIKZMztz9OBOQP6rt%2BkNGIJyQQideEyD/nBQRcN3SIwwMjYwp0iaY2xlkXGPJ8aug2vAJYZ5RC3VwHsTAvh/kyyDHxe5SyJmnNmZKCS%2B15agPAX3SxuSeAuJ2qZPxNhp4BJHPyPSDhnELwiQ5FJ29bapMVHE7y%2B8tYkOSZ08heV2AkBGmNTuQcsphKYQcng5LKWz1jpw%2BO/Cf4vP4QAj56jOakwkQYqRMjS7yJ1Yo1mOi6aV00fzHmGimLaPzqa72%2Bi1Hizbia%2Bipiu7mIJTkgKABxaIwI9g7DeqNYA%2BwJJeO0r41hs9jacMXqE9Ka8zZYMts5a2MT3INj3oQ7lgVj5Mr5b068DCJXPilTK1%2B8rnSKszsKDYIZDjHFOOcKM1xYz3EeBcRM7xPipgfOmEECIoQwjxHmcEBYiwYgzKWPEBJyBEgLFWAsNYMx1ixe6t0M0LFep1GEMIPY7E7HUuGnxSDaWytQUE025ll4lqvebfNW8007w5U7bdPKH1n3NCAFAbAVCynQowHwcZg63uYdKqNhVK0gU/kq55UN/7vIziIzV4ic4KLzsY9m%2BrsgKOw8oq1XMHVUzNdaiWtr2b2paAoyj7c3XdwNFunN3qVJeBvXiY9k8z2vxjaZWy8aH6YI6Y%2B3BGa1ycuze0t2D67bsqioSgKQnP1IC/VjIqIGE37PDuBrj2y35AQVTBhg/5EgAFpTBSAOBkaaoMSqmY4EQFQagN3ciM6VcgJm3GOaAjip5UEVWpwQuqkjWRs4U0NRhuiFdsMlxZmXF1FcVHN0NYl515GEuqOIzR%2BLMg6OgXuiAtgYDN2epzcrDjkadNUvnrGvjN6NOCek6mkTMnMmeXiW%2B3NpDT6ewFQs4VwBi0abA%2BW3TUGuFfwgnB15AjAtIY1WI0LucabZfprFg1jq8MreC9XJLjqUtGMi4YnmNrMOiTXfR3uGt5M6l9f6wNuBg3ldPXrCl3Hqu8ZCXVgTjL8lNfTfg8TCSrFJI/T1xgjBVocGVuoIgxxPq4ABrfHgJm6AsEwCZ4gKhJDkGwCZ1aUg/C4BM%2BwLgKgeDbj2cNiD7C5qI2YCZlQBwKgIu3NgAIKQUd/kYCZjgOA/wbAlPpqthnbPuciJaNQ0JWCuCkB539ahrOPJ4X5%2BDbzZvcjQODyH0PYfoRmIjzOVqgIADE/DdkwkfI32BVZ4G0EqDII0mgAHVXSXg3IaMxG6BCMYHmVmHcOZhPZG1Vy9cavvhKTUJ6Jz7M3tZzSDzeSmUo/r/QBhoA31MPyp5VuVHDhfcMmyr6baq5vbdQ2Fx1EWpZGuLrhtbNEtv7ctShhujeMt1xO4djunuGMlYHsx1jYg9jeqD9TwJDLPvoIyg137LLYmx8Bx1hP2C2WteyfHxr8pWSyH%2BHwNJ%2B%2BD%2BH4P/IBsTTOUtLaYk0HPSv0pHocHD0wAUedgWTioX0GwKi5MyyzaSvC/J1VUQyASMFFTy29z7ygXUDxBSBoXWEcyeGOTgWeyjl0x42CWvSn0TSnCjz%2B2fXlCsHKV3yP2IP3wAA4X0uVJM81E8et0A6AM9xUhsy0x8xtq1kNOoTczdNFeAh1cwwQFheBzsPUrsOsvBjcR9g9o13t0Cl5MC71k1mUn1WUF9X0N9qCyFek8BVoiB7RbwtCdCNhWDDNlcACAt05gD5tuCyYK9iMq9dEa91tiNNs0t2DzV29XDSNUtTsjtqNO8pZcsvd6w2RrteA3ptDexvFOMXtz0jJpC71%2BMI9sDGs59RM2tF81Cus5wk9%2BUU9/1wcvAOAOBBss9mCc9IM88P8JtwYi9AD1dhFLCLdrCltpEttos685EG8XCGjCMqNksCMdsvCu8nVei9s/DdEAje8RCmMWNl5CjR8yjYjQ9as5CZ8V8UiWsKCJMr91Dut%2BU6ETBIhijQ5SjoiK0KjxtkIQCe9LtgjRCTA/BrgXE/g2BIhkD/EpCljJ9b1ViU11jV8OUMjeUnJT849JNft8DSDd8rAalSDZBCkYS4S0QqQPJ18wSNDYB%2BVf01NGCSitNJDyj38Lia1Aw61dgG1wxm0rgYw7h4xO1Xhu0Uwfg%2B1cBAQB0sxeCR0ERx1URJ0sQcQZ0KwF1yQl0aQV00RGQhDAifcoFgRbEJ4KtTjUC4iw8Vifs1ilD58xN1R2VATr9ekzwLx7NMo74mC8SWDzjFVfNTCEM6jLjuitU0NwtlsuiHCcNDVnDvD7StFdtMt%2BjvTBjq9KNjsyNPSJibjZIOsbFwQ7Ej15S3jXslTPiMDvi1TfiNTUis00l/gdAxQqRqlSDalSC0lt9ylyDdSaD%2BVgB0B0A2cHxhlsBsA8RHNlYQt7pJoWz3B7o4gXhfYVAOzMUepTBsAAB9GAg2IgbQdySnE4lAgJWafEF4qafst/D%2BT/ZgEnXAYclQPwOgBgScpYK0vhMwoRSgYAW6P4XAY3XAZc%2B6WHNgekdnILeHNHG8iUDc2Jc6QGQFTsiUZoRgB8h8bcRiICMZRzWJLOJo9DZ0z010mLDo/DFvdwr01vF0oM3wkMrvJYFQE6KHb8zFb2EgPsvCnFUVACorbkUAnuSgPwV0V8wQVaLGIii3YrKY/vLwVjPYCSTYBSPYMIeYxUkPCfZM%2BrVMxQ5rTYqkf4SpffHQAs0gtEPgKkWQRUEIcslfeUPgIpBS6E2QUg8pNJHSvSgylEkIwKcE9JKwf4NENJayqwCKKwWS%2BStEE/XgM/dUC/JfRrEEzy37R6DxIIWQIAA%3D%3D',
|
||||
{
|
||||
onBeforeLoad: win => {
|
||||
stubConsoleOutput(win);
|
||||
|
||||
116
docs/ClaudeExplain.md
Normal file
116
docs/ClaudeExplain.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Claude Explain
|
||||
|
||||
Claude Explain uses Claude AI to provide natural language explanations of assembly code generation, helping users understand how their source code is translated into assembly and what compiler optimizations are applied.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Click the "Explain" button in the compiler toolbar to open a dedicated explanation pane
|
||||
2. After compilation completes:
|
||||
- Failed compilations show "Cannot explain: Compilation failed"
|
||||
- Code with `no-ai` directive shows a special message
|
||||
- Otherwise, users consent to sending code and compilation output to the Claude API
|
||||
3. Customize explanations by selecting audience level (beginner/intermediate/expert) and explanation type (assembly/source/optimization)
|
||||
4. Once consent is given (persisted for the session), Claude analyzes the code and assembly relationship
|
||||
5. The markdown-formatted explanation appears with syntax highlighting
|
||||
6. Responses are cached client-side (LRU, 200KB limit) and server-side to reduce API costs
|
||||
7. Use the reload button to bypass caches and get fresh explanations
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the API endpoint URL to `compiler-explorer.*.properties`:
|
||||
|
||||
```ini
|
||||
explainApiEndpoint=https://api.compiler-explorer.com/explain
|
||||
```
|
||||
|
||||
The explain button appears automatically when configured.
|
||||
|
||||
## Privacy Notice
|
||||
|
||||
- Source code and compilation output are sent to Anthropic's Claude API after explicit user consent
|
||||
- Consent is remembered for the browser session (not stored in cookies/localStorage)
|
||||
- Anthropic does not use the data for model training
|
||||
- Code containing `no-ai` (case-insensitive) is never sent to the API
|
||||
- Compiler Explorer's privacy policy covers Claude Explain usage
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
**Server-side**: Single property configuration (API endpoint). Server code: https://github.com/compiler-explorer/explain
|
||||
|
||||
**Client-side**:
|
||||
- `ExplainView` class (`static/panes/explain-view.ts`) handles UI, consent, API requests, and caching
|
||||
- `explain-view-utils.ts` contains testable business logic (validation, formatting, request building)
|
||||
- Uses `marked` library for markdown rendering with syntax highlighting
|
||||
- LRU cache (200KB limit) shared across all explain views in the session
|
||||
- Theme-aware styling with responsive layout and font scaling
|
||||
|
||||
**Features**:
|
||||
- Loading states with animated spinner
|
||||
- Error handling with helpful messages
|
||||
- Audience/explanation type selectors with Bootstrap popovers
|
||||
- Status bar showing model, token usage, cost estimates, and cache status
|
||||
- Session-persistent consent and user preferences
|
||||
- Reload button to bypass all caches
|
||||
|
||||
## API Integration
|
||||
|
||||
**GET /** - Fetch available options:
|
||||
```json
|
||||
{
|
||||
"audience": [
|
||||
{"value": "beginner", "description": "Simple language, explains technical terms"},
|
||||
{"value": "intermediate", "description": "Focuses on compiler behavior and choices"},
|
||||
{"value": "expert", "description": "Technical terminology, advanced optimizations"}
|
||||
],
|
||||
"explanation": [
|
||||
{"value": "assembly", "description": "Explains assembly instructions and purpose"},
|
||||
{"value": "source", "description": "Maps source code constructs to assembly"},
|
||||
{"value": "optimization", "description": "Explains compiler optimizations and transformations"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**POST /** - Generate explanation:
|
||||
```json
|
||||
{
|
||||
"language": "c++",
|
||||
"compiler": "GCC 13.2",
|
||||
"code": "Source code",
|
||||
"compilationOptions": ["-O2", "-std=c++20"],
|
||||
"instructionSet": "amd64",
|
||||
"asm": ["Assembly output lines"],
|
||||
"audience": "intermediate",
|
||||
"explanation": "optimization",
|
||||
"bypassCache": false
|
||||
}
|
||||
```
|
||||
|
||||
Optional fields: `audience` (default: "beginner"), `explanation` (default: "assembly"), `bypassCache` (default: false)
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"explanation": "Markdown-formatted explanation",
|
||||
"model": "claude-3-sonnet",
|
||||
"usage": {"inputTokens": 500, "outputTokens": 300, "totalTokens": 800},
|
||||
"cost": {"inputCost": 0.0015, "outputCost": 0.0045, "totalCost": 0.006},
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
**Multi-level caching** reduces API costs and improves response times:
|
||||
|
||||
- **Client-side**: LRU cache, cache key from request payload hash
|
||||
- **Server-side**: Shared cache across users, indicated by `cached: true` in response
|
||||
- **Cache bypass**: Reload button sends `bypassCache: true` for fresh generation
|
||||
- **Status display**: Shows cache state, models, token usage and cost estimates
|
||||
|
||||
## Limitations
|
||||
|
||||
- May not explain every compiler optimization or assembly pattern
|
||||
- Large assemblies may be truncated before sending to API
|
||||
- Requires internet connection for external API access
|
||||
- One explain view per compiler at a time
|
||||
@@ -3,3 +3,6 @@ httpRoot=/beta
|
||||
storageSolution=s3
|
||||
motdUrl=/motd/motd-beta.json
|
||||
sentryEnvironment=beta
|
||||
|
||||
# Claude Explain API endpoint, for beta
|
||||
explainApiEndpoint=https://api.compiler-explorer.com/explain
|
||||
|
||||
@@ -120,6 +120,7 @@ export type ClientOptionsType = {
|
||||
};
|
||||
motdUrl: string;
|
||||
pageloadUrl: string;
|
||||
explainApiEndpoint: string;
|
||||
};
|
||||
|
||||
/***
|
||||
@@ -220,6 +221,7 @@ export class ClientOptionsHandler implements ClientOptionsSource {
|
||||
},
|
||||
},
|
||||
motdUrl: ceProps('motdUrl', ''),
|
||||
explainApiEndpoint: ceProps('explainApiEndpoint', ''),
|
||||
pageloadUrl: ceProps('pageloadUrl', ''),
|
||||
};
|
||||
// Will be immediately replaced with actual values
|
||||
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -43,6 +43,7 @@
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"marked": "^15.0.12",
|
||||
"monaco-editor": "^0.49.0",
|
||||
"monaco-vim": "^0.4.2",
|
||||
"morgan": "^1.10.1",
|
||||
@@ -10268,6 +10269,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "15.0.12",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
|
||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"marked": "^15.0.12",
|
||||
"monaco-editor": "^0.49.0",
|
||||
"monaco-vim": "^0.4.2",
|
||||
"morgan": "^1.10.1",
|
||||
|
||||
@@ -209,3 +209,8 @@ class ArgumentParser {
|
||||
export function splitArguments(str = ''): string[] {
|
||||
return new ArgumentParser(str).exec();
|
||||
}
|
||||
|
||||
export function capitaliseFirst(str: string): string {
|
||||
if (str.length === 0) return str;
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export const GNAT_DEBUG_VIEW_COMPONENT_NAME = 'gnatdebug' as const;
|
||||
export const RUST_MACRO_EXP_VIEW_COMPONENT_NAME = 'rustmacroexp' as const;
|
||||
export const RUST_HIR_VIEW_COMPONENT_NAME = 'rusthir' as const;
|
||||
export const DEVICE_VIEW_COMPONENT_NAME = 'device' as const;
|
||||
export const EXPLAIN_VIEW_COMPONENT_NAME = 'explain' as const;
|
||||
|
||||
export type StateWithLanguage = {lang: string};
|
||||
// TODO(#7808): Normalize state types to reduce duplication (see #4490)
|
||||
@@ -338,6 +339,13 @@ export type PopulatedDeviceViewState = StateWithId & {
|
||||
treeid: number;
|
||||
};
|
||||
|
||||
export type EmptyExplainViewState = EmptyState;
|
||||
export type PopulatedExplainViewState = StateWithId & {
|
||||
compilerName: string;
|
||||
editorid: number;
|
||||
treeid: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of component names to their expected state types. This provides compile-time type safety for component
|
||||
* states. Components can have either empty (default) or populated states.
|
||||
@@ -372,6 +380,7 @@ export interface ComponentStateMap {
|
||||
[RUST_MACRO_EXP_VIEW_COMPONENT_NAME]: EmptyRustMacroExpViewState | PopulatedRustMacroExpViewState;
|
||||
[RUST_HIR_VIEW_COMPONENT_NAME]: EmptyRustHirViewState | PopulatedRustHirViewState;
|
||||
[DEVICE_VIEW_COMPONENT_NAME]: EmptyDeviceViewState | PopulatedDeviceViewState;
|
||||
[EXPLAIN_VIEW_COMPONENT_NAME]: EmptyExplainViewState | PopulatedExplainViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
DragSourceFactory,
|
||||
EDITOR_COMPONENT_NAME,
|
||||
EXECUTOR_COMPONENT_NAME,
|
||||
EXPLAIN_VIEW_COMPONENT_NAME,
|
||||
FLAGS_VIEW_COMPONENT_NAME,
|
||||
GCC_DUMP_VIEW_COMPONENT_NAME,
|
||||
GNAT_DEBUG_TREE_VIEW_COMPONENT_NAME,
|
||||
@@ -939,6 +940,26 @@ export function getDeviceViewWith(
|
||||
};
|
||||
}
|
||||
|
||||
/** Get an empty explain view component. */
|
||||
export function getExplainView(): ComponentConfig<typeof EXPLAIN_VIEW_COMPONENT_NAME> {
|
||||
return createComponentConfig(EXPLAIN_VIEW_COMPONENT_NAME, {});
|
||||
}
|
||||
|
||||
/** Get an explain view with the given configuration. */
|
||||
export function getExplainViewWith(
|
||||
id: number,
|
||||
compilerName: string,
|
||||
editorid: number,
|
||||
treeid: number,
|
||||
): ComponentConfig<typeof EXPLAIN_VIEW_COMPONENT_NAME> {
|
||||
return createComponentConfig(EXPLAIN_VIEW_COMPONENT_NAME, {
|
||||
id,
|
||||
compilerName,
|
||||
editorid,
|
||||
treeid,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a typed component configuration
|
||||
*/
|
||||
|
||||
@@ -72,6 +72,8 @@ export type EventMap = {
|
||||
copyShortLinkToClip: () => void;
|
||||
deviceViewClosed: (compilerId: number) => void;
|
||||
deviceViewOpened: (compilerId: number) => void;
|
||||
explainViewClosed: (compilerId: number) => void;
|
||||
explainViewOpened: (compilerId: number) => void;
|
||||
displaySharingPopover: () => void;
|
||||
editorChange: (editorId: number, source: string, langId: string, compilerId?: number) => void;
|
||||
editorClose: (editorId: number) => void;
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
DIFF_VIEW_COMPONENT_NAME,
|
||||
EDITOR_COMPONENT_NAME,
|
||||
EXECUTOR_COMPONENT_NAME,
|
||||
EXPLAIN_VIEW_COMPONENT_NAME,
|
||||
FLAGS_VIEW_COMPONENT_NAME,
|
||||
GCC_DUMP_VIEW_COMPONENT_NAME,
|
||||
GNAT_DEBUG_TREE_VIEW_COMPONENT_NAME,
|
||||
@@ -70,6 +71,7 @@ import {DeviceAsm as DeviceView} from './panes/device-view.js';
|
||||
import {Diff} from './panes/diff.js';
|
||||
import {Editor} from './panes/editor.js';
|
||||
import {Executor} from './panes/executor.js';
|
||||
import {ExplainView} from './panes/explain-view.js';
|
||||
import {Flags as FlagsView} from './panes/flags-view.js';
|
||||
import {GccDump as GCCDumpView} from './panes/gccdump-view.js';
|
||||
import {GnatDebug as GnatDebugView} from './panes/gnatdebug-view.js';
|
||||
@@ -165,6 +167,7 @@ export class Hub {
|
||||
layout.registerComponent(CONFORMANCE_VIEW_COMPONENT_NAME, (c: GLC, s: any) =>
|
||||
this.conformanceViewFactory(c, s),
|
||||
);
|
||||
layout.registerComponent(EXPLAIN_VIEW_COMPONENT_NAME, (c: GLC, s: any) => this.explainViewFactory(c, s));
|
||||
|
||||
layout.eventHub.on(
|
||||
'editorOpen',
|
||||
@@ -581,4 +584,8 @@ export class Hub {
|
||||
): ConformanceView {
|
||||
return new ConformanceView(this, container, state);
|
||||
}
|
||||
|
||||
public explainViewFactory(container: GoldenLayout.Container, state: any): ExplainView {
|
||||
return new ExplainView(this, container, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,5 @@ export type Options = {
|
||||
supportsExecute: boolean;
|
||||
supportsLibraryCodeFilter: boolean;
|
||||
cvCompilerCountMax: number;
|
||||
explainApiEndpoint: string;
|
||||
};
|
||||
|
||||
@@ -189,11 +189,13 @@ export class Compiler extends MonacoPane<monaco.editor.IStandaloneCodeEditor, Co
|
||||
private gnatDebugButton: JQuery<HTMLButtonElement>;
|
||||
private rustMirButton: JQuery<HTMLButtonElement>;
|
||||
private rustMacroExpButton: JQuery<HTMLButtonElement>;
|
||||
private rustHirButton: JQuery<HTMLButtonElement>;
|
||||
private haskellCoreButton: JQuery<HTMLButtonElement>;
|
||||
private haskellStgButton: JQuery<HTMLButtonElement>;
|
||||
private haskellCmmButton: JQuery<HTMLButtonElement>;
|
||||
private gccDumpButton: JQuery<HTMLButtonElement>;
|
||||
private cfgButton: JQuery<HTMLButtonElement>;
|
||||
private explainButton: JQuery<HTMLButtonElement>;
|
||||
private executorButton: JQuery<HTMLButtonElement>;
|
||||
private libsButton: JQuery<HTMLButtonElement>;
|
||||
private compileInfoLabel: JQuery<HTMLElement>;
|
||||
@@ -236,7 +238,6 @@ export class Compiler extends MonacoPane<monaco.editor.IStandaloneCodeEditor, Co
|
||||
private bottomBar: JQuery<HTMLElement>;
|
||||
private statusLabel: JQuery<HTMLElement>;
|
||||
private statusIcon: JQuery<HTMLElement>;
|
||||
private rustHirButton: JQuery<HTMLButtonElement>;
|
||||
private libsWidget: LibsWidget | null;
|
||||
private isLabelCtxKey: monaco.editor.IContextKey<boolean>;
|
||||
private revealJumpStackHasElementsCtxKey: monaco.editor.IContextKey<boolean>;
|
||||
@@ -658,6 +659,15 @@ export class Compiler extends MonacoPane<monaco.editor.IStandaloneCodeEditor, Co
|
||||
return Components.getCfgViewWith(this.id, this.sourceEditorId ?? 0, this.sourceTreeId ?? 0);
|
||||
};
|
||||
|
||||
const createExplainView = () => {
|
||||
return Components.getExplainViewWith(
|
||||
this.id,
|
||||
this.getCompilerName(),
|
||||
this.sourceEditorId ?? 0,
|
||||
this.sourceTreeId ?? 0,
|
||||
);
|
||||
};
|
||||
|
||||
const createExecutor = () => {
|
||||
const currentState = this.getCurrentState();
|
||||
const editorId = currentState.source;
|
||||
@@ -934,6 +944,18 @@ export class Compiler extends MonacoPane<monaco.editor.IStandaloneCodeEditor, Co
|
||||
insertPoint.addChild(createCfgView());
|
||||
});
|
||||
|
||||
createDragSource(this.container.layoutManager, this.explainButton, () => createExplainView()).on(
|
||||
'dragStart',
|
||||
hidePaneAdder,
|
||||
);
|
||||
|
||||
this.explainButton.on('click', () => {
|
||||
const insertPoint =
|
||||
this.hub.findParentRowOrColumn(this.container.parent) ||
|
||||
this.container.layoutManager.root.contentItems[0];
|
||||
insertPoint.addChild(createExplainView());
|
||||
});
|
||||
|
||||
createDragSource(this.container.layoutManager, this.executorButton, () => createExecutor()).on(
|
||||
'dragStart',
|
||||
hidePaneAdder,
|
||||
@@ -947,6 +969,11 @@ export class Compiler extends MonacoPane<monaco.editor.IStandaloneCodeEditor, Co
|
||||
});
|
||||
|
||||
this.initToolButtons();
|
||||
|
||||
// Hide Claude Explain button if no API endpoint is configured
|
||||
if (!options.explainApiEndpoint) {
|
||||
this.explainButton.hide();
|
||||
}
|
||||
}
|
||||
|
||||
undefer(): void {
|
||||
@@ -2273,6 +2300,19 @@ export class Compiler extends MonacoPane<monaco.editor.IStandaloneCodeEditor, Co
|
||||
}
|
||||
}
|
||||
|
||||
onExplainViewOpened(id: number): void {
|
||||
if (this.id === id) {
|
||||
this.explainButton.prop('disabled', true);
|
||||
this.compile();
|
||||
}
|
||||
}
|
||||
|
||||
onExplainViewClosed(id: number): void {
|
||||
if (this.id === id) {
|
||||
this.explainButton.prop('disabled', false);
|
||||
}
|
||||
}
|
||||
|
||||
initFilterButtons(): void {
|
||||
this.filterBinaryObjectButton = this.domRoot.find("[data-bind='binaryObject']");
|
||||
this.filterBinaryObjectTitle = this.filterBinaryObjectButton.prop('title');
|
||||
@@ -2336,6 +2376,7 @@ export class Compiler extends MonacoPane<monaco.editor.IStandaloneCodeEditor, Co
|
||||
this.haskellCmmButton = this.domRoot.find('.btn.view-haskellCmm');
|
||||
this.gccDumpButton = this.domRoot.find('.btn.view-gccdump');
|
||||
this.cfgButton = this.domRoot.find('.btn.view-cfg');
|
||||
this.explainButton = this.domRoot.find('.btn.view-explain');
|
||||
this.executorButton = this.domRoot.find('.create-executor');
|
||||
this.libsButton = this.domRoot.find('.btn.show-libs');
|
||||
|
||||
@@ -2828,6 +2869,8 @@ export class Compiler extends MonacoPane<monaco.editor.IStandaloneCodeEditor, Co
|
||||
|
||||
this.eventHub.on('cfgViewOpened', this.onCfgViewOpened, this);
|
||||
this.eventHub.on('cfgViewClosed', this.onCfgViewClosed, this);
|
||||
this.eventHub.on('explainViewOpened', this.onExplainViewOpened, this);
|
||||
this.eventHub.on('explainViewClosed', this.onExplainViewClosed, this);
|
||||
this.eventHub.on('requestCompiler', id => {
|
||||
if (id === this.id) {
|
||||
this.sendCompiler();
|
||||
|
||||
225
static/panes/explain-view-utils.ts
Normal file
225
static/panes/explain-view-utils.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2025, Compiler Explorer Authors
|
||||
// All rights reserved.
|
||||
//
|
||||
// Redistribution and use in source and binary forms, with or without
|
||||
// modification, are permitted provided that the following conditions are met:
|
||||
//
|
||||
// * Redistributions of source code must retain the above copyright notice,
|
||||
// this list of conditions and the following disclaimer.
|
||||
// * Redistributions in binary form must reproduce the above copyright
|
||||
// notice, this list of conditions and the following disclaimer in the
|
||||
// documentation and/or other materials provided with the distribution.
|
||||
//
|
||||
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
// POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import {marked} from 'marked';
|
||||
import {capitaliseFirst} from '../../shared/common-utils.js';
|
||||
import {CompilationResult} from '../../types/compilation/compilation.interfaces.js';
|
||||
import {CompilerInfo} from '../../types/compiler.interfaces.js';
|
||||
import {AvailableOptions, ClaudeExplainResponse, ExplainRequest} from './explain-view.interfaces.js';
|
||||
|
||||
// Anything to do with the explain view that doesn't need any direct UI access, so we can test it easily.
|
||||
// Includes validation, request building, caching, formatting, and other pure functions.
|
||||
|
||||
export interface ExplainContext {
|
||||
lastResult: CompilationResult | null;
|
||||
compiler: CompilerInfo | null;
|
||||
selectedAudience: string;
|
||||
selectedExplanation: string;
|
||||
explainApiEndpoint: string;
|
||||
consentGiven: boolean;
|
||||
availableOptions: AvailableOptions | null;
|
||||
}
|
||||
|
||||
export enum ValidationErrorCode {
|
||||
MISSING_REQUIRED_DATA = 'MISSING_REQUIRED_DATA',
|
||||
OPTIONS_NOT_AVAILABLE = 'OPTIONS_NOT_AVAILABLE',
|
||||
API_ENDPOINT_NOT_CONFIGURED = 'API_ENDPOINT_NOT_CONFIGURED',
|
||||
NO_AI_DIRECTIVE_FOUND = 'NO_AI_DIRECTIVE_FOUND',
|
||||
}
|
||||
|
||||
export type ValidationResult = {success: true} | {success: false; errorCode: ValidationErrorCode; message: string};
|
||||
|
||||
/**
|
||||
* Validates that all preconditions are met for fetching an explanation.
|
||||
* Returns a result object indicating success or failure with error message.
|
||||
*/
|
||||
export function validateExplainPreconditions(context: ExplainContext): ValidationResult {
|
||||
if (!context.lastResult || !context.consentGiven || !context.compiler) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: ValidationErrorCode.MISSING_REQUIRED_DATA,
|
||||
message: 'Missing required data: compilation result, consent, or compiler info',
|
||||
};
|
||||
}
|
||||
|
||||
if (context.availableOptions === null) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: ValidationErrorCode.OPTIONS_NOT_AVAILABLE,
|
||||
message: 'Explain options not available',
|
||||
};
|
||||
}
|
||||
|
||||
if (!context.explainApiEndpoint) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: ValidationErrorCode.API_ENDPOINT_NOT_CONFIGURED,
|
||||
message: 'Claude Explain API endpoint not configured',
|
||||
};
|
||||
}
|
||||
|
||||
if (context.lastResult.source && checkForNoAiDirective(context.lastResult.source)) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: ValidationErrorCode.NO_AI_DIRECTIVE_FOUND,
|
||||
message: 'no-ai directive found in source code',
|
||||
};
|
||||
}
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the request payload for the explain API.
|
||||
* Handles defaults for optional fields and constructs a complete ExplainRequest.
|
||||
*/
|
||||
export function buildExplainRequest(context: ExplainContext, bypassCache: boolean): ExplainRequest {
|
||||
if (!context.compiler || !context.lastResult) {
|
||||
throw new Error('Missing compiler or compilation result');
|
||||
}
|
||||
|
||||
return {
|
||||
language: context.compiler.lang,
|
||||
compiler: context.compiler.name,
|
||||
code: context.lastResult.source ?? '',
|
||||
compilationOptions: context.lastResult.compilationOptions ?? [],
|
||||
instructionSet: context.lastResult.instructionSet ?? 'amd64',
|
||||
asm: Array.isArray(context.lastResult.asm) ? context.lastResult.asm : [],
|
||||
audience: context.selectedAudience,
|
||||
explanation: context.selectedExplanation,
|
||||
...(bypassCache && {bypassCache: true}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the source code contains a no-ai directive (case-insensitive).
|
||||
* Returns true if the directive is found, false otherwise.
|
||||
*/
|
||||
export function checkForNoAiDirective(sourceCode: string): boolean {
|
||||
return /no-ai/i.test(sourceCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a consistent cache key from the request payload.
|
||||
* Uses JSON serialization of normalized payload fields.
|
||||
*/
|
||||
export function generateCacheKey(payload: ExplainRequest): string {
|
||||
return JSON.stringify({
|
||||
language: payload.language,
|
||||
compiler: payload.compiler,
|
||||
code: payload.code,
|
||||
compilationOptions: payload.compilationOptions ?? [],
|
||||
instructionSet: payload.instructionSet,
|
||||
asm: payload.asm,
|
||||
audience: payload.audience,
|
||||
explanation: payload.explanation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats markdown text to HTML using marked with consistent options.
|
||||
* Returns the HTML string ready for display.
|
||||
*/
|
||||
export function formatMarkdown(markdown: string): string {
|
||||
const markedOptions = {
|
||||
gfm: true, // GitHub Flavored Markdown
|
||||
breaks: true, // Convert line breaks to <br>
|
||||
};
|
||||
|
||||
// marked.parse() is synchronous and returns a string, but TypeScript types suggest it could be Promise<string>
|
||||
// The cast is safe because we're using the default synchronous implementation
|
||||
return marked.parse(markdown, markedOptions) as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats statistics text from Claude API response data.
|
||||
* Returns an array of formatted stats strings.
|
||||
*/
|
||||
export function formatStatsText(
|
||||
data: ClaudeExplainResponse,
|
||||
clientCacheHit: boolean,
|
||||
serverCacheHit: boolean,
|
||||
): string[] {
|
||||
if (!data.usage) return [];
|
||||
|
||||
const stats: string[] = [clientCacheHit ? 'Cached (client)' : serverCacheHit ? 'Cached (server)' : 'Fresh'];
|
||||
|
||||
if (data.model) {
|
||||
stats.push(`Model: ${data.model}`);
|
||||
}
|
||||
if (data.usage.totalTokens) {
|
||||
stats.push(`Tokens: ${data.usage.totalTokens}`);
|
||||
}
|
||||
if (data.cost?.totalCost !== undefined) {
|
||||
stats.push(`Cost: $${data.cost.totalCost.toFixed(6)}`);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates HTML content for popover tooltips from an array of options.
|
||||
* Each option becomes a formatted div with bold value and description.
|
||||
*/
|
||||
export function createPopoverContent(optionsList: Array<{value: string; description: string}>): string {
|
||||
return optionsList
|
||||
.map(
|
||||
option =>
|
||||
`<div class='mb-2'><strong>${capitaliseFirst(option.value)}:</strong> ${option.description}</div>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an error for display, handling both Error objects and other types.
|
||||
* Returns a user-friendly error message string.
|
||||
*/
|
||||
export function formatErrorMessage(error: unknown): string {
|
||||
let errorMessage: string;
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
} else if (typeof error === 'object' && error !== null) {
|
||||
// Try to extract useful information from object errors
|
||||
const errorObj = error as Record<string, unknown>;
|
||||
if ('message' in errorObj && typeof errorObj.message === 'string') {
|
||||
errorMessage = errorObj.message;
|
||||
} else if ('error' in errorObj && typeof errorObj.error === 'string') {
|
||||
errorMessage = errorObj.error;
|
||||
} else {
|
||||
// Fall back to JSON.stringify for better debugging
|
||||
try {
|
||||
errorMessage = JSON.stringify(error);
|
||||
} catch {
|
||||
errorMessage = String(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errorMessage = String(error);
|
||||
}
|
||||
|
||||
return `Error: ${errorMessage}`;
|
||||
}
|
||||
71
static/panes/explain-view.interfaces.ts
Normal file
71
static/panes/explain-view.interfaces.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2025, Compiler Explorer Authors
|
||||
// All rights reserved.
|
||||
//
|
||||
// Redistribution and use in source and binary forms, with or without
|
||||
// modification, are permitted provided that the following conditions are met:
|
||||
//
|
||||
// * Redistributions of source code must retain the above copyright notice,
|
||||
// this list of conditions and the following disclaimer.
|
||||
// * Redistributions in binary form must reproduce the above copyright
|
||||
// notice, this list of conditions and the following disclaimer in the
|
||||
// documentation and/or other materials provided with the distribution.
|
||||
//
|
||||
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
// POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import {ParsedAsmResultLine} from '../../types/asmresult/asmresult.interfaces.js';
|
||||
import {PaneState} from './pane.interfaces.js';
|
||||
|
||||
export interface ExplainViewState extends PaneState {
|
||||
audience?: string;
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
export interface ExplanationOption {
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AvailableOptions {
|
||||
audience: ExplanationOption[];
|
||||
explanation: ExplanationOption[];
|
||||
}
|
||||
|
||||
export interface ExplainRequest {
|
||||
language: string;
|
||||
compiler: string;
|
||||
code: string;
|
||||
compilationOptions: string[];
|
||||
instructionSet: string;
|
||||
asm: ParsedAsmResultLine[];
|
||||
audience?: string;
|
||||
explanation?: string;
|
||||
bypassCache?: boolean;
|
||||
}
|
||||
|
||||
export interface ClaudeExplainResponse {
|
||||
status: 'success' | 'error';
|
||||
explanation: string;
|
||||
message?: string;
|
||||
model?: string;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
cost?: {
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
totalCost: number;
|
||||
};
|
||||
cached: boolean;
|
||||
}
|
||||
545
static/panes/explain-view.ts
Normal file
545
static/panes/explain-view.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
// Copyright (c) 2025, Compiler Explorer Authors
|
||||
// All rights reserved.
|
||||
//
|
||||
// Redistribution and use in source and binary forms, with or without
|
||||
// modification, are permitted provided that the following conditions are met:
|
||||
//
|
||||
// * Redistributions of source code must retain the above copyright notice,
|
||||
// this list of conditions and the following disclaimer.
|
||||
// * Redistributions in binary form must reproduce the above copyright
|
||||
// notice, this list of conditions and the following disclaimer in the
|
||||
// documentation and/or other materials provided with the distribution.
|
||||
//
|
||||
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
// POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import {Container} from 'golden-layout';
|
||||
import $ from 'jquery';
|
||||
import {LRUCache} from 'lru-cache';
|
||||
import {capitaliseFirst} from '../../shared/common-utils.js';
|
||||
import {CompilationResult} from '../../types/compilation/compilation.interfaces.js';
|
||||
import {CompilerInfo} from '../../types/compiler.interfaces.js';
|
||||
import {initPopover} from '../bootstrap-utils.js';
|
||||
import {Hub} from '../hub.js';
|
||||
import {options} from '../options.js';
|
||||
import {SentryCapture} from '../sentry.js';
|
||||
import * as utils from '../utils.js';
|
||||
import {FontScale} from '../widgets/fontscale.js';
|
||||
import {AvailableOptions, ClaudeExplainResponse, ExplainRequest, ExplainViewState} from './explain-view.interfaces.js';
|
||||
import {
|
||||
buildExplainRequest,
|
||||
checkForNoAiDirective,
|
||||
createPopoverContent,
|
||||
ExplainContext,
|
||||
formatErrorMessage,
|
||||
formatMarkdown,
|
||||
formatStatsText,
|
||||
generateCacheKey,
|
||||
ValidationErrorCode,
|
||||
validateExplainPreconditions,
|
||||
} from './explain-view-utils.js';
|
||||
import {Pane} from './pane.js';
|
||||
|
||||
enum StatusIconState {
|
||||
Loading = 'loading',
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
Hidden = 'hidden',
|
||||
}
|
||||
|
||||
const defaultAudienceType = 'beginner';
|
||||
const defaultExplanationType = 'assembly';
|
||||
|
||||
const statusIconConfigs = {
|
||||
[StatusIconState.Loading]: {
|
||||
classes: 'status-icon fas fa-spinner fa-spin',
|
||||
color: '',
|
||||
ariaLabel: 'Generating explanation...',
|
||||
},
|
||||
[StatusIconState.Success]: {
|
||||
classes: 'status-icon fas fa-check-circle',
|
||||
color: '#4CAF50',
|
||||
ariaLabel: 'Explanation generated successfully',
|
||||
},
|
||||
[StatusIconState.Error]: {
|
||||
classes: 'status-icon fas fa-times-circle',
|
||||
color: '#FF6645',
|
||||
ariaLabel: 'Error generating explanation',
|
||||
},
|
||||
[StatusIconState.Hidden]: {
|
||||
classes: 'status-icon fas d-none',
|
||||
color: '',
|
||||
ariaLabel: '',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export class ExplainView extends Pane<ExplainViewState> {
|
||||
private lastResult: CompilationResult | null = null;
|
||||
private compiler: CompilerInfo | null = null;
|
||||
private readonly statusIcon: JQuery;
|
||||
private readonly consentElement: JQuery;
|
||||
private readonly noAiElement: JQuery;
|
||||
private readonly contentElement: JQuery;
|
||||
private readonly bottomBarElement: JQuery;
|
||||
private readonly statsElement: JQuery;
|
||||
private readonly audienceSelect: JQuery;
|
||||
private readonly explanationSelect: JQuery;
|
||||
private readonly audienceInfoButton: JQuery;
|
||||
private readonly explanationInfoButton: JQuery;
|
||||
private readonly explainApiEndpoint: string;
|
||||
private readonly fontScale: FontScale;
|
||||
private readonly cache: LRUCache<string, ClaudeExplainResponse>;
|
||||
|
||||
// Use a static variable to persist consent across all instances during the session
|
||||
private static consentGiven = false;
|
||||
|
||||
// Static cache for available options (shared across all instances)
|
||||
private static availableOptions: AvailableOptions | null = null;
|
||||
private static optionsFetchPromise: Promise<AvailableOptions> | null = null;
|
||||
|
||||
// Instance variables for selected options
|
||||
private selectedAudience: string;
|
||||
private selectedExplanation: string;
|
||||
private isInitializing = true;
|
||||
|
||||
// Store compilation results that arrive before initialization completes
|
||||
private pendingCompilationResult: CompilationResult | null = null;
|
||||
|
||||
constructor(hub: Hub, container: Container, state: ExplainViewState) {
|
||||
super(hub, container, state);
|
||||
|
||||
this.explainApiEndpoint = options.explainApiEndpoint ?? '';
|
||||
this.cache = new LRUCache({
|
||||
maxSize: 200 * 1024,
|
||||
sizeCalculation: n => JSON.stringify(n).length,
|
||||
});
|
||||
|
||||
this.statusIcon = this.domRoot.find('.status-icon');
|
||||
this.consentElement = this.domRoot.find('.explain-consent');
|
||||
this.noAiElement = this.domRoot.find('.explain-no-ai');
|
||||
this.contentElement = this.domRoot.find('.explain-content');
|
||||
this.bottomBarElement = this.domRoot.find('.explain-bottom-bar');
|
||||
this.statsElement = this.domRoot.find('.explain-stats');
|
||||
this.audienceSelect = this.domRoot.find('.explain-audience');
|
||||
this.explanationSelect = this.domRoot.find('.explain-type');
|
||||
this.audienceInfoButton = this.domRoot.find('.explain-audience-info');
|
||||
this.explanationInfoButton = this.domRoot.find('.explain-type-info');
|
||||
|
||||
this.fontScale = new FontScale(this.domRoot, state, '.explain-content');
|
||||
this.fontScale.on('change', this.updateState.bind(this));
|
||||
|
||||
this.attachEventListeners();
|
||||
void this.initializeOptions();
|
||||
|
||||
this.contentElement.text('Waiting for compilation...');
|
||||
this.isAwaitingInitialResults = true;
|
||||
this.eventHub.emit('explainViewOpened', this.compilerInfo.compilerId);
|
||||
}
|
||||
|
||||
private handleConsentClick(): void {
|
||||
ExplainView.consentGiven = true;
|
||||
this.consentElement.addClass('d-none');
|
||||
void this.fetchExplanation();
|
||||
}
|
||||
|
||||
private handleReloadClick(): void {
|
||||
void this.fetchExplanation(true);
|
||||
}
|
||||
|
||||
private handleAudienceChange(): void {
|
||||
this.selectedAudience = this.audienceSelect.val() as string;
|
||||
this.updateState();
|
||||
this.refreshExplanationIfReady();
|
||||
}
|
||||
|
||||
private handleExplanationChange(): void {
|
||||
this.selectedExplanation = this.explanationSelect.val() as string;
|
||||
this.updateState();
|
||||
this.refreshExplanationIfReady();
|
||||
}
|
||||
|
||||
private refreshExplanationIfReady(): void {
|
||||
if (ExplainView.consentGiven && this.lastResult) {
|
||||
void this.fetchExplanation();
|
||||
}
|
||||
}
|
||||
|
||||
private attachEventListeners(): void {
|
||||
this.consentElement.find('.consent-btn').on('click', this.handleConsentClick.bind(this));
|
||||
this.bottomBarElement.find('.explain-reload').on('click', this.handleReloadClick.bind(this));
|
||||
this.audienceSelect.on('change', this.handleAudienceChange.bind(this));
|
||||
this.explanationSelect.on('change', this.handleExplanationChange.bind(this));
|
||||
}
|
||||
|
||||
override getInitialHTML(): string {
|
||||
return $('#explain').html();
|
||||
}
|
||||
|
||||
private async initializeOptions(): Promise<void> {
|
||||
try {
|
||||
this.populateSelectOptions(await this.fetchAvailableOptions());
|
||||
this.isInitializing = false;
|
||||
|
||||
// Process any compilation results that arrived while we were initializing.
|
||||
if (this.pendingCompilationResult) {
|
||||
this.handleCompilationResult(this.pendingCompilationResult);
|
||||
this.pendingCompilationResult = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.isInitializing = false;
|
||||
console.error('Failed to initialize options:', error);
|
||||
this.showExplainUnavailable();
|
||||
// Even if initialization failed, clear any pending results
|
||||
this.pendingCompilationResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
private showExplainUnavailable(): void {
|
||||
const emptyOptions = [{value: '', description: 'Service unavailable'}];
|
||||
this.populateSelect(this.audienceSelect, emptyOptions);
|
||||
this.populateSelect(this.explanationSelect, emptyOptions);
|
||||
|
||||
this.audienceSelect.prop('disabled', true);
|
||||
this.explanationSelect.prop('disabled', true);
|
||||
|
||||
this.contentElement.html(
|
||||
'<div class="alert alert-warning">Claude Explain is currently unavailable due to a service error. Please try again later.</div>',
|
||||
);
|
||||
}
|
||||
|
||||
private populateSelect(selectElement: JQuery, optionsList: Array<{value: string; description: string}>): void {
|
||||
selectElement.empty();
|
||||
optionsList.forEach(option => {
|
||||
const optionElement = $('<option></option>')
|
||||
.attr('value', option.value)
|
||||
.text(capitaliseFirst(option.value))
|
||||
.attr('title', option.description);
|
||||
selectElement.append(optionElement);
|
||||
});
|
||||
}
|
||||
|
||||
private populateSelectOptions(options: AvailableOptions): void {
|
||||
this.populateSelect(this.audienceSelect, options.audience);
|
||||
this.populateSelect(this.explanationSelect, options.explanation);
|
||||
this.updatePopoverContent(options);
|
||||
|
||||
if (this.isInitializing) {
|
||||
// During initialisation: trust saved state completely, no validation
|
||||
this.audienceSelect.val(this.selectedAudience);
|
||||
this.explanationSelect.val(this.selectedExplanation);
|
||||
} else {
|
||||
// After initialisation: validate user changes normally
|
||||
const validAudienceValue = options.audience.some(opt => opt.value === this.selectedAudience)
|
||||
? this.selectedAudience
|
||||
: defaultAudienceType;
|
||||
const validExplanationValue = options.explanation.some(opt => opt.value === this.selectedExplanation)
|
||||
? this.selectedExplanation
|
||||
: defaultExplanationType;
|
||||
|
||||
this.selectedAudience = validAudienceValue;
|
||||
this.selectedExplanation = validExplanationValue;
|
||||
|
||||
this.audienceSelect.val(validAudienceValue);
|
||||
this.explanationSelect.val(validExplanationValue);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePopoverContent(options: AvailableOptions): void {
|
||||
const audienceContent = createPopoverContent(options.audience);
|
||||
const explanationContent = createPopoverContent(options.explanation);
|
||||
|
||||
initPopover(this.audienceInfoButton, {
|
||||
content: audienceContent,
|
||||
html: true,
|
||||
placement: 'bottom',
|
||||
trigger: 'focus',
|
||||
});
|
||||
initPopover(this.explanationInfoButton, {
|
||||
content: explanationContent,
|
||||
html: true,
|
||||
placement: 'bottom',
|
||||
trigger: 'focus',
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchAvailableOptions(): Promise<AvailableOptions> {
|
||||
// If we already have options cached, return them
|
||||
if (ExplainView.availableOptions) return ExplainView.availableOptions;
|
||||
// If we're already fetching, wait for that promise
|
||||
if (ExplainView.optionsFetchPromise) return ExplainView.optionsFetchPromise;
|
||||
|
||||
// Else, go fetch the options
|
||||
ExplainView.optionsFetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(this.explainApiEndpoint, {
|
||||
method: 'GET',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch options: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const options = (await response.json()) as AvailableOptions;
|
||||
ExplainView.availableOptions = options;
|
||||
return options;
|
||||
} finally {
|
||||
ExplainView.optionsFetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return ExplainView.optionsFetchPromise;
|
||||
}
|
||||
|
||||
override initializeStateDependentProperties(state: ExplainViewState): void {
|
||||
this.selectedAudience = state.audience ?? defaultAudienceType;
|
||||
this.selectedExplanation = state.explanation ?? defaultExplanationType;
|
||||
}
|
||||
|
||||
override getCurrentState(): ExplainViewState {
|
||||
const state = super.getCurrentState() as ExplainViewState;
|
||||
state.audience = this.selectedAudience;
|
||||
state.explanation = this.selectedExplanation;
|
||||
return state;
|
||||
}
|
||||
|
||||
override onCompiler(
|
||||
compilerId: number,
|
||||
compiler: CompilerInfo | null,
|
||||
_compilerOptions: string,
|
||||
editorId: number,
|
||||
treeId: number,
|
||||
): void {
|
||||
if (this.compilerInfo.compilerId !== compilerId) return;
|
||||
this.compilerInfo.compilerName = compiler ? compiler.name : '';
|
||||
this.compilerInfo.editorId = editorId;
|
||||
this.compilerInfo.treeId = treeId;
|
||||
this.compiler = compiler;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
private hideSpecialUIElements(): void {
|
||||
this.consentElement.addClass('d-none');
|
||||
this.noAiElement.addClass('d-none');
|
||||
}
|
||||
|
||||
private handleCompilationResult(result: CompilationResult): void {
|
||||
if (this.isInitializing) {
|
||||
// Store for processing after initialization completes
|
||||
this.pendingCompilationResult = result;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
this.contentElement.text('Cannot explain: Compilation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.source && checkForNoAiDirective(result.source)) {
|
||||
this.showNoAiDirective();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ExplainView.consentGiven) {
|
||||
void this.fetchExplanation();
|
||||
} else {
|
||||
this.showConsentUI();
|
||||
}
|
||||
}
|
||||
|
||||
private showNoAiDirective(): void {
|
||||
this.noAiElement.removeClass('d-none');
|
||||
this.contentElement.text('');
|
||||
}
|
||||
|
||||
private showConsentUI(): void {
|
||||
this.consentElement.removeClass('d-none');
|
||||
this.contentElement.text('Claude needs your consent to explain this code.');
|
||||
}
|
||||
|
||||
override onCompileResult(id: number, compiler: CompilerInfo, result: CompilationResult): void {
|
||||
if (id !== this.compilerInfo.compilerId) return;
|
||||
|
||||
this.compiler = compiler;
|
||||
this.lastResult = result;
|
||||
this.isAwaitingInitialResults = false;
|
||||
|
||||
this.hideSpecialUIElements();
|
||||
this.handleCompilationResult(result);
|
||||
}
|
||||
|
||||
private setStatusIcon(state: StatusIconState): void {
|
||||
const config = statusIconConfigs[state];
|
||||
this.statusIcon
|
||||
.removeClass()
|
||||
.addClass(config.classes)
|
||||
.css('color', config.color)
|
||||
.attr('aria-label', config.ariaLabel);
|
||||
}
|
||||
|
||||
private showLoading(): void {
|
||||
this.setStatusIcon(StatusIconState.Loading);
|
||||
this.contentElement.text('Generating explanation...');
|
||||
}
|
||||
|
||||
private hideLoading(): void {
|
||||
this.setStatusIcon(StatusIconState.Hidden);
|
||||
}
|
||||
|
||||
private showSuccess(): void {
|
||||
this.setStatusIcon(StatusIconState.Success);
|
||||
}
|
||||
|
||||
private showError(): void {
|
||||
this.setStatusIcon(StatusIconState.Error);
|
||||
}
|
||||
|
||||
private showBottomBar(): void {
|
||||
this.bottomBarElement.removeClass('d-none');
|
||||
}
|
||||
|
||||
private updateStatsInBottomBar(
|
||||
data: ClaudeExplainResponse,
|
||||
clientCacheHit: boolean,
|
||||
serverCacheHit: boolean,
|
||||
): void {
|
||||
const stats = formatStatsText(data, clientCacheHit, serverCacheHit);
|
||||
if (stats.length > 0) {
|
||||
this.statsElement.text(stats.join(' | '));
|
||||
}
|
||||
}
|
||||
|
||||
private displayCachedResult(cachedResult: ClaudeExplainResponse): void {
|
||||
this.hideLoading();
|
||||
this.showSuccess();
|
||||
this.renderMarkdown(cachedResult.explanation);
|
||||
this.showBottomBar();
|
||||
|
||||
if (cachedResult.usage) {
|
||||
this.updateStatsInBottomBar(cachedResult, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFromAPI(payload: ExplainRequest): Promise<ClaudeExplainResponse> {
|
||||
const response = await window.fetch(this.explainApiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<ClaudeExplainResponse>;
|
||||
}
|
||||
|
||||
private getExplainContext(): ExplainContext {
|
||||
return {
|
||||
lastResult: this.lastResult,
|
||||
compiler: this.compiler,
|
||||
selectedAudience: this.selectedAudience,
|
||||
selectedExplanation: this.selectedExplanation,
|
||||
explainApiEndpoint: this.explainApiEndpoint,
|
||||
consentGiven: ExplainView.consentGiven,
|
||||
availableOptions: ExplainView.availableOptions,
|
||||
};
|
||||
}
|
||||
|
||||
private processExplanationResponse(data: ClaudeExplainResponse, cacheKey: string): void {
|
||||
this.hideLoading();
|
||||
|
||||
if (data.status === 'error') {
|
||||
this.showError();
|
||||
this.contentElement.text(`Error: ${data.message || 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSuccess();
|
||||
this.cache.set(cacheKey, data);
|
||||
this.renderMarkdown(data.explanation);
|
||||
this.showBottomBar();
|
||||
|
||||
if (data.usage) {
|
||||
this.updateStatsInBottomBar(data, false, data.cached);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchExplanation(bypassCache = false): Promise<void> {
|
||||
const context = this.getExplainContext();
|
||||
const validationResult = validateExplainPreconditions(context);
|
||||
|
||||
if (!validationResult.success) {
|
||||
switch (validationResult.errorCode) {
|
||||
case ValidationErrorCode.NO_AI_DIRECTIVE_FOUND:
|
||||
this.hideLoading();
|
||||
this.noAiElement.removeClass('d-none');
|
||||
this.contentElement.text('');
|
||||
break;
|
||||
case ValidationErrorCode.MISSING_REQUIRED_DATA:
|
||||
// Silent return - this is expected during normal UI flow (before compilation, before consent, etc.)
|
||||
break;
|
||||
default:
|
||||
// Show all other validation errors to help with debugging
|
||||
this.contentElement.text(`Error: ${validationResult.message}`);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.contentElement.empty();
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
const payload = buildExplainRequest(context, bypassCache);
|
||||
const cacheKey = generateCacheKey(payload);
|
||||
|
||||
// Check cache first unless bypassing
|
||||
if (!bypassCache) {
|
||||
const cachedResult = this.cache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
this.displayCachedResult(cachedResult);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await this.fetchFromAPI(payload);
|
||||
this.processExplanationResponse(data, cacheKey);
|
||||
} catch (error) {
|
||||
this.handleFetchError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFetchError(error: unknown): void {
|
||||
this.hideLoading();
|
||||
this.showError();
|
||||
this.contentElement.text(formatErrorMessage(error));
|
||||
SentryCapture(error);
|
||||
}
|
||||
|
||||
private renderMarkdown(markdown: string): void {
|
||||
this.contentElement.html(formatMarkdown(markdown));
|
||||
}
|
||||
|
||||
override resize(): void {
|
||||
utils.updateAndCalcTopBarHeight(this.domRoot, this.topBar, this.hideable);
|
||||
}
|
||||
|
||||
override getDefaultPaneName(): string {
|
||||
return 'Claude Explain';
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
this.eventHub.emit('explainViewClosed', this.compilerInfo.compilerId);
|
||||
this.eventHub.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
@use 'themes/dark-theme';
|
||||
@use 'themes/pink-theme';
|
||||
@use 'themes/one-dark-theme';
|
||||
@use 'markdown.scss';
|
||||
|
||||
@import '~@fortawesome/fontawesome-free/css/all.min.css';
|
||||
|
||||
@@ -40,6 +41,38 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Styles for Claude Explain component
|
||||
.explain-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.explain-box {
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin: 15px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
max-width: 50em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.explain-bottom-bar {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.ai-disclaimer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.navbar-godbolt {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
205
static/styles/markdown.scss
Normal file
205
static/styles/markdown.scss
Normal file
@@ -0,0 +1,205 @@
|
||||
// Shared markdown rendering styles
|
||||
// Used by components that render markdown content (e.g., explain view)
|
||||
|
||||
.markdown-content {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
|
||||
&.wrap {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// Headers
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
// Code blocks
|
||||
pre {
|
||||
background-color: #f6f8fa;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e1e4e8;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'SF Mono', 'Courier New', Consolas, 'Liberation Mono', monospace;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
// Tables
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
// Blockquotes
|
||||
blockquote {
|
||||
border-left: 0.25rem solid #dfe2e5;
|
||||
padding: 0 1rem;
|
||||
color: #6a737d;
|
||||
margin: 1rem 0;
|
||||
|
||||
> p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Lists
|
||||
ul, ol {
|
||||
padding-left: 2rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
|
||||
> p {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
+ li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Paragraphs
|
||||
p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
hr {
|
||||
height: 0.25em;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0;
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// Links
|
||||
a {
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme adjustments based on CE's dark mode
|
||||
[data-theme="dark"] & {
|
||||
color: #c9d1d9;
|
||||
|
||||
h1, h2 {
|
||||
border-bottom-color: #30363d;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
background-color: #161b22;
|
||||
border-color: #30363d;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(240, 246, 252, 0.15);
|
||||
}
|
||||
|
||||
pre code {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
table {
|
||||
th, td {
|
||||
border-color: #30363d;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #161b22;
|
||||
}
|
||||
|
||||
tr:nth-child(2n) {
|
||||
background-color: #161b22;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left-color: #30363d;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
hr {
|
||||
background-color: #30363d;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #58a6ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -727,4 +727,20 @@ textarea.form-control {
|
||||
}
|
||||
}
|
||||
|
||||
.explain-box {
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.explain-bottom-bar {
|
||||
background-color: #2d2d2d !important;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.ai-disclaimer.bg-warning {
|
||||
background-color: #664d03 !important;
|
||||
color: #ffda6a !important;
|
||||
}
|
||||
|
||||
} // End html[data-theme='dark']
|
||||
|
||||
@@ -802,4 +802,20 @@ textarea.form-control {
|
||||
}
|
||||
}
|
||||
|
||||
.explain-box {
|
||||
background-color: #282c34;
|
||||
border-color: #4b5263;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.explain-bottom-bar {
|
||||
background-color: $dark !important;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.ai-disclaimer.bg-warning {
|
||||
background-color: #664d03 !important;
|
||||
color: #ffda6a !important;
|
||||
}
|
||||
|
||||
} // End html[data-theme='one-dark']
|
||||
|
||||
@@ -734,4 +734,18 @@ textarea.form-control {
|
||||
}
|
||||
}
|
||||
|
||||
.explain-box {
|
||||
background-color: #fde5fd;
|
||||
border-color: #e3a5e3;
|
||||
}
|
||||
|
||||
.explain-bottom-bar {
|
||||
background-color: #e3a5e3 !important;
|
||||
}
|
||||
|
||||
.ai-disclaimer.bg-warning {
|
||||
background-color: #e787e7 !important;
|
||||
color: #3c3c3f !important;
|
||||
}
|
||||
|
||||
} // End html[data-theme='pink']
|
||||
|
||||
527
static/tests/panes/explain-view-utils-tests.ts
Normal file
527
static/tests/panes/explain-view-utils-tests.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
// Copyright (c) 2025, Compiler Explorer Authors
|
||||
// All rights reserved.
|
||||
//
|
||||
// Redistribution and use in source and binary forms, with or without
|
||||
// modification, are permitted provided that the following conditions are met:
|
||||
//
|
||||
// * Redistributions of source code must retain the above copyright notice,
|
||||
// this list of conditions and the following disclaimer.
|
||||
// * Redistributions in binary form must reproduce the above copyright
|
||||
// notice, this list of conditions and the following disclaimer in the
|
||||
// documentation and/or other materials provided with the distribution.
|
||||
//
|
||||
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
// POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
import {CompilationResult} from '../../../types/compilation/compilation.interfaces.js';
|
||||
import {CompilerInfo} from '../../../types/compiler.interfaces.js';
|
||||
import {ClaudeExplainResponse, ExplainRequest} from '../../panes/explain-view.interfaces.js';
|
||||
import {
|
||||
buildExplainRequest,
|
||||
checkForNoAiDirective,
|
||||
createPopoverContent,
|
||||
ExplainContext,
|
||||
formatErrorMessage,
|
||||
formatMarkdown,
|
||||
formatStatsText,
|
||||
generateCacheKey,
|
||||
ValidationErrorCode,
|
||||
validateExplainPreconditions,
|
||||
} from '../../panes/explain-view-utils.js';
|
||||
|
||||
// Test utilities for creating fake objects for testing
|
||||
function createFakeCompilerInfo(overrides: Partial<CompilerInfo> = {}): CompilerInfo {
|
||||
return {
|
||||
id: 'gcc',
|
||||
exe: '/usr/bin/gcc',
|
||||
name: 'GCC 12.2.0',
|
||||
version: '12.2.0',
|
||||
fullVersion: 'gcc (GCC) 12.2.0',
|
||||
baseName: 'gcc',
|
||||
alias: ['gcc'],
|
||||
options: '-O2',
|
||||
versionFlag: ['--version'],
|
||||
lang: 'c++',
|
||||
group: 'cpp',
|
||||
groupName: 'C++',
|
||||
compilerType: 'gcc',
|
||||
semver: '12.2.0',
|
||||
libsArr: [],
|
||||
unwantedLibsArr: [],
|
||||
tools: {},
|
||||
supportedLibraries: {},
|
||||
includeFlag: '-I',
|
||||
notification: '',
|
||||
instructionSet: 'amd64',
|
||||
supportsAsmDocs: false,
|
||||
supportsLibraryCodeFilter: false,
|
||||
supportsOptOutput: false,
|
||||
supportedOpts: [],
|
||||
nvccProps: undefined,
|
||||
...overrides,
|
||||
} as CompilerInfo;
|
||||
}
|
||||
|
||||
function createFakeCompilationResult(overrides: Partial<CompilationResult> = {}): CompilationResult {
|
||||
return {
|
||||
code: 0,
|
||||
timedOut: false,
|
||||
okToCache: true,
|
||||
source: 'int main() { return 0; }',
|
||||
compilationOptions: ['-O2', '-g'],
|
||||
instructionSet: 'amd64',
|
||||
asm: [
|
||||
{text: 'main:', opcodes: [], address: 0x1000},
|
||||
{text: ' ret', opcodes: ['c3'], address: 0x1001},
|
||||
],
|
||||
stdout: [],
|
||||
stderr: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeAvailableOptions() {
|
||||
return {
|
||||
audience: [
|
||||
{value: 'beginner', description: 'New to programming'},
|
||||
{value: 'intermediate', description: 'Some programming experience'},
|
||||
{value: 'expert', description: 'Experienced programmer'},
|
||||
],
|
||||
explanation: [
|
||||
{value: 'assembly', description: 'Explain the assembly code'},
|
||||
{value: 'optimization', description: 'Explain compiler optimizations'},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ExplainView Utils - Pure Functions', () => {
|
||||
let testContext: ExplainContext;
|
||||
let fakeCompiler: CompilerInfo;
|
||||
let fakeResult: CompilationResult;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeCompiler = createFakeCompilerInfo();
|
||||
fakeResult = createFakeCompilationResult();
|
||||
testContext = {
|
||||
lastResult: fakeResult,
|
||||
compiler: fakeCompiler,
|
||||
selectedAudience: 'beginner',
|
||||
selectedExplanation: 'assembly',
|
||||
explainApiEndpoint: 'https://api.example.com/explain',
|
||||
consentGiven: true,
|
||||
availableOptions: createFakeAvailableOptions(),
|
||||
};
|
||||
});
|
||||
|
||||
// Helper function to test validation failures
|
||||
function expectValidationFailure(expectedErrorCode: ValidationErrorCode, expectedMessage: string) {
|
||||
const result = validateExplainPreconditions(testContext);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.errorCode).toBe(expectedErrorCode);
|
||||
expect(result.message).toBe(expectedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
describe('validateExplainPreconditions()', () => {
|
||||
it('should pass with valid context', () => {
|
||||
const result = validateExplainPreconditions(testContext);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error when lastResult is missing', () => {
|
||||
testContext.lastResult = null;
|
||||
expectValidationFailure(
|
||||
ValidationErrorCode.MISSING_REQUIRED_DATA,
|
||||
'Missing required data: compilation result, consent, or compiler info',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when consent is not given', () => {
|
||||
testContext.consentGiven = false;
|
||||
expectValidationFailure(
|
||||
ValidationErrorCode.MISSING_REQUIRED_DATA,
|
||||
'Missing required data: compilation result, consent, or compiler info',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when compiler is missing', () => {
|
||||
testContext.compiler = null;
|
||||
expectValidationFailure(
|
||||
ValidationErrorCode.MISSING_REQUIRED_DATA,
|
||||
'Missing required data: compilation result, consent, or compiler info',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when options are not available', () => {
|
||||
testContext.availableOptions = null;
|
||||
expectValidationFailure(ValidationErrorCode.OPTIONS_NOT_AVAILABLE, 'Explain options not available');
|
||||
});
|
||||
|
||||
it('should return error when API endpoint is not configured', () => {
|
||||
testContext.explainApiEndpoint = '';
|
||||
expectValidationFailure(
|
||||
ValidationErrorCode.API_ENDPOINT_NOT_CONFIGURED,
|
||||
'Claude Explain API endpoint not configured',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when no-ai directive is found', () => {
|
||||
testContext.lastResult!.source = 'int main() { /* no-ai */ return 0; }';
|
||||
expectValidationFailure(ValidationErrorCode.NO_AI_DIRECTIVE_FOUND, 'no-ai directive found in source code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExplainRequest()', () => {
|
||||
it('should build complete payload with all fields', () => {
|
||||
const request = buildExplainRequest(testContext, false);
|
||||
|
||||
expect(request).toEqual({
|
||||
language: 'c++',
|
||||
compiler: 'GCC 12.2.0',
|
||||
code: 'int main() { return 0; }',
|
||||
compilationOptions: ['-O2', '-g'],
|
||||
instructionSet: 'amd64',
|
||||
asm: [
|
||||
{text: 'main:', opcodes: [], address: 0x1000},
|
||||
{text: ' ret', opcodes: ['c3'], address: 0x1001},
|
||||
],
|
||||
audience: 'beginner',
|
||||
explanation: 'assembly',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing optional fields with defaults', () => {
|
||||
testContext.lastResult = createFakeCompilationResult({
|
||||
source: undefined,
|
||||
compilationOptions: undefined,
|
||||
instructionSet: undefined,
|
||||
asm: undefined,
|
||||
});
|
||||
|
||||
const request = buildExplainRequest(testContext, false);
|
||||
|
||||
expect(request).toEqual({
|
||||
language: 'c++',
|
||||
compiler: 'GCC 12.2.0',
|
||||
code: '',
|
||||
compilationOptions: [],
|
||||
instructionSet: 'amd64',
|
||||
asm: [],
|
||||
audience: 'beginner',
|
||||
explanation: 'assembly',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include bypassCache flag when true', () => {
|
||||
const request = buildExplainRequest(testContext, true);
|
||||
expect(request.bypassCache).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle non-array asm field', () => {
|
||||
testContext.lastResult!.asm = 'some string content';
|
||||
const request = buildExplainRequest(testContext, false);
|
||||
expect(request.asm).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw when compiler is missing', () => {
|
||||
testContext.compiler = null;
|
||||
expect(() => buildExplainRequest(testContext, false)).toThrow('Missing compiler or compilation result');
|
||||
});
|
||||
|
||||
it('should throw when lastResult is missing', () => {
|
||||
testContext.lastResult = null;
|
||||
expect(() => buildExplainRequest(testContext, false)).toThrow('Missing compiler or compilation result');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForNoAiDirective()', () => {
|
||||
it('should return false for normal code', () => {
|
||||
const result = checkForNoAiDirective('int main() { return 0; }');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect case-insensitive no-ai directive', () => {
|
||||
expect(checkForNoAiDirective('// NO-AI directive')).toBe(true);
|
||||
expect(checkForNoAiDirective('// no-ai directive')).toBe(true);
|
||||
expect(checkForNoAiDirective('// No-Ai directive')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect no-ai in various contexts', () => {
|
||||
expect(checkForNoAiDirective('/* no-ai explanation not wanted */')).toBe(true);
|
||||
expect(checkForNoAiDirective('int main() { /* no-ai */ return 0; }')).toBe(true);
|
||||
expect(checkForNoAiDirective('# no-ai Python comment')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(checkForNoAiDirective('')).toBe(false);
|
||||
expect(checkForNoAiDirective(' ')).toBe(false);
|
||||
expect(checkForNoAiDirective('no ai (without hyphen)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCacheKey()', () => {
|
||||
let testPayload: ExplainRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
testPayload = {
|
||||
language: 'c++',
|
||||
compiler: 'GCC 12.2.0',
|
||||
code: 'int main() { return 0; }',
|
||||
compilationOptions: ['-O2'],
|
||||
instructionSet: 'amd64',
|
||||
asm: [{text: 'main:', opcodes: [], address: 0x1000}],
|
||||
audience: 'beginner',
|
||||
explanation: 'assembly',
|
||||
};
|
||||
});
|
||||
|
||||
it('should generate consistent keys for same input', () => {
|
||||
const key1 = generateCacheKey(testPayload);
|
||||
const key2 = generateCacheKey(testPayload);
|
||||
expect(key1).toBe(key2);
|
||||
});
|
||||
|
||||
it('should generate different keys for different inputs', () => {
|
||||
const key1 = generateCacheKey(testPayload);
|
||||
|
||||
const modifiedPayload = {...testPayload, audience: 'expert'};
|
||||
const key2 = generateCacheKey(modifiedPayload);
|
||||
|
||||
expect(key1).not.toBe(key2);
|
||||
});
|
||||
|
||||
it('should include all relevant fields in cache key', () => {
|
||||
const originalKey = generateCacheKey(testPayload);
|
||||
|
||||
// Test that changing each field changes the key
|
||||
const fieldsToTest = ['language', 'compiler', 'code', 'instructionSet', 'audience', 'explanation'] as const;
|
||||
|
||||
fieldsToTest.forEach(field => {
|
||||
const modifiedPayload = {...testPayload};
|
||||
(modifiedPayload as any)[field] = `modified_${field}`;
|
||||
const modifiedKey = generateCacheKey(modifiedPayload);
|
||||
expect(modifiedKey).not.toBe(originalKey);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty compilation options', () => {
|
||||
const payloadWithEmptyOptions = {...testPayload, compilationOptions: []};
|
||||
expect(() => generateCacheKey(payloadWithEmptyOptions)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMarkdown()', () => {
|
||||
it('should convert basic markdown to HTML', () => {
|
||||
const markdown = '# Hello\n\nThis is **bold** text.';
|
||||
const html = formatMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<h1>Hello</h1>');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
});
|
||||
|
||||
it('should handle GitHub flavored markdown', () => {
|
||||
const markdown = '```cpp\nint main() {}\n```';
|
||||
const html = formatMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<code class="language-cpp">');
|
||||
expect(html).toContain('int main() {}');
|
||||
});
|
||||
|
||||
it('should convert line breaks to <br> tags', () => {
|
||||
const markdown = 'Line 1\nLine 2';
|
||||
const html = formatMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<br>');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(formatMarkdown('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatStatsText()', () => {
|
||||
let fakeResponse: ClaudeExplainResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeResponse = {
|
||||
status: 'success',
|
||||
explanation: 'Test explanation',
|
||||
cached: false,
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
},
|
||||
model: 'claude-3-sonnet',
|
||||
cost: {
|
||||
inputCost: 0.001,
|
||||
outputCost: 0.002,
|
||||
totalCost: 0.003,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should format complete stats with client cache hit', () => {
|
||||
const stats = formatStatsText(fakeResponse, true, false);
|
||||
|
||||
expect(stats).toEqual(['Cached (client)', 'Model: claude-3-sonnet', 'Tokens: 150', 'Cost: $0.003000']);
|
||||
});
|
||||
|
||||
it('should format complete stats with server cache hit', () => {
|
||||
const stats = formatStatsText(fakeResponse, false, true);
|
||||
|
||||
expect(stats).toEqual(['Cached (server)', 'Model: claude-3-sonnet', 'Tokens: 150', 'Cost: $0.003000']);
|
||||
});
|
||||
|
||||
it('should format complete stats with fresh response', () => {
|
||||
const stats = formatStatsText(fakeResponse, false, false);
|
||||
|
||||
expect(stats).toEqual(['Fresh', 'Model: claude-3-sonnet', 'Tokens: 150', 'Cost: $0.003000']);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields', () => {
|
||||
const minimalResponse: ClaudeExplainResponse = {
|
||||
status: 'success',
|
||||
explanation: 'Test explanation',
|
||||
cached: false,
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
},
|
||||
};
|
||||
|
||||
const stats = formatStatsText(minimalResponse, false, false);
|
||||
|
||||
expect(stats).toEqual(['Fresh', 'Tokens: 150']);
|
||||
});
|
||||
|
||||
it('should return empty array when usage is missing', () => {
|
||||
const noUsageResponse: ClaudeExplainResponse = {
|
||||
status: 'success',
|
||||
explanation: 'Test explanation',
|
||||
cached: false,
|
||||
model: 'claude-3-sonnet',
|
||||
};
|
||||
const stats = formatStatsText(noUsageResponse, false, false);
|
||||
|
||||
expect(stats).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle zero cost correctly', () => {
|
||||
const zeroCostResponse: ClaudeExplainResponse = {
|
||||
status: 'success',
|
||||
explanation: 'Test explanation',
|
||||
cached: false,
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
},
|
||||
cost: {
|
||||
inputCost: 0,
|
||||
outputCost: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const stats = formatStatsText(zeroCostResponse, false, false);
|
||||
|
||||
expect(stats).toEqual(['Fresh', 'Tokens: 150', 'Cost: $0.000000']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPopoverContent()', () => {
|
||||
it('should create HTML content for options list', () => {
|
||||
const options = [
|
||||
{value: 'beginner', description: 'New to programming'},
|
||||
{value: 'expert', description: 'Experienced programmer'},
|
||||
];
|
||||
|
||||
const html = createPopoverContent(options);
|
||||
|
||||
expect(html).toContain("<div class='mb-2'><strong>Beginner:</strong> New to programming</div>");
|
||||
expect(html).toContain("<div class='mb-2'><strong>Expert:</strong> Experienced programmer</div>");
|
||||
});
|
||||
|
||||
it('should handle empty options list', () => {
|
||||
const html = createPopoverContent([]);
|
||||
expect(html).toBe('');
|
||||
});
|
||||
|
||||
it('should capitalize first letter of option values', () => {
|
||||
const options = [{value: 'assembly', description: 'Explain assembly code'}];
|
||||
const html = createPopoverContent(options);
|
||||
|
||||
expect(html).toContain('<strong>Assembly:</strong>');
|
||||
});
|
||||
|
||||
it('should handle special characters in descriptions', () => {
|
||||
const options = [{value: 'test', description: 'Description with "quotes" & symbols'}];
|
||||
const html = createPopoverContent(options);
|
||||
|
||||
expect(html).toContain('Description with "quotes" & symbols');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorMessage()', () => {
|
||||
it('should format Error object with message', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
const formatted = formatErrorMessage(error);
|
||||
|
||||
expect(formatted).toBe('Error: Something went wrong');
|
||||
});
|
||||
|
||||
it('should format string error', () => {
|
||||
const error = 'Network timeout';
|
||||
const formatted = formatErrorMessage(error);
|
||||
|
||||
expect(formatted).toBe('Error: Network timeout');
|
||||
});
|
||||
|
||||
it('should format number error', () => {
|
||||
const error = 404;
|
||||
const formatted = formatErrorMessage(error);
|
||||
|
||||
expect(formatted).toBe('Error: 404');
|
||||
});
|
||||
|
||||
it('should format null/undefined errors', () => {
|
||||
expect(formatErrorMessage(null)).toBe('Error: null');
|
||||
expect(formatErrorMessage(undefined)).toBe('Error: undefined');
|
||||
});
|
||||
|
||||
it('should format object error with message property', () => {
|
||||
const error = {code: 500, message: 'Internal server error'};
|
||||
const formatted = formatErrorMessage(error);
|
||||
|
||||
expect(formatted).toBe('Error: Internal server error');
|
||||
});
|
||||
|
||||
it('should format object error with error property', () => {
|
||||
const error = {status: 'failed', error: 'Network timeout'};
|
||||
const formatted = formatErrorMessage(error);
|
||||
|
||||
expect(formatted).toBe('Error: Network timeout');
|
||||
});
|
||||
|
||||
it('should format generic object error as JSON', () => {
|
||||
const error = {code: 500, details: 'Something went wrong'};
|
||||
const formatted = formatErrorMessage(error);
|
||||
|
||||
expect(formatted).toBe('Error: {"code":500,"details":"Something went wrong"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {addDigitSeparator, escapeHTML, splitArguments} from '../shared/common-utils.js';
|
||||
import {addDigitSeparator, capitaliseFirst, escapeHTML, splitArguments} from '../shared/common-utils.js';
|
||||
|
||||
describe('HTML Escape Test Cases', () => {
|
||||
it('should prevent basic injection', () => {
|
||||
@@ -167,3 +167,29 @@ describe('argument splitting', () => {
|
||||
expect(splitArguments('"hello \\"world\\" \\\\"')).toEqual(['hello "world" \\']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capitalise first', () => {
|
||||
it('should capitalise normal strings', () => {
|
||||
expect(capitaliseFirst('hello')).toEqual('Hello');
|
||||
expect(capitaliseFirst('world')).toEqual('World');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(capitaliseFirst('')).toEqual('');
|
||||
});
|
||||
|
||||
it('should handle single characters', () => {
|
||||
expect(capitaliseFirst('a')).toEqual('A');
|
||||
expect(capitaliseFirst('z')).toEqual('Z');
|
||||
});
|
||||
|
||||
it('should handle already capitalised strings', () => {
|
||||
expect(capitaliseFirst('Hello')).toEqual('Hello');
|
||||
expect(capitaliseFirst('WORLD')).toEqual('WORLD');
|
||||
});
|
||||
|
||||
it('should handle non-alphabetic first characters', () => {
|
||||
expect(capitaliseFirst('123abc')).toEqual('123abc');
|
||||
expect(capitaliseFirst('!hello')).toEqual('!hello');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ mixin newPaneButton(classId, text, title, icon)
|
||||
+newPaneButton("view-gnatdebugtree", "GNAT Debug Tree", "Show GNAT debug tree", "fas fa-tree")
|
||||
+newPaneButton("view-gnatdebug", "GNAT Debug Expanded Code", "Show GNAT debug expanded code", "fas fa-tree")
|
||||
+newPaneButton("view-cfg", "Control Flow Graph", "Show assembly control flow graphs", "fas fa-exchange-alt")
|
||||
+newPaneButton("view-explain", "Claude Explain", "Get AI-powered explanation of your code", "fas fa-robot")
|
||||
.btn-group.btn-group-sm(role="group")
|
||||
button.btn.btn-sm.btn-light.dropdown-toggle.add-tool(type="button" title="Add tool" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="Add tooling to this editor and compiler")
|
||||
span.fas.fa-screwdriver
|
||||
|
||||
53
views/templates/panes/explain.pug
Normal file
53
views/templates/panes/explain.pug
Normal file
@@ -0,0 +1,53 @@
|
||||
#explain
|
||||
.explain-container.d-flex.flex-column.h-100
|
||||
.top-bar.btn-toolbar.options-toolbar.bg-light.flex-shrink-0(role="toolbar")
|
||||
.btn-group(role="group" aria-label="Explain options")
|
||||
include ../../font-size
|
||||
.ai-disclaimer.text-muted.badge.bg-warning.mx-2(
|
||||
title="LLM-generated explanations may contain errors or inaccuracies, and can state things with more \
|
||||
confidence than they deserve. \
|
||||
Please check before acting on information derived from this view."
|
||||
)
|
||||
i.fas.fa-triangle-exclamation.me-1
|
||||
| LLMs can be inaccurate
|
||||
.input-group
|
||||
select.explain-audience.form-select.form-select-sm(style="width: auto;" aria-label="Select audience level")
|
||||
option(value="loading") Loading...
|
||||
button.btn.btn-sm.btn-outline-secondary.explain-audience-info(type="button" title="Audience level" tabindex="0")
|
||||
i.fas.fa-info-circle
|
||||
.input-group
|
||||
select.explain-type.form-select.form-select-sm(style="width: auto;" aria-label="Select explanation type")
|
||||
option(value="loading") Loading...
|
||||
button.btn.btn-sm.btn-outline-secondary.explain-type-info(type="button" title="Explanation focus" tabindex="0")
|
||||
i.fas.fa-info-circle
|
||||
.btn-group(role="group" aria-label="Status").ms-auto
|
||||
.status.my-auto
|
||||
i.status-icon.fas.d-none
|
||||
.text-muted.badge.bg-info.d-flex.align-items-center.user-select-none(title="Claude Explain is a beta feature and is subject to change or removal at any time.")
|
||||
i.fas.fa-info-circle.me-1
|
||||
| Beta
|
||||
.explain-consent.explain-box.d-none.flex-shrink-0
|
||||
h4 Consent Request
|
||||
p
|
||||
| Claude Explain will send your #[b source code] and #[b compilation output] to
|
||||
| #[a(href="https://www.anthropic.com/" target="_blank" rel="noopener noreferrer") Anthropic]
|
||||
| (a third party company), and will use a large language model (LLM, a form of AI) to attempt to explain your
|
||||
| code and the assembly output it produces.
|
||||
p LLMs can be useful but can make mistakes and can sound confident even when they're wrong.
|
||||
p
|
||||
| The data sent is #[b not] collected or used by Anthropic to train their model, and remains private to Compiler Explorer,
|
||||
| and is covered by our #[a(href="/#privacy" target="_blank" rel="noopener noreferrer") Privacy Policy].
|
||||
p Continue?
|
||||
button.btn.btn-primary.mt-2.consent-btn Yes, explain this code
|
||||
.explain-no-ai.explain-box.d-none.flex-shrink-0
|
||||
h4 AI Explanation Not Available
|
||||
p This code contains a "#[b no-ai]" directive.
|
||||
p
|
||||
| As a courtesy to people who do not wish to have their code processed by forms of AI (including LLMs), Compiler Explorer looks for
|
||||
| the string #[code no-ai] in the source (or libraries included by the source).
|
||||
p If found, we will not process with AI.
|
||||
.explain-content.markdown-content.content.flex-grow-1.overflow-auto
|
||||
.bottom-bar.bg-light.d-none.explain-bottom-bar.flex-shrink-0.px-3.d-flex.align-items-center
|
||||
button.btn.btn-sm.btn-link.explain-reload(title="Regenerate explanation")
|
||||
i.fas.fa-sync-alt
|
||||
span.explain-stats.ms-auto
|
||||
@@ -37,6 +37,8 @@ mixin monacopane(id)
|
||||
include panes/conformance
|
||||
|
||||
include panes/tree
|
||||
|
||||
include panes/explain
|
||||
|
||||
include widgets/compiler-selector
|
||||
|
||||
|
||||
Reference in New Issue
Block a user