diff --git a/package-lock.json b/package-lock.json index 6d7aa9edc..4aabfdeab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "monaco-editor": "0.51.0", "monaco-yaml": "^5.2.3", "node-diff3": "^3.1.2", + "openai": "^4.97.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.56.1", @@ -4570,7 +4571,6 @@ "version": "22.15.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", - "dev": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4579,7 +4579,6 @@ "version": "2.6.12", "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", - "dev": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.0" @@ -5463,7 +5462,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -5527,7 +5525,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, "dependencies": { "humanize-ms": "^1.2.1" }, @@ -5984,8 +5981,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.7", @@ -6561,7 +6557,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -7172,7 +7167,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -8190,7 +8184,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -8418,7 +8411,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -8658,7 +8650,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -8667,7 +8658,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -8709,7 +8699,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -8721,7 +8710,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -10017,7 +10005,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "engines": { "node": ">=6" } @@ -10617,7 +10604,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -10631,14 +10617,12 @@ "node_modules/form-data-encoder": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "dev": true + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" }, "node_modules/formdata-node": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "dev": true, "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" @@ -10858,7 +10842,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -10899,7 +10882,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -11082,7 +11064,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -11222,7 +11203,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -11234,7 +11214,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -11644,7 +11623,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, "dependencies": { "ms": "^2.0.0" } @@ -14743,7 +14721,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -15412,7 +15389,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -15421,7 +15397,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -15792,7 +15767,6 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "dev": true, "funding": [ { "type": "github", @@ -16098,10 +16072,9 @@ } }, "node_modules/openai": { - "version": "4.96.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.96.2.tgz", - "integrity": "sha512-R2XnxvMsizkROr7BV3uNp1q/3skwPZ7fmPjO1bXLnfB4Tu5xKxrT1EVwzjhxn0MZKBKAvOaGWS63jTMN6KrIXA==", - "dev": true, + "version": "4.97.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.97.0.tgz", + "integrity": "sha512-LRoiy0zvEf819ZUEJhgfV8PfsE8G5WpQi4AwA1uCV8SKvvtXQkoWUFkepD6plqyJQRghy2+AEPQ07FrJFKHZ9Q==", "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -16131,7 +16104,6 @@ "version": "18.19.87", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.87.tgz", "integrity": "sha512-OIAAu6ypnVZHmsHCeJ+7CCSub38QNBS9uceMQeg7K5Ur0Jr+wG9wEOEvvMbhp09pxD5czIUy/jND7s7Tb6Nw7A==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -16139,8 +16111,7 @@ "node_modules/openai/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/optionator": { "version": "0.9.4", @@ -20340,8 +20311,7 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/unfetch": { "version": "4.2.0", @@ -20820,7 +20790,6 @@ "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "dev": true, "engines": { "node": ">= 14" } @@ -21496,7 +21465,7 @@ "version": "8.18.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "dev": true, + "devOptional": true, "engines": { "node": ">=10.0.0" }, @@ -21635,7 +21604,7 @@ "version": "3.24.3", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", - "dev": true, + "devOptional": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index bb7740600..8b6ec1d7f 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "monaco-editor": "0.51.0", "monaco-yaml": "^5.2.3", "node-diff3": "^3.1.2", + "openai": "^4.97.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.56.1", diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/components/EditorWrapper.tsx b/source/javascripts/components/unified-editor/StepConfigDrawer/components/EditorWrapper.tsx new file mode 100644 index 000000000..0023f9a82 --- /dev/null +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/components/EditorWrapper.tsx @@ -0,0 +1,61 @@ +import { Editor } from '@monaco-editor/react'; +import { editor } from 'monaco-editor'; +import { useCallback, useEffect, useState } from 'react'; + +import MonacoUtils from '@/core/utils/MonacoUtils'; + +const EDITOR_OPTIONS = { + fontSize: 13, + fontFamily: 'mono', + roundedSelection: false, + scrollBeyondLastLine: false, + stickyScroll: { enabled: true }, + contextmenu: false, + minimap: { enabled: false }, + padding: { top: 16, bottom: 16 }, +}; + +type EditorWrapperProps = { + value: string; + defaultValue?: string; + onChange: (value: string | null) => void; + readOnly?: boolean; +}; + +const EditorWrapper = (props: EditorWrapperProps) => { + const { defaultValue, onChange, readOnly, value } = props; + const [editorInstance, setEditor] = useState(); + + const updateEditorHeight = useCallback(() => { + if (!editorInstance) { + return; + } + + const contentHeight = Math.min(editorInstance?.getContentHeight() || 250, window.innerHeight * 0.5); + requestAnimationFrame(() => + editorInstance?.layout({ + height: contentHeight, + width: editorInstance?.getLayoutInfo().width, + }), + ); + }, [editorInstance]); + + useEffect(() => { + editorInstance?.onDidContentSizeChange(updateEditorHeight); + updateEditorHeight(); + return undefined; + }, [editorInstance, updateEditorHeight]); + + return ( + onChange(changedValue || null)} + beforeMount={MonacoUtils.configureEnvVarsCompletionProvider} + /> + ); +}; +export default EditorWrapper; diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/components/ExpandableMessage.tsx b/source/javascripts/components/unified-editor/StepConfigDrawer/components/ExpandableMessage.tsx new file mode 100644 index 000000000..08123865a --- /dev/null +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/components/ExpandableMessage.tsx @@ -0,0 +1,54 @@ +import { Box, Button, Card, Collapse, forwardRef, Icon, MarkdownContent, useDisclosure } from '@bitrise/bitkit'; + +import EditorWrapper from './EditorWrapper'; + +type ExpandableMessageProps = { + buttonLabel: string; + children: string; + isExpanded?: boolean; + isLoading?: boolean; + onButtonClick?: VoidFunction; + title: string; + type: 'plan' | 'content' | 'message'; +}; + +const ExpandableMessage = forwardRef((props, ref) => { + const { buttonLabel, children, isLoading, onButtonClick, title, type } = props; + + const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onButtonClick?.(); + }; + + return ( + + onToggle()} padding="16" data-group role="button" display="flex" alignItems="center" gap="8"> + + {title} + + + + + {type === 'plan' && } + {type === 'content' && } + + + + + ); +}); + +export default ExpandableMessage; diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepCodeEditor.tsx b/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepCodeEditor.tsx index 01fe8568a..e5927a8f1 100644 --- a/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepCodeEditor.tsx +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepCodeEditor.tsx @@ -1,20 +1,8 @@ -import { Box, Label } from '@bitrise/bitkit'; -import { Editor } from '@monaco-editor/react'; -import type { editor } from 'monaco-editor'; -import { useCallback, useEffect, useState } from 'react'; +import { Box, FilterSwitch, FilterSwitchGroup, Label } from '@bitrise/bitkit'; +import { useState } from 'react'; -import MonacoUtils from '@/core/utils/MonacoUtils'; - -const EDITOR_OPTIONS = { - fontSize: 13, - fontFamily: 'mono', - roundedSelection: false, - scrollBeyondLastLine: false, - stickyScroll: { enabled: true }, - contextmenu: false, - minimap: { enabled: false }, - padding: { top: 16, bottom: 16 }, -}; +import EditorWrapper from './EditorWrapper'; +import StepMaker from './StepMaker'; type Props = { label?: string; @@ -24,40 +12,23 @@ type Props = { }; const StepCodeEditor = ({ label, value, defaultValue, onChange }: Props) => { - const [editorInstance, setEditor] = useState(); - - const updateEditorHeight = useCallback(() => { - if (!editorInstance) { - return; - } - - const contentHeight = Math.min(editorInstance?.getContentHeight() || 250, window.innerHeight * 0.5); - requestAnimationFrame(() => - editorInstance?.layout({ - height: contentHeight, - width: editorInstance?.getLayoutInfo().width, - }), - ); - }, [editorInstance]); - - useEffect(() => { - editorInstance?.onDidContentSizeChange(updateEditorHeight); - updateEditorHeight(); - return undefined; - }, [editorInstance, updateEditorHeight]); + const [state, setState] = useState<'script' | 'ai'>('script'); return ( - - {label && } - onChange(changedValue || null)} - beforeMount={MonacoUtils.configureEnvVarsCompletionProvider} - /> + + + {label && } + setState(v as 'script' | 'ai')} value={state} marginBlockStart="0"> + Script + Step Maker + + + + + + + + ); }; diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepMaker.tsx b/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepMaker.tsx new file mode 100644 index 000000000..ad1da2a59 --- /dev/null +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepMaker.tsx @@ -0,0 +1,200 @@ +/* eslint-disable react/no-array-index-key */ +import { Avatar, Box, BoxProps, Button, Input, MarkdownContent, ProgressBitbot, Text } from '@bitrise/bitkit'; +import { useEffect, useState } from 'react'; + +import PageProps from '@/core/utils/PageProps'; +import useEnvVars from '@/hooks/useEnvVars'; +import { useSecrets } from '@/hooks/useSecrets'; + +import { examplePrompts } from '../hooks/prompts'; +import useStepMakerAI, { Message } from '../hooks/useStepMakerAI'; +import { useStepDrawerContext } from '../StepConfigDrawer.context'; +import ExpandableMessage from './ExpandableMessage'; +import aiAvatar from './purr.png'; + +interface MessageItemProps extends BoxProps { + isLoading: boolean; + message: Message; + onPlanButtonClick?: VoidFunction; + onSaveButtonClick?: (value: string | null) => void; +} + +const MessageItem = (props: MessageItemProps) => { + const { isLoading, message, onPlanButtonClick, onSaveButtonClick } = props; + return ( + + {message.sender === 'user' && ( + + )} + {message.sender === 'ai' && } + + {message.type === 'plan' && ( + + {message.content} + + )} + {message.type === 'content' && ( + onSaveButtonClick?.(message.content)} + title="Here is the purr-fect code. Apply to write it into the YML." + type="content" + isLoading={isLoading} + > + {message.content} + + )} + {message.type === 'message' && } + + + ); +}; + +type StepMakerProps = { + onChange: (value: string | null) => void; +}; + +const StepMaker = (props: StepMakerProps) => { + const { onChange } = props; + const { workflowId } = useStepDrawerContext(); + + const [value, setValue] = useState(''); + const [token, setToken] = useState(''); + + const appSlug = PageProps.appSlug(); + const { data: secretData, isLoading: isSecretsLoading } = useSecrets({ appSlug }); + const { envs } = useEnvVars({ + enabled: true, + stepBundleIds: [], + workflowIds: workflowId ? [workflowId] : [], + }); + + useEffect(() => { + if (!isSecretsLoading) { + setToken(secretData?.find(({ key }) => key === 'OPENAI_API_KEY')?.value || ''); + } + }, [isSecretsLoading, secretData]); + + const { isLoading, messages, sendMessage, reset } = useStepMakerAI({ + bitriseYml: '', + appSecretKeys: secretData?.map(({ key }) => key) || [], + appEnvKeys: envs.map(({ key }) => key) || [], + selectedWorkflow: workflowId, + token, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + sendMessage('chat', value); + setValue(''); + }; + + return ( + <> + + {messages.length === 0 && ( + + + + Meowdy! I'm Purr Request, your paw-sonal Step-making assistant. + + + Just give me a whisker of a description about what you want your step to +
+ do, and I’ll pounce on it for you. +
+ {!token && ( + + To get started, add your ChatGPT API key as a secret. The key must +
+ be OPENAI_API_KEY e.g: OPENAI_API_KEY=’your_API_key’ +
+ )} + + {!!token && + examplePrompts.map((p) => ( + { + sendMessage('chat', p); + }} + > + {p} + + ))} + +
+ )} + {messages.map((message, index) => ( + sendMessage('process_with_plan', 'Proceed with code generation')} + onSaveButtonClick={onChange} + isLoading={isLoading} + /> + ))} + {isLoading && } + {messages.length > 0 && ( + + + + )} +
+ + setValue(e.currentTarget.value)} + size="md" + flex="1" + value={value} + autoFocus + placeholder="What do you need?" + /> + + + + Purr Request uses LLMs and may not always be reliable. Always review code before testing. + + + ); +}; + +export default StepMaker; diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/components/purr.png b/source/javascripts/components/unified-editor/StepConfigDrawer/components/purr.png new file mode 100644 index 000000000..aa7b302ed Binary files /dev/null and b/source/javascripts/components/unified-editor/StepConfigDrawer/components/purr.png differ diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/hooks/prompts.ts b/source/javascripts/components/unified-editor/StepConfigDrawer/hooks/prompts.ts new file mode 100644 index 000000000..60d9323fc --- /dev/null +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/hooks/prompts.ts @@ -0,0 +1,132 @@ +const bitriseInfo = ` +# Bitrise step constraints + +## Introduction + +Bitrise steps are the building blocks of CI/CD workflows. Steps are very similar to GitHub Actions' Actions. Any executable can be a step as long it has an entrypoint. + +Step parameters are called inputs. They are implemented as env vars, and a step's inputs are described in a metadata file called \`step.yml\`. Steps also have outputs (also env vars) that can be connected to (referenced in) other steps' inputs. + +## bitrise.yml + +A workflow is declared in a file called \`bitrise.yml\`. A workflow is essentially a sequence of step references with inputs. A step reference can be: + +1. A step ID and a version constraint. There is a central registry called the Step Library, which contains the mapping of step IDs to git repositories (where the step source code is). For example, \`git-clone@8\` is a step reference to the step with ID \`git-clone\`, selecting the latest \`8.x.x\` version. +2. A direct git repo reference. For example, \`git::github.com/user/repo\`. + +## Notable env vars, steps, step outputs + +There are some notable values that come in handy when implementing a new step. + +### Env vars + +The following env vars are exposed to the build environment and steps can read them: + +- \`$BITRISE_SOURCE_DIR\`: Local path of the working directory. Unless modified, this is where the repo is checked out. +- \`$BITRISE_DEPLOY_DIR\`: Local path of a special directory used for build artifacts. Steps can copy/write files to this dir if they want to create an artifact. +- \`$PR\`: \`true\` if the current build relates to a PR, \`false\` otherwise + +### The build environment + +You can find the following CLI tools pre-installed and ready for use: +- curl +- wget +- zip +- tar +- jq +- git +- coreutils (cat, chmod, cp, install, ln, etc.) +- node, npm, yarn, pnpm +- gh (note: some subcommands need authentication!) +- aws (version 2) +- gcloud +- pipx +- firebase + +Linux-only: +- docker + +macOS-only: +- brew +- xcodebuild +- xcodes +- swift +- tuist +- pod (Cocoapods) +`; + +export const plannerPrompt = ( + selectedWorkflow: string, + bitriseYml: string, + appSecretKeys: string[], + appEnvKeys: string[], +) => ` +You are a DevOps engineer helping Bitrise CI/CD users with their bash script step. You are given an existing (functioning) workflow and editing a bash script step and a new request to improve that step. +Your goal is to understand the user's request and create a high-level plan to implement the requested changes. + +Technical considerations: +- DO NOT write any code or YAML, just a high-level plan. +- DO NOT assume the user uses any CI/CD tool other than Bitrise. +- DO NOT add high level plan or code in responses. +- YOU MUST ALWAYS CALL call store_plan function instead of returning the plan. + +Content considerations: +- Make sure to ask clarifying questions if the request is not clear. Think about various edge cases, not just the happy path. +- The plan shouldn't be too verbose, but it should be detailed enough to understand the implementation. +- For every response in this conversation, add a 1-3 word cat-like comment to the end of the response. + +Selected workflow to edit: ${selectedWorkflow} + +bitrise.yml that implements the selected workflow: +\`\`\`yml +${bitriseYml} +\`\`\` + +Available user-defined env vars: +${appEnvKeys.map((key) => `- $${key}`).join('\n')} + +Available user-defined secret env vars: +${appSecretKeys.map((key) => `- $${key}`).join('\n')} + +Below you will find some documentation about Bitrise workflows and implementation details you need to be aware of: +${bitriseInfo} +`; + +export const examplePrompts = [ + 'Look at the changed files of the PR that triggered this build. Add a label to the PR based on the size of the diff, such as `Size: M` or `Size: XXL`.', + "Fetch one random Chuck Norris joke from `https://api.chucknorris.io/jokes/random` so that I don't get bored waiting for the build.", + 'Print the IP address of the machine running the build.', + "Iterate the dependency tree, fetch their declared license metadata and abort the build if a dependency's license is in our forbidden license list file. Present results in a table format and post it as a PR comment. If the offending dependency is a transitive one, make sure to also report the direct dependency that introduced it.", +]; + +export const coderSystemPrompt = ( + selectedWorkflow: string, + bitriseYml: string, + appEnvKeys: string[], + appSecretKeys: string[], +) => ` +You are an expert bash script developer. Your task is to generate a single, syntactically correct bash script based on the plan provided. + +## Instructions: +- YOU MUST ALWAYS CALL store_bash_script function instead of returning code in response. +- Include appropriate shebang (#!/bin/bash) at the beginning +- Ensure errors are handled and error messages are meaningful, and do parameter validation when appropriate +- Add helpful comments within the script to document functionality +- Follow best practices for bash scripting (variable naming, quoting, etc.) +- Implement all functionality described in the provided plan + +The bash script you provide will be parsed and executed directly as a bash script at the right place within the workflow. It's going to be inlined into a script step, you do not need to work on that. + +Workflow you are working on: ${selectedWorkflow} + +Available user-defined env vars: +${appEnvKeys.map((key) => `- $${key}`).join('\n')} + +Available user-defined secret env vars: +${appSecretKeys.map((key) => `- $${key}`).join('\n')} + +bitrise.yml that implements the selected workflow: +\`\`\`yml +${bitriseYml} +\`\`\` +`; diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/hooks/useStepMakerAI.ts b/source/javascripts/components/unified-editor/StepConfigDrawer/hooks/useStepMakerAI.ts new file mode 100644 index 000000000..297db3b4f --- /dev/null +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/hooks/useStepMakerAI.ts @@ -0,0 +1,196 @@ +import OpenAI from 'openai'; +import { ResponseInput } from 'openai/resources/responses/responses'; +import { useState } from 'react'; + +import { coderSystemPrompt, plannerPrompt } from './prompts'; + +export type Message = { + content: string; + sender: 'user' | 'ai'; + type: 'message' | 'plan' | 'content'; +}; + +type Props = { + bitriseYml: string; + selectedWorkflow: string; + appSecretKeys: string[]; + appEnvKeys: string[]; + token: string; +}; + +const FUNCTION_CALL_PLAN = 'store_plan'; +const FUNCTION_CALL_STORE_BASH_SCRIPT = 'store_bash_script'; + +// type StepMakerState = InitialState | Planning | CodeGeneration | WaitingForBuild | BuildLogEvaluation; + +// type InitialState = { +// kind: 'initial'; +// messages: Message[]; +// examplePrompts: string[]; +// }; + +// type Planning = { +// kind: 'planning'; +// messages: Message[]; +// userQA: Map; +// }; + +// type CodeGeneration = { +// kind: 'codeGeneration'; +// messages: Message[]; +// userQA: Map; +// }; + +// type WaitingForBuild = { +// kind: 'waitingForBuild'; +// buildSlug: string; +// messages: Message[]; +// }; + +// type BuildLogEvaluation = { +// kind: 'buildLogEvaluation'; +// plan: string; +// messages: Message[]; +// buildLogSnippet: string; +// }; + +const useStepMakerAI = (props: Props) => { + const { bitriseYml, selectedWorkflow, token } = props; + const [isLoading, setIsLoading] = useState(false); + const [responseId, setResponseId] = useState(null); + const [messages, setMessages] = useState([]); + const [toolOutputId, setToolOutputId] = useState(null); + + const client = new OpenAI({ + apiKey: token, + dangerouslyAllowBrowser: true, + }); + + const reset = () => { + setMessages([]); + setToolOutputId(null); + setResponseId(null); + }; + + const sendMessage = async (action: 'chat' | 'process_with_plan', input: string) => { + setIsLoading(true); + setMessages((prev) => [...prev, { content: input, sender: 'user', type: 'message' }]); + + const inputs: ResponseInput = [ + { + content: input, + role: 'user', // | 'assistant' | 'system' | 'developer'; + type: 'message', + }, + ]; + + if (toolOutputId) { + let output = 'ok'; + if (action !== 'process_with_plan') { + output = 'requires refinement'; + } + + inputs.push({ + output, + call_id: toolOutputId, + type: 'function_call_output', + }); + + setToolOutputId(''); + } + + let instructions = plannerPrompt(selectedWorkflow, bitriseYml, props.appSecretKeys, props.appEnvKeys); + if (action === 'process_with_plan') { + instructions = coderSystemPrompt(selectedWorkflow, bitriseYml, props.appSecretKeys, props.appEnvKeys); + } + + // let selectedTool = FUNCTION_CALL_PLAN; + // if (action === 'process_with_plan') { + // selectedTool = FUNCTION_CALL_STORE_BASH_SCRIPT; + // } + + const response = await client.responses.create({ + model: 'gpt-4o-mini', + instructions, + input: inputs, + previous_response_id: responseId, + temperature: 0.5, + tools: [ + { + name: FUNCTION_CALL_PLAN, + strict: false, + parameters: { + type: 'object', + properties: { + plan: { + type: 'string', + description: 'The plan you created in Markdown.', + }, + questions: { + description: 'The questions to ask the user.', + type: 'array', + items: { + type: 'string', + }, + minItems: 0, + maxItems: 3, + }, + }, + required: ['plan', 'questions'], + additionalProperties: false, + }, + type: 'function', + description: 'Store the current high-level plan for the next phase (code generation).', + }, + { + name: FUNCTION_CALL_STORE_BASH_SCRIPT, + strict: false, + parameters: { + type: 'object', + properties: { + script: { + type: 'string', + description: 'The text of the bash script.', + }, + }, + required: ['script'], + additionalProperties: false, + }, + type: 'function', + description: + 'The provided bash script gets stored in the selected Bitrise workflow script step. The script is a part of the workflow.', + }, + ], + // tool_choice: { + // type: 'function', + // name: selectedTool, + // }, + }); + setIsLoading(false); + setResponseId(response.id); + + console.log('Response:', response); + + response.output.forEach((outputItem) => { + if (outputItem.type === 'message') { + setMessages((prev) => [...prev, { content: response.output_text, sender: 'ai', type: 'message' }]); + } else if (outputItem.type === 'function_call' && outputItem.name === FUNCTION_CALL_PLAN) { + setToolOutputId(outputItem.call_id); + setMessages((prev) => [ + ...prev, + { content: JSON.parse((outputItem as any).arguments).plan, sender: 'ai', type: 'plan' }, + ]); + } else if (outputItem.type === 'function_call' && outputItem.name === FUNCTION_CALL_STORE_BASH_SCRIPT) { + setToolOutputId(outputItem.call_id); + setMessages((prev) => [ + ...prev, + { content: JSON.parse((outputItem as any).arguments).script, sender: 'ai', type: 'content' }, + ]); + } + }); + }; + + return { isLoading, messages, sendMessage, reset }; +}; + +export default useStepMakerAI; diff --git a/spec/integration/test_bitrise.secrets.yml b/spec/integration/test_bitrise.secrets.yml index 609276153..363f5503b 100644 --- a/spec/integration/test_bitrise.secrets.yml +++ b/spec/integration/test_bitrise.secrets.yml @@ -34,3 +34,4 @@ envs: is_expose: true is_protected: false test: elek +