feat: UI for test scripts generation (#4637)

AI experiments

Co-authored-by: amk-dev <akash.k.mohan98@gmail.com>
This commit is contained in:
Govind.S.B 2024-12-20 18:17:31 +05:30 committed by GitHub
parent f734bdea5c
commit e16db0ca32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 549 additions and 3 deletions

View file

@ -1250,6 +1250,9 @@
"feedback_thank_you": "Thank you for your feedback!",
"feedback_cta_text_long": "Rate the generation, helps us to improve",
"feedback_cta_request_name": "Did you like the generated name?",
"modify_request_body_error": "Failed to modify request body"
"modify_request_body_error": "Failed to modify request body",
"generate_or_modify_prerequest_input_placeholder": "Enter a prompt to generate or modify the pre-request script",
"generate_or_modify_test_script_input_placeholder": "Enter a prompt to generate or modify the test script",
"modify_test_script_error": "Failed to modify test script"
}
}

View file

@ -16,6 +16,8 @@ declare module 'vue' {
AccessTokensOverview: typeof import('./components/accessTokens/Overview.vue')['default']
AiexperimentsMergeView: typeof import('./components/aiexperiments/MergeView.vue')['default']
AiexperimentsModifyBodyModal: typeof import('./components/aiexperiments/ModifyBodyModal.vue')['default']
AiexperimentsModifyPreRequestModal: typeof import('./components/aiexperiments/ModifyPreRequestModal.vue')['default']
AiexperimentsModifyTestScriptModal: typeof import('./components/aiexperiments/ModifyTestScriptModal.vue')['default']
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: (typeof import("./components/app/Announcement.vue"))["default"]
AppBanner: typeof import('./components/app/Banner.vue')['default']

View file

@ -0,0 +1,159 @@
<template>
<HoppSmartModal styles="sm:max-w-3xl" full-width>
<template #body>
<div class="flex flex-col border-b border-divider transition relative">
<div class="flex items-center pt-3 pb-3 sticky">
<input
id="command"
v-model="userPrompt"
v-focus
type="text"
autocomplete="off"
name="command"
:placeholder="`${t(
'ai_experiments.generate_or_modify_prerequest_input_placeholder'
)}`"
class="flex flex-1 bg-transparent px-6 text-base text-secondaryDark"
@keypress="
async (e) => {
if (e.key === 'Enter') {
await modifyPreRequestScript()
submittedFeedback = false
}
}
"
/>
<HoppButtonSecondary
:icon="IconArrowRight"
class="mr-6 rounded-md flex flex-col-reverse"
:label="t('ai_experiments.generate')"
outline
filled
:loading="isModifyPreRequestPending"
:disabled="!userPrompt || isModifyPreRequestPending"
@click="
async () => {
await modifyPreRequestScript()
submittedFeedback = false
}
"
/>
</div>
<div>
<AiexperimentsMergeView
:content-left="{
content: currentScript ?? '',
langMime: 'application/javascript',
}"
:content-right="{
content: generatedScriptContent,
langMime: 'application/javascript',
}"
></AiexperimentsMergeView>
</div>
</div>
</template>
<template #footer>
<div class="flex gap-1 px-6 py-3 justify-between items-center w-full">
<div
v-if="lastTraceID && !submittedFeedback"
class="flex items-center gap-2"
>
<p>{{ t("ai_experiments.feedback_cta_text_long") }}</p>
<template v-if="!isSubmitFeedbackPending">
<HoppButtonSecondary
:icon="IconThumbsUp"
outline
@click="
async () => {
if (lastTraceID) {
await submitFeedback('positive', lastTraceID)
submittedFeedback = true
}
}
"
/>
<HoppButtonSecondary
:icon="IconThumbsDown"
outline
@click="
async () => {
if (lastTraceID) {
await submitFeedback('negative', lastTraceID)
submittedFeedback = true
}
}
"
/>
</template>
<HoppSmartSpinner v-else />
</div>
<div v-if="submittedFeedback">
<p>{{ t("ai_experiments.feedback_thank_you") }}</p>
</div>
<div class="ml-auto space-x-2">
<HoppButtonSecondary
:label="t('action.cancel')"
outline
@click="emit('closeModal')"
/>
<HoppButtonSecondary
:label="t('ai_experiments.accept_change')"
outline
filled
:disabled="isModifyPreRequestPending || !generatedScriptContent"
@click="
() => {
emit('updateScript', generatedScriptContent)
emit('closeModal')
}
"
/>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import IconArrowRight from "~icons/lucide/arrow-right"
import IconThumbsUp from "~icons/lucide/thumbs-up"
import IconThumbsDown from "~icons/lucide/thumbs-down"
import { ref } from "vue"
import { useI18n } from "~/composables/i18n"
import {
useModifyPreRequestScript,
useSubmitFeedback,
} from "~/composables/ai-experiments"
import { HoppRESTRequest } from "@hoppscotch/data"
const props = defineProps<{
currentScript: string
requestInfo: HoppRESTRequest
}>()
const emit = defineEmits<{
(e: "closeModal"): void
(e: "updateScript", script: string): void
}>()
const t = useI18n()
const generatedScriptContent = ref("")
const userPrompt = ref("")
const submittedFeedback = ref(false)
const { modifyPreRequestScript, isModifyPreRequestPending, lastTraceID } =
useModifyPreRequestScript(
props.currentScript,
userPrompt,
generatedScriptContent,
props.requestInfo
)
const { submitFeedback, isSubmitFeedbackPending } = useSubmitFeedback()
</script>

View file

@ -0,0 +1,159 @@
<template>
<HoppSmartModal styles="sm:max-w-3xl" full-width>
<template #body>
<div class="flex flex-col border-b border-divider transition relative">
<div class="flex items-center pt-3 pb-3 sticky">
<input
id="command"
v-model="userPrompt"
v-focus
type="text"
autocomplete="off"
name="command"
:placeholder="`${t(
'ai_experiments.generate_or_modify_test_script_input_placeholder'
)}`"
class="flex flex-1 bg-transparent px-6 text-base text-secondaryDark"
@keypress="
async (e) => {
if (e.key === 'Enter') {
await modifyTestScript()
submittedFeedback = false
}
}
"
/>
<HoppButtonSecondary
:icon="IconArrowRight"
class="mr-6 rounded-md flex flex-col-reverse"
:label="t('ai_experiments.generate')"
outline
filled
:loading="isModifyTestScriptPending"
:disabled="!userPrompt || isModifyTestScriptPending"
@click="
async () => {
await modifyTestScript()
submittedFeedback = false
}
"
/>
</div>
<div>
<AiexperimentsMergeView
:content-left="{
content: currentScript ?? '',
langMime: 'application/javascript',
}"
:content-right="{
content: generatedScriptContent,
langMime: 'application/javascript',
}"
></AiexperimentsMergeView>
</div>
</div>
</template>
<template #footer>
<div class="flex gap-1 px-6 py-3 justify-between items-center w-full">
<div
v-if="lastTraceID && !submittedFeedback"
class="flex items-center gap-2"
>
<p>{{ t("ai_experiments.feedback_cta_text_long") }}</p>
<template v-if="!isSubmitFeedbackPending">
<HoppButtonSecondary
:icon="IconThumbsUp"
outline
@click="
async () => {
if (lastTraceID) {
await submitFeedback('positive', lastTraceID)
submittedFeedback = true
}
}
"
/>
<HoppButtonSecondary
:icon="IconThumbsDown"
outline
@click="
async () => {
if (lastTraceID) {
await submitFeedback('negative', lastTraceID)
submittedFeedback = true
}
}
"
/>
</template>
<HoppSmartSpinner v-else />
</div>
<div v-if="submittedFeedback">
<p>{{ t("ai_experiments.feedback_thank_you") }}</p>
</div>
<div class="ml-auto space-x-2">
<HoppButtonSecondary
:label="t('action.cancel')"
outline
@click="emit('closeModal')"
/>
<HoppButtonSecondary
:label="t('ai_experiments.accept_change')"
outline
filled
:disabled="isModifyTestScriptPending || !generatedScriptContent"
@click="
() => {
emit('updateScript', generatedScriptContent)
emit('closeModal')
}
"
/>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import IconArrowRight from "~icons/lucide/arrow-right"
import IconThumbsUp from "~icons/lucide/thumbs-up"
import IconThumbsDown from "~icons/lucide/thumbs-down"
import { ref } from "vue"
import { useI18n } from "~/composables/i18n"
import {
useModifyTestScript,
useSubmitFeedback,
} from "~/composables/ai-experiments"
import { HoppRESTRequest } from "@hoppscotch/data"
const props = defineProps<{
currentScript: string
requestInfo: HoppRESTRequest
}>()
const emit = defineEmits<{
(e: "closeModal"): void
(e: "updateScript", script: string): void
}>()
const t = useI18n()
const generatedScriptContent = ref("")
const userPrompt = ref("")
const submittedFeedback = ref(false)
const { modifyTestScript, isModifyTestScriptPending, lastTraceID } =
useModifyTestScript(
props.currentScript,
userPrompt,
generatedScriptContent,
props.requestInfo
)
const { submitFeedback, isSubmitFeedbackPending } = useSubmitFeedback()
</script>

View file

@ -27,6 +27,13 @@
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpPreRequest')"
/>
<HoppButtonSecondary
v-if="shouldEnableAIFeatures && currentRequest"
v-tippy="{ theme: 'tooltip' }"
:title="t('ai_experiments.modify_with_ai')"
:icon="IconSparkles"
@click="showModifyPreRequestModal"
/>
</div>
</div>
<div class="flex flex-1 border-b border-dividerLight">
@ -58,6 +65,13 @@
</div>
</div>
</div>
<AiexperimentsModifyPreRequestModal
v-if="isModifyPreRequestModalOpen && currentRequest"
:current-script="preRequestScript"
:request-info="currentRequest"
@close-modal="isModifyPreRequestModalOpen = false"
@update-script="(updatedScript) => (preRequestScript = updatedScript)"
/>
</div>
</template>
@ -65,7 +79,8 @@
import IconHelpCircle from "~icons/lucide/help-circle"
import IconWrapText from "~icons/lucide/wrap-text"
import IconTrash2 from "~icons/lucide/trash-2"
import { reactive, ref } from "vue"
import IconSparkles from "~icons/lucide/sparkles"
import { reactive, ref, computed } from "vue"
import snippets from "@helpers/preRequestScriptSnippets"
import { useCodemirror } from "@composables/codemirror"
import linter from "~/helpers/editor/linting/preRequest"
@ -74,6 +89,13 @@ import { useI18n } from "@composables/i18n"
import { useVModel } from "@vueuse/core"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import { useAIExperiments } from "~/composables/ai-experiments"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { platform } from "~/platform"
import { useReadonlyStream } from "~/composables/stream"
import AiexperimentsModifyPreRequestModal from "@components/aiexperiments/ModifyPreRequestModal.vue"
import { invokeAction } from "~/helpers/actions"
const t = useI18n()
@ -112,6 +134,29 @@ const useSnippet = (script: string) => {
const clearContent = () => {
preRequestScript.value = ""
}
const tabService = useService(RESTTabService)
const currentRequest = computed(() =>
tabService.currentActiveTab.value?.document.type === "request"
? tabService.currentActiveTab.value?.document.request
: null
)
const { shouldEnableAIFeatures } = useAIExperiments()
const isModifyPreRequestModalOpen = ref(false)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const showModifyPreRequestModal = () => {
if (!currentUser.value) {
invokeAction("modals.login.toggle")
return
}
isModifyPreRequestModalOpen.value = true
}
</script>
<style lang="scss" scoped>

View file

@ -27,6 +27,13 @@
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpTest')"
/>
<HoppButtonSecondary
v-if="shouldEnableAIFeatures && currentRequest"
v-tippy="{ theme: 'tooltip' }"
:title="t('ai_experiments.modify_with_ai')"
:icon="IconSparkles"
@click="showModifyTestScriptModal"
/>
</div>
</div>
<div class="flex flex-1 border-b border-dividerLight">
@ -58,6 +65,13 @@
</div>
</div>
</div>
<AiexperimentsModifyTestScriptModal
v-if="isModifyTestScriptModalOpen && currentRequest"
:current-script="testScript"
:request-info="currentRequest"
@close-modal="isModifyTestScriptModalOpen = false"
@update-script="(updatedScript) => (testScript = updatedScript)"
/>
</div>
</template>
@ -65,7 +79,8 @@
import IconHelpCircle from "~icons/lucide/help-circle"
import IconWrapText from "~icons/lucide/wrap-text"
import IconTrash2 from "~icons/lucide/trash-2"
import { reactive, ref } from "vue"
import IconSparkles from "~icons/lucide/sparkles"
import { reactive, ref, computed } from "vue"
import testSnippets from "~/helpers/testSnippets"
import { useCodemirror } from "@composables/codemirror"
import linter from "~/helpers/editor/linting/testScript"
@ -74,6 +89,13 @@ import { useI18n } from "@composables/i18n"
import { useVModel } from "@vueuse/core"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import { useAIExperiments } from "~/composables/ai-experiments"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { platform } from "~/platform"
import { useReadonlyStream } from "~/composables/stream"
import AiexperimentsModifyTestScriptModal from "@components/aiexperiments/ModifyTestScriptModal.vue"
import { invokeAction } from "~/helpers/actions"
const t = useI18n()
@ -108,6 +130,29 @@ const useSnippet = (script: string) => {
const clearContent = () => {
testScript.value = ""
}
const tabService = useService(RESTTabService)
const currentRequest = computed(() =>
tabService.currentActiveTab.value?.document.type === "request"
? tabService.currentActiveTab.value?.document.request
: null
)
const { shouldEnableAIFeatures } = useAIExperiments()
const isModifyTestScriptModalOpen = ref(false)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const showModifyTestScriptModal = () => {
if (!currentUser.value) {
invokeAction("modals.login.toggle")
return
}
isModifyTestScriptModalOpen.value = true
}
</script>
<style lang="scss" scoped>

View file

@ -185,3 +185,124 @@ export const useSubmitFeedback = () => {
isSubmitFeedbackPending,
}
}
export const useModifyPreRequestScript = (
currentScript: string,
userPromptRef: Ref<string>,
generatedScriptRef: Ref<string>,
requestInfo: HoppRESTRequest
) => {
const toast = useToast()
const t = useI18n()
const lastTraceID = ref<string | null>(null)
const isModifyPreRequestPending = ref(false)
const modifyPreRequestScriptForPlatform =
platform.experiments?.aiExperiments?.modifyPreRequestScript
const modifyPreRequestScript = async () => {
isModifyPreRequestPending.value = true
if (!modifyPreRequestScriptForPlatform) {
toast.error(t("ai_experiments.modify_prerequest_error"))
isModifyPreRequestPending.value = false
return
}
const result = await modifyPreRequestScriptForPlatform(
buildRequestInfoString(requestInfo, currentScript),
userPromptRef.value
)
if (result && E.isLeft(result)) {
toast.error(t("ai_experiments.modify_prerequest_error"))
isModifyPreRequestPending.value = false
return
}
generatedScriptRef.value = result.right.modified_script
lastTraceID.value = result.right.trace_id
isModifyPreRequestPending.value = false
return result.right
}
return {
modifyPreRequestScript,
isModifyPreRequestPending,
lastTraceID,
}
}
export const useModifyTestScript = (
currentScript: string,
userPromptRef: Ref<string>,
generatedScriptRef: Ref<string>,
requestInfo: HoppRESTRequest
) => {
const toast = useToast()
const t = useI18n()
const lastTraceID = ref<string | null>(null)
const isModifyTestScriptPending = ref(false)
const modifyTestScriptForPlatform =
platform.experiments?.aiExperiments?.modifyTestScript
const modifyTestScript = async () => {
isModifyTestScriptPending.value = true
if (!modifyTestScriptForPlatform) {
toast.error(t("ai_experiments.modify_test_script_error"))
isModifyTestScriptPending.value = false
return
}
const result = await modifyTestScriptForPlatform(
buildRequestInfoString(requestInfo, currentScript),
userPromptRef.value
)
if (result && E.isLeft(result)) {
toast.error(t("ai_experiments.modify_test_script_error"))
isModifyTestScriptPending.value = false
return
}
generatedScriptRef.value = result.right.modified_script
lastTraceID.value = result.right.trace_id
isModifyTestScriptPending.value = false
return result.right
}
return {
modifyTestScript,
isModifyTestScriptPending,
lastTraceID,
}
}
const buildRequestInfoString = (
request: HoppRESTRequest,
currentScript: string
) => {
return `
METHOD:
${request.method}
URL:
${request.endpoint}
BODY:
${JSON.stringify(request.body) ?? ""}
PARAMS:
${JSON.stringify(request.params, null, 2)}
HEADERS:
${JSON.stringify(request.headers, null, 2)}
EXISTING SCRIPT:
${currentScript}
`
}

View file

@ -28,5 +28,17 @@ export type ExperimentsPlatformDef = {
rating: -1 | 1,
traceID: string
) => Promise<E.Either<string, void>>
modifyPreRequestScript?: (
requestInfo: string,
userPrompt: string
) => Promise<
E.Either<string, { modified_script: string; trace_id: string }>
>
modifyTestScript?: (
requestInfo: string,
userPrompt: string
) => Promise<
E.Either<string, { modified_script: string; trace_id: string }>
>
}
}