diff --git a/sippy-ng/package-lock.json b/sippy-ng/package-lock.json index 86b6e6dd2..affa2de67 100644 --- a/sippy-ng/package-lock.json +++ b/sippy-ng/package-lock.json @@ -23,6 +23,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^11.2.7", "@testing-library/user-event": "^12.8.3", + "@types/js-yaml": "^4.0.9", "chart.js": "^3.5.1", "chartjs-plugin-annotation": "^1.0.2", "chartjs-plugin-trendline": "^0.1.1", @@ -33,6 +34,8 @@ "date-fns-tz": "^1.1.6", "eventemitter3": "^5.0.1", "idb-keyval": "^6.2.2", + "js-yaml": "^4.1.0", + "nunjucks": "^3.2.4", "plotly.js": "^3.1.1", "prop-types": "^15.8.1", "query-string": "^4.3.4", @@ -45,6 +48,7 @@ "react-markdown": "^8.0.7", "react-plotly.js": "^2.6.0", "react-router-dom": "^6.26.0", + "react-syntax-highlighter": "^16.1.0", "remark-gfm": "^3.0.1", "timelines-chart": "^2.12.1", "universal-cookie": "^7.2.1", @@ -1937,13 +1941,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -1967,11 +1968,6 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" - }, "node_modules/@babel/template": { "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", @@ -2639,13 +2635,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -2662,19 +2651,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -2815,6 +2791,16 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -2837,6 +2823,20 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -6241,6 +6241,12 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6315,6 +6321,12 @@ "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", "dev": true }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.10", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.10.tgz", @@ -7005,6 +7017,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -7239,7 +7257,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, + "devOptional": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -7255,13 +7273,10 @@ "dev": true }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.2", @@ -7522,8 +7537,7 @@ "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, "node_modules/ast-types-flow": { "version": "0.0.8", @@ -8038,7 +8052,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -8460,6 +8474,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chart.js": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz", @@ -8526,7 +8560,7 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -11589,13 +11623,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11710,19 +11737,6 @@ "node": ">=8" } }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12154,6 +12168,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -12641,6 +12668,14 @@ "node": ">= 6" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -13006,7 +13041,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -13502,6 +13537,28 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/hast-util-whitespace": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", @@ -13512,6 +13569,42 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/hastscript/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -13521,6 +13614,21 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -14017,6 +14125,30 @@ "node": ">=8" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -14080,7 +14212,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, + "devOptional": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -14197,6 +14329,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -14216,7 +14358,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -14299,7 +14441,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -14307,6 +14449,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-iexplorer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", @@ -18266,13 +18418,12 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -18846,6 +18997,20 @@ "tslib": "^2.0.3" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -20473,7 +20638,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -20541,6 +20706,40 @@ "node": ">=0.10.0" } }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/nunjucks/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/nwsapi": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.1.tgz", @@ -20892,6 +21091,25 @@ "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", "license": "MIT" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -22784,6 +23002,16 @@ "node": ">=4" } }, + "node_modules/prettier-eslint/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/prettier-eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -22970,6 +23198,20 @@ "node": ">= 4" } }, + "node_modules/prettier-eslint/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/prettier-eslint/node_modules/pretty-format": { "version": "23.6.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", @@ -23077,6 +23319,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/probe-image-size": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", @@ -24441,6 +24692,26 @@ "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", + "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/react-test-renderer": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz", @@ -24498,7 +24769,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "devOptional": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -24553,6 +24824,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -26035,7 +26331,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/stable": { "version": "0.1.8", @@ -26628,6 +26925,16 @@ "node": ">=4.0.0" } }, + "node_modules/svgo/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/svgo/node_modules/css-select": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", @@ -26687,6 +26994,20 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/svgo/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/svgo/node_modules/nth-check": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", diff --git a/sippy-ng/package.json b/sippy-ng/package.json index 32adeffc2..7261b89ff 100644 --- a/sippy-ng/package.json +++ b/sippy-ng/package.json @@ -18,6 +18,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^11.2.7", "@testing-library/user-event": "^12.8.3", + "@types/js-yaml": "^4.0.9", "chart.js": "^3.5.1", "chartjs-plugin-annotation": "^1.0.2", "chartjs-plugin-trendline": "^0.1.1", @@ -28,6 +29,8 @@ "date-fns-tz": "^1.1.6", "eventemitter3": "^5.0.1", "idb-keyval": "^6.2.2", + "js-yaml": "^4.1.0", + "nunjucks": "^3.2.4", "plotly.js": "^3.1.1", "prop-types": "^15.8.1", "query-string": "^4.3.4", @@ -40,6 +43,7 @@ "react-markdown": "^8.0.7", "react-plotly.js": "^2.6.0", "react-router-dom": "^6.26.0", + "react-syntax-highlighter": "^16.1.0", "remark-gfm": "^3.0.1", "timelines-chart": "^2.12.1", "universal-cookie": "^7.2.1", diff --git a/sippy-ng/src/chat/ChatHeader.js b/sippy-ng/src/chat/ChatHeader.js index 201a39ee1..07fac6d9b 100644 --- a/sippy-ng/src/chat/ChatHeader.js +++ b/sippy-ng/src/chat/ChatHeader.js @@ -5,6 +5,7 @@ import { Typography, } from '@mui/material' import { + Code as CodeIcon, ExpandMore as ExpandMoreIcon, Help as HelpIcon, Fullscreen as MaximizeIcon, @@ -17,13 +18,15 @@ import { makeStyles } from '@mui/styles' import { useConnectionState, usePageContextForChat, + usePrompts, useSessionState, useSettings, useShareActions, useShareState, } from './store/useChatStore' +import PromptManagerModal from './PromptManagerModal' import PropTypes from 'prop-types' -import React from 'react' +import React, { useState } from 'react' import SessionManager from './SessionDropdown' const useStyles = makeStyles((theme) => ({ @@ -103,10 +106,13 @@ export default function ChatHeader({ const { shareConversation } = useShareActions() const { setSettingsOpen } = useSettings() const { pageContext } = usePageContextForChat() + const { localPrompts } = usePrompts() const messages = activeSession?.messages || [] const hasMessages = messages.length > 0 + const [promptManagerOpen, setPromptManagerOpen] = useState(false) + const handleHelp = () => { window.open( 'https://source.redhat.com/departments/products_and_global_engineering/openshift_development/openshift_wiki/sippy_chat_user_guide', @@ -155,6 +161,20 @@ export default function ChatHeader({ + 0 ? ` (${localPrompts.length})` : '' + }`} + > + setPromptManagerOpen(true)} + data-tour="prompt-manager-button" + > + + + + @@ -187,6 +207,11 @@ export default function ChatHeader({ )} + + setPromptManagerOpen(false)} + /> ) } diff --git a/sippy-ng/src/chat/ChatInput.js b/sippy-ng/src/chat/ChatInput.js index 447edf625..3a2ad99c1 100644 --- a/sippy-ng/src/chat/ChatInput.js +++ b/sippy-ng/src/chat/ChatInput.js @@ -1,12 +1,5 @@ import { - Chip, - CircularProgress, - IconButton, - Paper, - TextField, - Tooltip, -} from '@mui/material' -import { + AddCircleOutline as AddCircleOutlineIcon, Code as CodeIcon, Masks as MasksIcon, PlayArrow as PlayArrowIcon, @@ -14,7 +7,16 @@ import { Send as SendIcon, Stop as StopIcon, } from '@mui/icons-material' +import { + Chip, + CircularProgress, + IconButton, + Paper, + TextField, + Tooltip, +} from '@mui/material' import { CONNECTION_STATES } from './store/webSocketSlice' +import { extractYAMLFromText } from './promptSchema' import { humanize, validateMessage } from './chatUtils' import { makeStyles } from '@mui/styles' import { @@ -24,6 +26,8 @@ import { useSettings, useWebSocketActions, } from './store/useChatStore' +import CreatePromptDialog from './CreatePromptDialog' +import PromptEditor from './PromptEditor' import PropTypes from 'prop-types' import React, { useEffect, useRef, useState } from 'react' import SlashCommandModal from './SlashCommandModal' @@ -121,6 +125,11 @@ export default function ChatInput({ const [commandMenuAnchor, setCommandMenuAnchor] = useState(null) const slashNavigationRef = useRef(null) + // Custom prompt creation state + const [createPromptDialogOpen, setCreatePromptDialogOpen] = useState(false) + const [promptEditorOpen, setPromptEditorOpen] = useState(false) + const [promptEditorInitialYAML, setPromptEditorInitialYAML] = useState(null) + const { settings } = useSettings() const { personas } = usePersonas() const { prompts, renderPrompt } = usePrompts() @@ -189,6 +198,34 @@ export default function ChatInput({ setCommandMenuAnchor(null) } + const handleCreatePromptClick = () => { + setCreatePromptDialogOpen(true) + } + + const handleYAMLGenerated = (aiResponse) => { + console.log('ChatInput: Received AI response, length:', aiResponse.length) + + // Extract YAML from the AI response + const yamlBlocks = extractYAMLFromText(aiResponse) + const yamlContent = yamlBlocks.length > 0 ? yamlBlocks[0] : aiResponse + + console.log('ChatInput: Extracted YAML length:', yamlContent.length) + console.log('ChatInput: First 500 chars:', yamlContent.substring(0, 500)) + console.log( + 'ChatInput: Last 500 chars:', + yamlContent.substring(Math.max(0, yamlContent.length - 500)) + ) + + // Open the prompt editor with the generated YAML + setPromptEditorInitialYAML(yamlContent) + setPromptEditorOpen(true) + } + + const handlePromptEditorClose = () => { + setPromptEditorOpen(false) + setPromptEditorInitialYAML(null) + } + const handleSendMessage = () => { const validation = validateMessage(message) @@ -307,8 +344,8 @@ export default function ChatInput({ const getCharacterCountClass = () => { const length = message.length - if (length > 9000) return 'error' - if (length > 8000) return 'warning' + if (length > 90000) return 'error' + if (length > 80000) return 'warning' return '' } @@ -475,6 +512,20 @@ export default function ChatInput({ + {/* Create custom prompt button */} + + + + + + + + + + {/* Create prompt dialog */} + setCreatePromptDialogOpen(false)} + onYAMLGenerated={handleYAMLGenerated} + /> + + {/* Prompt editor */} + ) } diff --git a/sippy-ng/src/chat/ChatSettings.js b/sippy-ng/src/chat/ChatSettings.js index c92b86c8c..c1d0fd262 100644 --- a/sippy-ng/src/chat/ChatSettings.js +++ b/sippy-ng/src/chat/ChatSettings.js @@ -21,6 +21,7 @@ import { import { VerticalAlignBottom as AutoScrollIcon, Close as CloseIcon, + Code as CodeIcon, Delete as DeleteIcon, Info as InfoIcon, Masks as MasksIcon, @@ -37,10 +38,12 @@ import { makeStyles } from '@mui/styles' import { useConnectionState, usePersonas, + usePrompts, useSessionActions, useSessionState, useSettings, } from './store/useChatStore' +import PromptManagerModal from './PromptManagerModal' import PropTypes from 'prop-types' import React, { useCallback, useEffect, useState } from 'react' @@ -109,6 +112,7 @@ export default function ChatSettings({ onClearMessages, onReconnect }) { const { connectionState } = useConnectionState() const { personas, personasLoading, personasError, loadPersonas } = usePersonas() + const { localPrompts } = usePrompts() const { sessions, activeSessionId } = useSessionState() const { clearAllSessions, clearOldSessions } = useSessionActions() @@ -122,6 +126,9 @@ export default function ChatSettings({ onClearMessages, onReconnect }) { }) const [storageLoading, setStorageLoading] = useState(false) + // Prompt manager + const [promptManagerOpen, setPromptManagerOpen] = useState(false) + useEffect(() => { if (personas.length === 0 && !personasLoading) { loadPersonas() @@ -475,6 +482,57 @@ export default function ChatSettings({ onClearMessages, onReconnect }) { + {/* Custom Prompts */} +
+ + + + Custom Prompts + + + + + + + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + borderRadius: 1, + textAlign: 'center', + }} + > + + {localPrompts.length === 0 + ? 'No custom prompts yet' + : `${localPrompts.length} custom prompt${ + localPrompts.length !== 1 ? 's' : '' + }`} + + + + +
+ + + {/* Tour Management */}
@@ -500,6 +558,12 @@ export default function ChatSettings({ onClearMessages, onReconnect }) { )}
+ + {/* Prompt Manager Modal */} + setPromptManagerOpen(false)} + /> ) } diff --git a/sippy-ng/src/chat/CreatePromptDialog.js b/sippy-ng/src/chat/CreatePromptDialog.js new file mode 100644 index 000000000..d05762238 --- /dev/null +++ b/sippy-ng/src/chat/CreatePromptDialog.js @@ -0,0 +1,280 @@ +import { AutoAwesome as AutoAwesomeIcon } from '@mui/icons-material' +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + TextField, + Typography, +} from '@mui/material' +import { makeStyles } from '@mui/styles' +import { useSessionState } from './store/useChatStore' +import OneShotChatModal from './OneShotChatModal' +import PropTypes from 'prop-types' +import React, { useState } from 'react' + +const useStyles = makeStyles((theme) => ({ + dialogPaper: { + minWidth: 500, + }, + content: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + }, + description: { + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), + }, +})) + +/** + * CreatePromptDialog - Dialog for AI-assisted prompt creation + * Allows user to describe desired prompt and optionally include chat history as example + */ +export default function CreatePromptDialog({ open, onClose, onYAMLGenerated }) { + const classes = useStyles() + const { activeSession } = useSessionState() + + const [promptDescription, setPromptDescription] = useState('') + const [includeHistory, setIncludeHistory] = useState(false) + const [aiModalOpen, setAiModalOpen] = useState(false) + + // Pre-fill when there's conversation history + React.useEffect(() => { + if (open && activeSession && activeSession.messages?.length > 0) { + setIncludeHistory(true) + if (!promptDescription) { + setPromptDescription( + 'Create a reusable prompt template based on the conversation above. Identify any specific values (like release versions, job names, test names, etc.) and make them parameterized arguments.' + ) + } + } + }, [open, activeSession]) + + const handleCreate = () => { + setAiModalOpen(true) + } + + const handleClose = () => { + setPromptDescription('') + setIncludeHistory(false) + onClose() + } + + const handleAIResult = (result) => { + console.log('=== AI Generated Prompt Response ===') + console.log('Full response length:', result.length) + + // Pass the generated YAML to parent (should be raw YAML) + onYAMLGenerated(result) + handleClose() + } + + const buildAIPrompt = () => { + let prompt = `Create a Sippy prompt in YAML format based on this request: + +${promptDescription} + +` + + // Include chat history if checkbox is checked + if (includeHistory && activeSession && activeSession.messages) { + const conversationHistory = activeSession.messages + .map((msg) => { + const role = msg.role === 'user' ? 'User' : 'Assistant' + return `${role}: ${msg.content}` + }) + .join('\n\n') + + prompt += `Example Conversation: +--- +${conversationHistory} +--- + +Based on this conversation, create a reusable prompt template with appropriate arguments. +Use Jinja2/Nunjucks templating for variable substitution (e.g., {{ variable_name }}). + +` + } + + prompt += `IMPORTANT GUIDELINES FOR CREATING SIPPY PROMPTS: + +1. **Arguments are for VARIABLES only** - Use arguments to parameterize inputs like job names, releases, test names, etc. +2. **All instructions go in the prompt field** - The entire workflow, formatting requirements, and detailed instructions should be in the prompt template itself +3. **Be VERY detailed in the prompt** - Include exact steps, SQL queries, tool usage patterns, formatting requirements, and output structure +4. **Specify exact output format** - Tell the LLM exactly how to structure the response (markdown headings, tables, lists, etc.) +5. **Include examples** - Show the LLM what good output looks like + +**CRITICAL OUTPUT REQUIREMENT:** +Return ONLY the YAML content. Do NOT wrap it in markdown code blocks or add any other text. +Start your response directly with "name:" and nothing else before it. + +Here's an example of a WELL-WRITTEN prompt: + +name: test-failure-analysis +description: Analyze a test's performance, identify failure patterns, and provide recommendations +arguments: + - name: release + description: Release version (e.g., 4.18, 4.17) + required: true + type: string + autocomplete: releases + - name: test_name + description: Fully qualified test name + required: true + type: string + autocomplete: tests +prompt: | + Analyze test: **{{ test_name }}** on release **{{ release }}** + + ## 1. Overview + - Display test name as a markdown link to the Sippy analysis page + - Use format: \`[Test Name]({base_url}/sippy-ng/tests/{release}/analysis?test={url_encoded_test_name})\` + + ## 2. 7-Day Performance + Use \`prow_test_report_7d_matview\` materialized view: + - Overall pass rate: sum(current_successes) / sum(current_runs) × 100 + - Total runs, failures, flakes + - Trend: consistent passing/failing or intermittent + + ## 3. Variant Analysis + Query grouped by variants: + - Calculate failure rate per variant combination + - Report: variant combo, pass rate, failure count vs runs + - Order by worst performing first + + ## 4. Failure Modes + Query \`prow_job_run_test_outputs\` for recent failures: + - Examine up to 10 failure outputs + - Categorize: Consistent error vs diverse issues + - Include exact error messages + + ## 5. Assessment & Recommendations + **Root Cause (confidence: High/Medium/Low):** + - Product bug / Test issue / Infrastructure / Variant-specific + - Key evidence + - Next steps + + **Guidelines:** Use exact data, include links, use markdown tables, state explicitly if data unavailable + +Now create a prompt following this pattern. Make your prompt DETAILED with: +- Exact step-by-step workflow +- Specific tool calls and database queries +- Clear output format with markdown structure +- Explicit guidelines for the LLM + +**CRITICAL: GENERALIZE THE CONVERSATION** +When basing this on a conversation: +- Identify any SPECIFIC VALUES mentioned (release "4.21", job name "periodic-ci-...", test name, payload URL, etc.) +- Make those into ARGUMENTS - do NOT hardcode them in the prompt +- Example: If user asked about "release 4.21", create an argument \`release\` and use \`{{ release }}\` in the prompt +- Example: If user asked about a specific job name, create a \`job_name\` argument with autocomplete: jobs +- The prompt should work for ANY similar scenario, not just the specific one from the conversation + +**MAKE THE PROMPT COMPREHENSIVE** +- Include ALL the steps the LLM took in the conversation +- Specify exact database tables, materialized views, and query patterns +- **CRITICAL**: Tell the LLM that the database tables DO EXIST and should be used as specified +- If the LLM needs table schema information, instruct it to query the PostgreSQL database directly to retrieve column names and types +- The Sippy database has many more tables than what's documented - the LLM should trust the table names in the prompt +- Describe the output format in detail (headings, sections, tables, charts) +- Include any guidelines about when to stop, how to handle errors, parallel tool calls +- Tell the LLM exactly what to do, don't leave anything ambiguous + +**ABOUT PLOTLY CHARTS** +- If the conversation included creating a chart, describe the chart requirements in detail +- Do NOT include sample Plotly JSON in the prompt itself +- Instead, describe what the chart should look like (chart type, axes, colors, hover mode, etc.) +- The LLM executing the prompt will generate the actual Plotly JSON at runtime +` + + return prompt + } + + return ( + <> + + + Create Custom Prompt + + + + + Describe the prompt you want to create, and AI will generate a YAML + template for you to customize. + + + setPromptDescription(e.target.value)} + multiline + rows={4} + fullWidth + autoFocus + /> + + {activeSession && activeSession.messages?.length > 0 && ( + setIncludeHistory(e.target.checked)} + /> + } + label={ + + Include current chat history as an example for the LLM + + Helps AI understand patterns from this successful + conversation + + + } + /> + )} + + + + + + + + + {/* AI Generation Modal */} + setAiModalOpen(false)} + prompt={buildAIPrompt()} + onResult={handleAIResult} + title="Generating Prompt YAML" + /> + + ) +} + +CreatePromptDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onYAMLGenerated: PropTypes.func.isRequired, +} diff --git a/sippy-ng/src/chat/PromptEditor.js b/sippy-ng/src/chat/PromptEditor.js new file mode 100644 index 000000000..d2dd228ad --- /dev/null +++ b/sippy-ng/src/chat/PromptEditor.js @@ -0,0 +1,697 @@ +import { + Add as AddIcon, + AutoAwesome as AutoAwesomeIcon, + Close as CloseIcon, + Delete as DeleteIcon, + History as HistoryIcon, + Save as SaveIcon, +} from '@mui/icons-material' +import { + Alert, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControlLabel, + IconButton, + List, + ListItem, + ListItemText, + MenuItem, + Select, + Switch, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material' +import { + getDefaultPromptTemplate, + promptToYAML, + validatePromptYAML, +} from './promptSchema' +import { makeStyles } from '@mui/styles' +import { usePrompts } from './store/useChatStore' +import OneShotChatModal from './OneShotChatModal' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' +import YamlEditor from './YamlEditor' + +const useStyles = makeStyles((theme) => ({ + dialogPaper: { + minWidth: 800, + maxWidth: '90vw', + height: '80vh', + }, + dialogContent: { + display: 'flex', + flexDirection: 'column', + padding: 0, + height: '100%', + overflow: 'hidden', + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + tabs: { + borderBottom: `1px solid ${theme.palette.divider}`, + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }, + tabContent: { + flex: 1, + overflow: 'auto', + minHeight: 0, + padding: theme.spacing(2), + }, + formFields: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + }, + argumentsList: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(1), + }, + argumentItem: { + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + borderRadius: theme.shape.borderRadius, + marginBottom: theme.spacing(1), + padding: theme.spacing(1), + }, + aiRefinementSection: { + marginTop: theme.spacing(2), + padding: theme.spacing(2), + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(138, 43, 226, 0.1)' + : 'rgba(138, 43, 226, 0.05)', + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.primary.main}`, + }, + versionHistory: { + marginTop: theme.spacing(2), + }, + versionItem: { + cursor: 'pointer', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }, +})) + +/** + * PromptEditor - Dialog for creating and editing custom prompts + * Features: Dual view (YAML/Form), version history, AI refinement + */ +export default function PromptEditor({ + open, + onClose, + promptName = null, + initialYAML = null, +}) { + const classes = useStyles() + const { + saveLocalPrompt, + updateLocalPrompt, + deleteLocalPrompt, + getLocalPrompt, + serverPrompts, + } = usePrompts() + + const [viewMode, setViewMode] = useState(1) // 0 = YAML, 1 = Form + const [yamlContent, setYamlContent] = useState('') + const [validationErrors, setValidationErrors] = useState([]) + const [versions, setVersions] = useState([]) + const [aiModalOpen, setAiModalOpen] = useState(false) + const [aiRefinementPrompt, setAiRefinementPrompt] = useState('') + const [saveError, setSaveError] = useState(null) + + // Form fields (parsed from YAML) + const [formData, setFormData] = useState({ + name: '', + description: '', + hide: false, + arguments: [], + prompt: '', + }) + + // Load existing prompt or use default template + useEffect(() => { + if (open) { + if (promptName) { + // Editing existing prompt + const existingPrompt = getLocalPrompt(promptName) + if (existingPrompt) { + const { createdAt, updatedAt, source, ...cleanPrompt } = + existingPrompt + const yaml = promptToYAML(cleanPrompt) + setYamlContent(yaml) + setFormData(cleanPrompt) + setVersions([{ yaml, timestamp: new Date().toISOString() }]) + } + } else if (initialYAML) { + // Creating from AI-generated YAML + console.log( + 'PromptEditor: Received initialYAML, length:', + initialYAML.length + ) + console.log('PromptEditor: initialYAML content:', initialYAML) + setYamlContent(initialYAML) + parseYAMLToForm(initialYAML) + setVersions([ + { yaml: initialYAML, timestamp: new Date().toISOString() }, + ]) + } else { + // New prompt with default template + const defaultYAML = getDefaultPromptTemplate() + setYamlContent(defaultYAML) + parseYAMLToForm(defaultYAML) + setVersions([ + { yaml: defaultYAML, timestamp: new Date().toISOString() }, + ]) + } + } else { + // Reset on close + setYamlContent('') + setFormData({ + name: '', + description: '', + hide: false, + arguments: [], + prompt: '', + }) + setVersions([]) + setValidationErrors([]) + setSaveError(null) + setAiRefinementPrompt('') + } + }, [open, promptName, initialYAML, getLocalPrompt]) + + // Parse YAML to form data + const parseYAMLToForm = (yamlStr) => { + const validation = validatePromptYAML(yamlStr) + if (validation.valid) { + setFormData(validation.prompt) + setValidationErrors([]) + } else { + setValidationErrors(validation.errors) + } + } + + // Sync form to YAML when switching to YAML view + const syncFormToYAML = () => { + try { + const yamlStr = promptToYAML(formData) + setYamlContent(yamlStr) + setValidationErrors([]) + } catch (error) { + setValidationErrors([`Failed to convert form to YAML: ${error.message}`]) + } + } + + // Handle tab change + const handleTabChange = (event, newValue) => { + if (newValue === 1 && viewMode === 0) { + // Switching from Form to YAML + syncFormToYAML() + } else if (newValue === 0 && viewMode === 1) { + // Switching from YAML to Form + parseYAMLToForm(yamlContent) + } + setViewMode(newValue) + } + + // Handle YAML change + const handleYAMLChange = (newYAML) => { + setYamlContent(newYAML) + parseYAMLToForm(newYAML) + } + + // Handle form field changes + const handleFormFieldChange = (field, value) => { + setFormData((prev) => ({ ...prev, [field]: value })) + } + + // Handle argument changes + const addArgument = () => { + setFormData((prev) => ({ + ...prev, + arguments: [ + ...(prev.arguments || []), + { + name: '', + description: '', + required: false, + type: 'string', + }, + ], + })) + } + + const updateArgument = (index, field, value) => { + setFormData((prev) => { + const newArgs = [...(prev.arguments || [])] + newArgs[index] = { ...newArgs[index], [field]: value } + return { ...prev, arguments: newArgs } + }) + } + + const deleteArgument = (index) => { + setFormData((prev) => ({ + ...prev, + arguments: (prev.arguments || []).filter((_, i) => i !== index), + })) + } + + // Save prompt + const handleSave = () => { + // Ensure we have the latest YAML + const finalYAML = viewMode === 1 ? promptToYAML(formData) : yamlContent + + // Validate + const validation = validatePromptYAML(finalYAML) + if (!validation.valid) { + setValidationErrors(validation.errors) + return + } + + try { + if (promptName) { + // Update existing + updateLocalPrompt(promptName, validation.prompt) + } else { + // Save new + saveLocalPrompt(validation.prompt) + } + onClose() + } catch (error) { + setSaveError(error.message) + } + } + + // Delete prompt + const handleDelete = () => { + if ( + window.confirm( + `Are you sure you want to delete the prompt "${promptName}"?` + ) + ) { + try { + deleteLocalPrompt(promptName) + onClose() + } catch (error) { + setSaveError(error.message) + } + } + } + + // AI refinement + const handleAIRefinement = () => { + setAiModalOpen(true) + } + + const handleAIRefinementResult = (result) => { + // Extract YAML from the result + const yamlBlockRegex = /```(?:yaml|yml)?\s*\n([\s\S]*?)```/i + const match = result.match(yamlBlockRegex) + + if (match) { + const newYAML = match[1].trim() + // Add to version history + setVersions((prev) => [ + { yaml: newYAML, timestamp: new Date().toISOString() }, + ...prev.slice(0, 9), // Keep max 10 versions + ]) + setYamlContent(newYAML) + parseYAMLToForm(newYAML) + } else { + // If no YAML block found, try to use the whole result + setVersions((prev) => [ + { yaml: result, timestamp: new Date().toISOString() }, + ...prev.slice(0, 9), + ]) + setYamlContent(result) + parseYAMLToForm(result) + } + setAiRefinementPrompt('') + } + + // Revert to a previous version + const handleRevertToVersion = (versionYAML) => { + setYamlContent(versionYAML) + parseYAMLToForm(versionYAML) + } + + // Build AI refinement prompt + const buildAIPrompt = () => { + const currentYAML = viewMode === 1 ? promptToYAML(formData) : yamlContent + return `Adjust this Sippy prompt YAML according to the following request: + +Current YAML: +\`\`\`yaml +${currentYAML} +\`\`\` + +Requested changes: ${aiRefinementPrompt} + +Please provide the updated YAML in a code block. Maintain the same structure and format.` + } + + return ( + <> + + + + + {promptName ? `Edit Prompt: ${promptName}` : 'Create New Prompt'} + + + + + + + + + {saveError && ( + setSaveError(null)} + sx={{ mb: 2 }} + > + {saveError} + + )} + + {validationErrors.length > 0 && ( + + + Validation Errors: + +
    + {validationErrors.map((error, idx) => ( +
  • {error}
  • + ))} +
+
+ )} + + + + + + + + {viewMode === 1 && ( + 0 ? 'Invalid YAML' : null} + /> + )} + + {viewMode === 0 && ( + + {/* AI Refinement Section */} + + setAiRefinementPrompt(e.target.value)} + fullWidth + multiline + rows={2} + size="small" + /> + + + + + + + handleFormFieldChange('name', e.target.value) + } + required + helperText="Lowercase letters, numbers, and hyphens only" + fullWidth + /> + + + handleFormFieldChange('description', e.target.value) + } + required + multiline + rows={2} + fullWidth + /> + + + handleFormFieldChange('hide', e.target.checked) + } + /> + } + label="Hide from slash command list" + /> + + + + + + Arguments + + + + + {(formData.arguments || []).length === 0 ? ( + + No arguments defined. Click "Add Argument" to + create one. + + ) : ( + (formData.arguments || []).map((arg, index) => ( + + + + Argument {index + 1} + + deleteArgument(index)} + > + + + + + + + updateArgument(index, 'name', e.target.value) + } + size="small" + fullWidth + /> + + updateArgument( + index, + 'description', + e.target.value + ) + } + size="small" + fullWidth + /> + + + + updateArgument( + index, + 'required', + e.target.checked + ) + } + size="small" + /> + } + label="Required" + /> + + + updateArgument( + index, + 'autocomplete', + e.target.value + ) + } + size="small" + fullWidth + helperText="API autocomplete field name" + /> + + + )) + )} + + + + + + + handleFormFieldChange('prompt', e.target.value) + } + required + multiline + rows={32} + fullWidth + helperText="Use {{ argument_name }} for variable substitution" + /> + + )} + + {/* Version History */} + {versions.length > 1 && ( + + + + Version History + + + {versions.map((version, index) => ( + handleRevertToVersion(version.yaml)} + > + + {index === 0 && } + + ))} + + + )} + +
+ + + {promptName && ( + + )} + + + +
+ + {/* AI Refinement Modal */} + setAiModalOpen(false)} + prompt={buildAIPrompt()} + onResult={handleAIRefinementResult} + title="Refining Prompt with AI" + /> + + ) +} + +PromptEditor.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + promptName: PropTypes.string, + initialYAML: PropTypes.string, +} diff --git a/sippy-ng/src/chat/PromptManagerModal.js b/sippy-ng/src/chat/PromptManagerModal.js new file mode 100644 index 000000000..21c445194 --- /dev/null +++ b/sippy-ng/src/chat/PromptManagerModal.js @@ -0,0 +1,548 @@ +import { + Add as AddIcon, + Close as CloseIcon, + Code as CodeIcon, + Computer as ComputerIcon, + Delete as DeleteIcon, + Edit as EditIcon, + FileDownload as FileDownloadIcon, + FileUpload as FileUploadIcon, + Search as SearchIcon, +} from '@mui/icons-material' +import { + Box, + Button, + Dialog, + DialogContent, + Divider, + IconButton, + InputAdornment, + List, + ListItem, + ListItemButton, + ListItemText, + TextField, + Toolbar, + Typography, +} from '@mui/material' +import { extractYAMLFromText, promptToYAML } from './promptSchema' +import { makeStyles } from '@mui/styles' +import { usePrompts } from './store/useChatStore' +import CreatePromptDialog from './CreatePromptDialog' +import PromptEditor from './PromptEditor' +import PropTypes from 'prop-types' +import React, { useState } from 'react' + +const useStyles = makeStyles((theme) => ({ + dialog: { + '& .MuiDialog-paper': { + width: '90vw', + maxWidth: 1400, + height: '85vh', + maxHeight: 900, + }, + }, + dialogContent: { + padding: 0, + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + toolbar: { + borderBottom: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(2), + gap: theme.spacing(2), + }, + mainContent: { + display: 'flex', + flex: 1, + overflow: 'hidden', + }, + sidebar: { + width: 320, + borderRight: `1px solid ${theme.palette.divider}`, + display: 'flex', + flexDirection: 'column', + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.02)' + : 'rgba(0, 0, 0, 0.02)', + }, + sidebarHeader: { + padding: theme.spacing(2), + borderBottom: `1px solid ${theme.palette.divider}`, + }, + promptList: { + flex: 1, + overflow: 'auto', + padding: 0, + }, + promptListItem: { + borderBottom: `1px solid ${theme.palette.divider}`, + '&.Mui-selected': { + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(144, 202, 249, 0.16)' + : 'rgba(25, 118, 210, 0.08)', + }, + }, + emptyState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + padding: theme.spacing(4), + textAlign: 'center', + color: theme.palette.text.secondary, + }, + detailPanel: { + flex: 1, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }, + detailHeader: { + padding: theme.spacing(2), + borderBottom: `1px solid ${theme.palette.divider}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + detailContent: { + flex: 1, + overflow: 'auto', + padding: theme.spacing(3), + }, + promptPreview: { + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + fontFamily: 'monospace', + fontSize: '0.875rem', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + metadataRow: { + display: 'flex', + gap: theme.spacing(2), + marginBottom: theme.spacing(1), + }, +})) + +export default function PromptManagerModal({ open, onClose }) { + const classes = useStyles() + const { + localPrompts, + deleteLocalPrompt, + exportLocalPromptsAsYAML, + saveLocalPrompt, + } = usePrompts() + + const [selectedPromptName, setSelectedPromptName] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [editorOpen, setEditorOpen] = useState(false) + const [editorPromptName, setEditorPromptName] = useState(null) + const [editorInitialYAML, setEditorInitialYAML] = useState(null) + + // Filter prompts based on search + const filteredPrompts = localPrompts.filter( + (prompt) => + prompt.name.toLowerCase().includes(searchQuery.toLowerCase()) || + prompt.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const selectedPrompt = localPrompts.find((p) => p.name === selectedPromptName) + + const handlePromptSelect = (promptName) => { + setSelectedPromptName(promptName) + } + + const handleCreateNew = () => { + setCreateDialogOpen(true) + } + + const handleEdit = () => { + if (selectedPrompt) { + setEditorPromptName(selectedPrompt.name) + setEditorInitialYAML(null) + setEditorOpen(true) + } + } + + const handleDelete = () => { + if ( + selectedPrompt && + window.confirm( + `Are you sure you want to delete "${selectedPrompt.name}"?` + ) + ) { + deleteLocalPrompt(selectedPrompt.name) + setSelectedPromptName(null) + } + } + + const handleYAMLGenerated = (aiResponse) => { + const yamlBlocks = extractYAMLFromText(aiResponse) + const yamlContent = yamlBlocks.length > 0 ? yamlBlocks[0] : aiResponse + setEditorInitialYAML(yamlContent) + setEditorPromptName(null) + setEditorOpen(true) + } + + const handleEditorClose = () => { + setEditorOpen(false) + setEditorPromptName(null) + setEditorInitialYAML(null) + } + + const handleExport = () => { + if (!selectedPrompt) { + return + } + + // Export selected prompt with its name as filename + const { createdAt, updatedAt, source, ...cleanPrompt } = selectedPrompt + const yamlContent = promptToYAML(cleanPrompt) + const dataStr = + 'data:text/yaml;charset=utf-8,' + encodeURIComponent(yamlContent) + const a = document.createElement('a') + a.href = dataStr + a.download = `${selectedPrompt.name}.yaml` + a.click() + } + + const handleImport = () => { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.yaml,.yml' + input.onchange = (e) => { + const file = e.target.files[0] + if (file) { + const reader = new FileReader() + reader.onload = (event) => { + try { + const yamlContent = event.target.result + // Split by document separator if multiple prompts + const prompts = yamlContent.split(/\n---\n/) + prompts.forEach((promptYAML) => { + if (promptYAML.trim()) { + setEditorInitialYAML(promptYAML.trim()) + setEditorPromptName(null) + setEditorOpen(true) + } + }) + } catch (error) { + alert(`Failed to import: ${error.message}`) + } + } + reader.readAsText(file) + } + } + input.click() + } + + return ( + <> + + + {/* Toolbar */} + + + + Custom Prompt Manager + + ({localPrompts.length} prompt + {localPrompts.length !== 1 ? 's' : ''}) + + + + + + + + + + + + + + {/* Main Content */} + + {/* Sidebar */} + + + setSearchQuery(e.target.value)} + size="small" + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + {filteredPrompts.length === 0 ? ( + + + + {searchQuery + ? 'No prompts match your search' + : 'No custom prompts yet'} + + {!searchQuery && ( + + )} + + ) : ( + filteredPrompts.map((prompt) => ( + + handlePromptSelect(prompt.name)} + > + + + + + )) + )} + + + + {/* Detail Panel */} + + {!selectedPrompt ? ( + + + Select a prompt to view details + + + Choose a prompt from the list or create a new one + + + ) : ( + <> + + + + {selectedPrompt.name} + + + {selectedPrompt.description} + + + + + + + + + + + + + + {/* Metadata */} + + Metadata + + + + Created:{' '} + {selectedPrompt.createdAt + ? new Date(selectedPrompt.createdAt).toLocaleString() + : 'Unknown'} + + + + + Updated:{' '} + {selectedPrompt.updatedAt + ? new Date(selectedPrompt.updatedAt).toLocaleString() + : 'Unknown'} + + + + {/* Arguments */} + {selectedPrompt.arguments && + selectedPrompt.arguments.length > 0 && ( + <> + + + Arguments ({selectedPrompt.arguments.length}) + + {selectedPrompt.arguments.map((arg, idx) => ( + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + borderRadius: 1, + }} + > + + {arg.name} + {arg.required && ( + + * + + )} + + ({arg.type || 'string'}) + + + + {arg.description} + + + ))} + + )} + + {/* Prompt Template */} + + + Prompt Template + + + {selectedPrompt.prompt} + + + {/* YAML Preview */} + + + YAML Source + + + {promptToYAML({ + name: selectedPrompt.name, + description: selectedPrompt.description, + arguments: selectedPrompt.arguments, + prompt: selectedPrompt.prompt, + hide: selectedPrompt.hide, + })} + + + + )} + + + + + + {/* Create Dialog */} + setCreateDialogOpen(false)} + onYAMLGenerated={handleYAMLGenerated} + /> + + {/* Editor */} + + + ) +} + +PromptManagerModal.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +} diff --git a/sippy-ng/src/chat/SlashCommandModal.js b/sippy-ng/src/chat/SlashCommandModal.js index 55d7736b3..1f7df7380 100644 --- a/sippy-ng/src/chat/SlashCommandModal.js +++ b/sippy-ng/src/chat/SlashCommandModal.js @@ -5,6 +5,7 @@ import { Autocomplete, Box, Button, + Chip, CircularProgress, Dialog, DialogActions, @@ -13,7 +14,10 @@ import { TextField, Typography, } from '@mui/material' -import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material' +import { + Computer as ComputerIcon, + ExpandMore as ExpandMoreIcon, +} from '@mui/icons-material' import { makeStyles } from '@mui/styles' import { safeEncodeURIComponent } from '../helpers' import { usePrompts } from './store/useChatStore' @@ -353,7 +357,20 @@ export default function SlashCommandModal({ maxWidth="md" fullWidth > - /{prompt.name} + + + /{prompt.name} + {prompt.source === 'local' && ( + } + label="Local" + size="small" + color="secondary" + variant="outlined" + /> + )} + + {prompt.description} @@ -404,6 +421,7 @@ SlashCommandModal.propTypes = { prompt: PropTypes.shape({ name: PropTypes.string.isRequired, description: PropTypes.string, + source: PropTypes.string, arguments: PropTypes.arrayOf( PropTypes.shape({ name: PropTypes.string.isRequired, diff --git a/sippy-ng/src/chat/SlashCommandSelector.js b/sippy-ng/src/chat/SlashCommandSelector.js index e95d01b80..1469f6eb8 100644 --- a/sippy-ng/src/chat/SlashCommandSelector.js +++ b/sippy-ng/src/chat/SlashCommandSelector.js @@ -1,4 +1,5 @@ import { + Chip, ClickAwayListener, List, ListItem, @@ -6,6 +7,7 @@ import { Paper, Popper, } from '@mui/material' +import { Computer as ComputerIcon } from '@mui/icons-material' import { makeStyles } from '@mui/styles' import { usePrompts } from './store/useChatStore' import PropTypes from 'prop-types' @@ -25,6 +27,15 @@ const useStyles = makeStyles((theme) => ({ backgroundColor: theme.palette.action.hover, }, }, + listItemContent: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + width: '100%', + }, + localChip: { + marginLeft: 'auto', + }, })) export default function SlashCommandSelector({ @@ -116,10 +127,22 @@ export default function SlashCommandSelector({ onClick={() => handlePromptClick(prompt)} selected={index === selectedIndex} > - +
+ + {prompt.source === 'local' && ( + } + label="Local" + size="small" + color="secondary" + variant="outlined" + className={classes.localChip} + /> + )} +
))} diff --git a/sippy-ng/src/chat/YamlEditor.js b/sippy-ng/src/chat/YamlEditor.js new file mode 100644 index 000000000..88179ad9b --- /dev/null +++ b/sippy-ng/src/chat/YamlEditor.js @@ -0,0 +1,126 @@ +import { + atomOneDark, + atomOneLight, +} from 'react-syntax-highlighter/dist/esm/styles/hljs' +import { Box, TextField } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { Light as SyntaxHighlighter } from 'react-syntax-highlighter' +import { useTheme } from '@mui/material/styles' +import PropTypes from 'prop-types' +import React from 'react' +import yaml from 'react-syntax-highlighter/dist/esm/languages/hljs/yaml' + +// Register YAML language +SyntaxHighlighter.registerLanguage('yaml', yaml) + +const useStyles = makeStyles((theme) => ({ + editorContainer: { + display: 'flex', + flexDirection: 'column', + height: '100%', + minHeight: 400, + overflow: 'hidden', + }, + textFieldContainer: { + flex: 1, + display: 'flex', + overflow: 'hidden', + '& .MuiTextField-root': { + flex: 1, + }, + '& .MuiInputBase-root': { + height: '100%', + alignItems: 'flex-start', + fontFamily: 'monospace', + fontSize: '0.875rem', + }, + '& textarea': { + height: '100% !important', + overflow: 'auto !important', + }, + }, + previewContainer: { + flex: 1, + overflow: 'auto', + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(0, 0, 0, 0.3)' + : 'rgba(0, 0, 0, 0.02)', + }, + syntaxHighlighter: { + margin: 0, + height: '100%', + '& code': { + fontFamily: 'monospace', + fontSize: '0.875rem', + }, + }, +})) + +/** + * YamlEditor - Component for editing YAML with syntax highlighting + * Can be used in edit mode (with TextField) or preview mode (read-only with highlighting) + */ +export default function YamlEditor({ + value, + onChange, + readOnly = false, + error = null, + placeholder = 'Enter YAML here...', +}) { + const classes = useStyles() + const theme = useTheme() + + const syntaxTheme = theme.palette.mode === 'dark' ? atomOneDark : atomOneLight + + if (readOnly) { + return ( + + + {value || placeholder} + + + ) + } + + return ( + +
+ onChange(e.target.value)} + placeholder={placeholder} + error={!!error} + helperText={error} + fullWidth + variant="outlined" + InputProps={{ + style: { + fontFamily: 'Consolas, Monaco, "Courier New", monospace', + }, + }} + /> +
+
+ ) +} + +YamlEditor.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func, + readOnly: PropTypes.bool, + error: PropTypes.string, + placeholder: PropTypes.string, +} diff --git a/sippy-ng/src/chat/chatUtils.js b/sippy-ng/src/chat/chatUtils.js index 4dbdd0024..c8db9a8bd 100644 --- a/sippy-ng/src/chat/chatUtils.js +++ b/sippy-ng/src/chat/chatUtils.js @@ -125,10 +125,10 @@ export function validateMessage(content) { return { valid: false, error: 'Message cannot be empty' } } - if (content.length > 10000) { + if (content.length > 100000) { return { valid: false, - error: 'Message is too long (max 10,000 characters)', + error: 'Message is too long (max 100,000 characters)', } } diff --git a/sippy-ng/src/chat/promptSchema.js b/sippy-ng/src/chat/promptSchema.js new file mode 100644 index 000000000..844a09c55 --- /dev/null +++ b/sippy-ng/src/chat/promptSchema.js @@ -0,0 +1,230 @@ +import yaml from 'js-yaml' + +/** + * Validates a prompt YAML string and returns the parsed object or validation errors + * @param {string} yamlString - The YAML string to validate + * @returns {{valid: boolean, prompt?: object, errors?: string[]}} + */ +export function validatePromptYAML(yamlString) { + const errors = [] + + if (!yamlString || yamlString.trim() === '') { + return { valid: false, errors: ['YAML content is empty'] } + } + + let parsed + try { + parsed = yaml.load(yamlString) + } catch (error) { + return { + valid: false, + errors: [`Invalid YAML syntax: ${error.message}`], + } + } + + // Validate required fields + if (!parsed || typeof parsed !== 'object') { + errors.push('YAML must contain a valid object') + return { valid: false, errors } + } + + if (!parsed.name || typeof parsed.name !== 'string') { + errors.push('Missing or invalid "name" field (must be a non-empty string)') + } else if (!/^[a-z0-9-]+$/.test(parsed.name)) { + errors.push( + 'Invalid "name" format (must contain only lowercase letters, numbers, and hyphens)' + ) + } + + if (!parsed.description || typeof parsed.description !== 'string') { + errors.push( + 'Missing or invalid "description" field (must be a non-empty string)' + ) + } + + if (!parsed.prompt || typeof parsed.prompt !== 'string') { + errors.push( + 'Missing or invalid "prompt" field (must be a non-empty string)' + ) + } + + // Validate optional arguments field + if (parsed.arguments !== undefined) { + if (!Array.isArray(parsed.arguments)) { + errors.push('"arguments" field must be an array') + } else { + parsed.arguments.forEach((arg, index) => { + if (!arg.name || typeof arg.name !== 'string') { + errors.push( + `Argument at index ${index} is missing or has invalid "name" field` + ) + } + if (!arg.description || typeof arg.description !== 'string') { + errors.push( + `Argument "${ + arg.name || index + }" is missing or has invalid "description" field` + ) + } + if (arg.type && !['string', 'array'].includes(arg.type)) { + errors.push( + `Argument "${ + arg.name || index + }" has invalid "type" (must be "string" or "array")` + ) + } + if (arg.required !== undefined && typeof arg.required !== 'boolean') { + errors.push( + `Argument "${ + arg.name || index + }" has invalid "required" field (must be boolean)` + ) + } + if ( + arg.autocomplete !== undefined && + typeof arg.autocomplete !== 'string' + ) { + errors.push( + `Argument "${ + arg.name || index + }" has invalid "autocomplete" field (must be string)` + ) + } + }) + } + } + + // Validate optional hide field + if (parsed.hide !== undefined && typeof parsed.hide !== 'boolean') { + errors.push('"hide" field must be a boolean') + } + + if (errors.length > 0) { + return { valid: false, errors } + } + + return { valid: true, prompt: parsed } +} + +/** + * Converts a prompt object to YAML string + * @param {object} promptObject - The prompt object to convert + * @returns {string} YAML string representation + */ +export function promptToYAML(promptObject) { + return yaml.dump(promptObject, { + indent: 2, + lineWidth: -1, // No line wrapping + noRefs: true, + }) +} + +/** + * Extracts YAML code blocks from markdown/text content + * @param {string} content - Text content that may contain YAML code blocks + * @returns {string[]} Array of YAML strings found in code blocks + */ +export function extractYAMLFromText(content) { + const yamlBlocks = [] + + // Find all code block starts marked as yaml or yml + const yamlStartRegex = /```(?:yaml|yml)\s*\n/gi + let startMatch + + while ((startMatch = yamlStartRegex.exec(content)) !== null) { + const startPos = startMatch.index + startMatch[0].length + + // Find the matching closing ``` by counting nested blocks + let depth = 1 + let pos = startPos + let endPos = -1 + + while (pos < content.length && depth > 0) { + const nextBackticks = content.indexOf('```', pos) + if (nextBackticks === -1) { + break + } + + // Check if this is a new opening block + const beforeBackticks = content.substring( + Math.max(0, nextBackticks - 50), + nextBackticks + ) + if (/\n```[a-z]*\s*$/.test(beforeBackticks)) { + depth++ + } else { + depth-- + if (depth === 0) { + endPos = nextBackticks + break + } + } + + pos = nextBackticks + 3 + } + + if (endPos !== -1) { + const yamlContent = content.substring(startPos, endPos).trim() + console.log('Extracted YAML block length:', yamlContent.length) + yamlBlocks.push(yamlContent) + } + } + + console.log('Total YAML blocks found:', yamlBlocks.length) + return yamlBlocks +} + +/** + * Creates a default prompt template + * @returns {string} Default YAML template string + */ +export function getDefaultPromptTemplate() { + return `name: my-custom-prompt +description: A brief description of what this prompt does +arguments: + - name: example_arg + description: Description of this argument + required: true + type: string +prompt: | + This is your prompt template. + You can use {{ example_arg }} for variable substitution. +` +} + +/** + * Validates that a prompt name doesn't conflict with server prompts + * @param {string} name - The prompt name to check + * @param {Array} serverPrompts - Array of server prompts + * @param {string} currentName - Current name if editing (to allow keeping same name) + * @returns {{valid: boolean, error?: string}} + */ +export function validatePromptName(name, serverPrompts, currentName = null) { + if (!name || name.trim() === '') { + return { valid: false, error: 'Prompt name cannot be empty' } + } + + if (!/^[a-z0-9-]+$/.test(name)) { + return { + valid: false, + error: + 'Prompt name must contain only lowercase letters, numbers, and hyphens', + } + } + + // Allow keeping the same name when editing + if (currentName && name === currentName) { + return { valid: true } + } + + // Check for conflicts with server prompts + const conflictingServerPrompt = serverPrompts.find((p) => p.name === name) + if (conflictingServerPrompt) { + return { + valid: false, + error: `A server prompt with name "${name}" already exists. Please choose a different name.`, + } + } + + return { valid: true } +} diff --git a/sippy-ng/src/chat/store/promptsSlice.js b/sippy-ng/src/chat/store/promptsSlice.js index 482de3ce1..3f1ee11df 100644 --- a/sippy-ng/src/chat/store/promptsSlice.js +++ b/sippy-ng/src/chat/store/promptsSlice.js @@ -1,12 +1,33 @@ +import { promptToYAML, validatePromptName } from '../promptSchema' +import nunjucks from 'nunjucks' + +// Configure nunjucks for template rendering +const nunjucksEnv = new nunjucks.Environment(null, { autoescape: false }) + /** * Zustand slice for managing slash command prompts */ export const createPromptsSlice = (set, get) => ({ // State - prompts: [], + serverPrompts: [], // Prompts fetched from the server + localPrompts: [], // User-created prompts stored locally promptsLoading: false, promptsError: null, + // Function that merges server and local prompts + getPrompts: () => { + const state = get() + const server = (state.serverPrompts || []).map((p) => ({ + ...p, + source: 'server', + })) + const local = (state.localPrompts || []).map((p) => ({ + ...p, + source: 'local', + })) + return [...server, ...local].sort((a, b) => a.name.localeCompare(b.name)) + }, + // Fetch prompts from the server fetchPrompts: async () => { set({ promptsLoading: true, promptsError: null }) @@ -22,7 +43,7 @@ export const createPromptsSlice = (set, get) => ({ const data = await response.json() set({ - prompts: data.prompts || [], + serverPrompts: data.prompts || [], promptsLoading: false, }) } catch (error) { @@ -35,6 +56,42 @@ export const createPromptsSlice = (set, get) => ({ // Render a prompt with arguments renderPrompt: async (promptName, args) => { + const state = get() + const allPrompts = state.getPrompts() + + // Find the prompt + const prompt = allPrompts.find((p) => p.name === promptName) + + if (!prompt) { + throw new Error(`Prompt "${promptName}" not found`) + } + + // If it's a local prompt, render it client-side + if (prompt.source === 'local') { + try { + // Fill in default values for missing arguments + const filledArgs = { ...args } + if (prompt.arguments) { + prompt.arguments.forEach((arg) => { + if ( + filledArgs[arg.name] === undefined && + arg.default !== undefined + ) { + filledArgs[arg.name] = arg.default + } + }) + } + + // Render the template using nunjucks + const rendered = nunjucksEnv.renderString(prompt.prompt, filledArgs) + return rendered + } catch (error) { + console.error('Error rendering local prompt:', error) + throw new Error(`Failed to render local prompt: ${error.message}`) + } + } + + // For server prompts, use the server API try { const response = await fetch( (process.env.REACT_APP_CHAT_API_URL || '/api/chat') + '/prompts/render', @@ -57,8 +114,117 @@ export const createPromptsSlice = (set, get) => ({ const data = await response.json() return data.rendered } catch (error) { - console.error('Error rendering prompt:', error) + console.error('Error rendering server prompt:', error) throw error } }, + + // Save a new local prompt + saveLocalPrompt: (promptData) => { + const state = get() + + // Validate prompt name doesn't conflict with server prompts + const nameValidation = validatePromptName( + promptData.name, + state.serverPrompts + ) + if (!nameValidation.valid) { + throw new Error(nameValidation.error) + } + + // Check for duplicate local prompt names + const existingLocal = state.localPrompts.find( + (p) => p.name === promptData.name + ) + if (existingLocal) { + throw new Error( + `A local prompt with name "${promptData.name}" already exists` + ) + } + + // Add metadata + const newPrompt = { + ...promptData, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + set({ + localPrompts: [...state.localPrompts, newPrompt], + }) + + return newPrompt + }, + + // Update an existing local prompt + updateLocalPrompt: (promptName, promptData) => { + const state = get() + + const index = state.localPrompts.findIndex((p) => p.name === promptName) + if (index === -1) { + throw new Error(`Local prompt "${promptName}" not found`) + } + + // If name is changing, validate the new name + if (promptData.name && promptData.name !== promptName) { + const nameValidation = validatePromptName( + promptData.name, + state.serverPrompts, + promptName + ) + if (!nameValidation.valid) { + throw new Error(nameValidation.error) + } + + // Check for duplicate in other local prompts + const duplicateLocal = state.localPrompts.find( + (p) => p.name === promptData.name && p.name !== promptName + ) + if (duplicateLocal) { + throw new Error( + `A local prompt with name "${promptData.name}" already exists` + ) + } + } + + // Update the prompt + const updatedPrompts = [...state.localPrompts] + updatedPrompts[index] = { + ...updatedPrompts[index], + ...promptData, + updatedAt: new Date().toISOString(), + } + + set({ localPrompts: updatedPrompts }) + + return updatedPrompts[index] + }, + + // Delete a local prompt + deleteLocalPrompt: (promptName) => { + const state = get() + + const filtered = state.localPrompts.filter((p) => p.name !== promptName) + if (filtered.length === state.localPrompts.length) { + throw new Error(`Local prompt "${promptName}" not found`) + } + + set({ localPrompts: filtered }) + }, + + // Get a local prompt by name + getLocalPrompt: (promptName) => { + const state = get() + return state.localPrompts.find((p) => p.name === promptName) + }, + + // Export local prompts as YAML + exportLocalPromptsAsYAML: () => { + const state = get() + return state.localPrompts.map((prompt) => { + // Remove metadata fields for clean export + const { createdAt, updatedAt, source, ...cleanPrompt } = prompt + return promptToYAML(cleanPrompt) + }) + }, }) diff --git a/sippy-ng/src/chat/store/useChatStore.js b/sippy-ng/src/chat/store/useChatStore.js index 884b5c75e..343a6ed7e 100644 --- a/sippy-ng/src/chat/store/useChatStore.js +++ b/sippy-ng/src/chat/store/useChatStore.js @@ -37,6 +37,7 @@ export const useChatStore = create( sessions: state.sessions, activeSessionId: state.activeSessionId, settings: state.settings, + localPrompts: state.localPrompts, }), } ) @@ -161,10 +162,17 @@ export const useWebSocketActions = () => export const usePrompts = () => useChatStore( useShallow((state) => ({ - prompts: state.prompts, + prompts: state.getPrompts(), + localPrompts: state.localPrompts, + serverPrompts: state.serverPrompts, promptsLoading: state.promptsLoading, promptsError: state.promptsError, fetchPrompts: state.fetchPrompts, renderPrompt: state.renderPrompt, + saveLocalPrompt: state.saveLocalPrompt, + updateLocalPrompt: state.updateLocalPrompt, + deleteLocalPrompt: state.deleteLocalPrompt, + getLocalPrompt: state.getLocalPrompt, + exportLocalPromptsAsYAML: state.exportLocalPromptsAsYAML, })) )