diff --git a/client/codegen.ts b/client/codegen.ts index 0d86bd44baa..5d5d4eae707 100644 --- a/client/codegen.ts +++ b/client/codegen.ts @@ -89,6 +89,7 @@ const config: CodegenConfig = { '../server/libs/automation/automation-configuration/automation-configuration-graphql/src/main/resources/graphql/*.graphqls', '../server/libs/automation/automation-search/automation-search-graphql/src/main/resources/graphql/*.graphqls', '../server/libs/automation/automation-task/automation-task-graphql/src/main/resources/graphql/*.graphqls', + '../server/ee/libs/automation/automation-ai/automation-ai-gateway/automation-ai-gateway-graphql/src/main/resources/graphql/*.graphqls', '../server/libs/platform/platform-security/platform-security-graphql/src/main/resources/graphql/*.graphqls', '../server/libs/platform/platform-component/platform-component-log/platform-component-log-graphql/src/main/resources/graphql/*.graphqls', '../server/libs/ai/mcp/mcp-server-configuration/mcp-server-configuration-graphql/src/main/resources/graphql/*.graphqls', diff --git a/client/src/graphql/platform/ai-providers/aiDefaultModel.graphql b/client/src/graphql/platform/ai-providers/aiDefaultModel.graphql new file mode 100644 index 00000000000..d30a0219ef6 --- /dev/null +++ b/client/src/graphql/platform/ai-providers/aiDefaultModel.graphql @@ -0,0 +1,6 @@ +query aiDefaultModel($environment: ID!) { + aiDefaultModel(environment: $environment) { + provider + model + } +} diff --git a/client/src/graphql/platform/ai-providers/aiProviderCatalog.graphql b/client/src/graphql/platform/ai-providers/aiProviderCatalog.graphql new file mode 100644 index 00000000000..30e6a57291b --- /dev/null +++ b/client/src/graphql/platform/ai-providers/aiProviderCatalog.graphql @@ -0,0 +1,13 @@ +query aiProviderCatalog($environment: ID!) { + aiProviderCatalog(environment: $environment) { + key + name + icon + enabled + supportsModelById + models { + name + label + } + } +} diff --git a/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/hooks/usePropertyCodeEditorDialog.ts b/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/hooks/usePropertyCodeEditorDialog.ts index 47aaf35b37d..cd5d8849b07 100644 --- a/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/hooks/usePropertyCodeEditorDialog.ts +++ b/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/hooks/usePropertyCodeEditorDialog.ts @@ -1,10 +1,17 @@ import {usePropertyCodeEditorDialogStore} from '@/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/stores/usePropertyCodeEditorDialogStore'; import {getTask} from '@/pages/platform/workflow-editor/utils/getTask'; -import {useCopilotStore} from '@/shared/components/copilot/stores/useCopilotStore'; +import {parseJson} from '@/shared/components/ai-chat/messages/toToolResultDataPart'; +import useCopilotCodeToolResultStore from '@/shared/components/copilot/stores/useCopilotCodeToolResultStore'; +import useCopilotPostTurnRegistry from '@/shared/components/copilot/stores/useCopilotPostTurnRegistry'; +import {MODE, Source, useCopilotStore} from '@/shared/components/copilot/stores/useCopilotStore'; +import useCopilotToolResultHandlerRegistry from '@/shared/components/copilot/stores/useCopilotToolResultHandlerRegistry'; +import {extractScriptFromDefinition} from '@/shared/components/copilot/utils/extractScriptFromDefinition'; import {Workflow} from '@/shared/middleware/platform/configuration'; import {useCallback, useEffect, useState} from 'react'; import {useShallow} from 'zustand/react/shallow'; +const APPLIED_TO_EDITOR_MESSAGE = '✓ Applied changes to the editor.'; + interface UsePropertyCodeEditorDialogProps { onClose?: () => void; value?: string; @@ -81,6 +88,45 @@ export const usePropertyCodeEditorDialog = ({ } }, [value, editorValue, setDirty, setSaving]); + useEffect(() => { + const unregisterToolResult = useCopilotToolResultHandlerRegistry + .getState() + .register('updateScriptComponentCode', (content) => { + const result = parseJson<{definition?: string}>(content, 'updateScriptComponentCode result'); + + if (result?.definition) { + useCopilotCodeToolResultStore.getState().setLastUpdatedDefinition(result.definition); + } + }); + + const unregisterPostTurn = useCopilotPostTurnRegistry.getState().register(Source.CODE_EDITOR, () => { + const {appendToLastAssistantMessage, context} = useCopilotStore.getState(); + + const definition = useCopilotCodeToolResultStore.getState().lastUpdatedDefinition; + + useCopilotCodeToolResultStore.getState().clear(); + + if (context?.mode !== MODE.BUILD || definition == null) { + return; + } + + const code = extractScriptFromDefinition(definition, workflowNodeName); + + if (code == null) { + return; + } + + setEditorValue(code); + + appendToLastAssistantMessage(APPLIED_TO_EDITOR_MESSAGE); + }); + + return () => { + unregisterToolResult(); + unregisterPostTurn(); + }; + }, [setEditorValue, workflowNodeName]); + const currentWorkflowTask = getTask({ tasks: workflow.tasks || [], workflowNodeName, diff --git a/client/src/shared/components/ai-chat/messages/AskUserQuestionMessage.tsx b/client/src/shared/components/ai-chat/messages/AskUserQuestionMessage.tsx new file mode 100644 index 00000000000..bdeb7d4aa2d --- /dev/null +++ b/client/src/shared/components/ai-chat/messages/AskUserQuestionMessage.tsx @@ -0,0 +1,439 @@ +import Button from '@/components/Button/Button'; +import ComboBox from '@/components/ComboBox/ComboBox'; +import {MultiSelect} from '@/components/MultiSelect/MultiSelect'; +import {Input} from '@/components/ui/input'; +import {useAiChatAskedQuestionsStore} from '@/shared/components/ai-chat/stores/useAiChatAskedQuestionsStore'; +import {DataMessagePartProps, useThreadRuntime} from '@assistant-ui/react'; +import {ArrowLeftIcon, CheckIcon, XIcon} from 'lucide-react'; +import {useMemo, useState} from 'react'; + +export interface AskUserQuestionOptionDataI { + description?: string; + label: string; +} + +export interface AskUserQuestionDataI { + awaitingAnswer?: boolean; + kind: 'ask-user-question'; + questions: Array<{ + header?: string; + multiSelect: boolean; + options: AskUserQuestionOptionDataI[]; + question: string; + }>; +} + +const OTHER_OPTION_LABEL = '__other__'; + +/** Above this many options, single/multi select render a searchable control instead of a stacked list. */ +export const COMBOBOX_OPTION_THRESHOLD = 8; + +/** Match LLM-supplied option labels that should trigger the free-form input instead of submitting the literal text. */ +const isOtherLabel = (label: string) => /^other\b/i.test(label.trim()); + +/** + * Renders the LLM's askUserQuestion tool result: one question is a single card, multiple become a wizard + * that collects all answers and submits them as one combined user message (persisted to Spring AI chat + * memory, so picks survive a refresh). An "Other" option opens a free-form input, deduplicated against any + * "Other" the LLM already supplied. Answers are keyed in {@link useAiChatAskedQuestionsStore} by a + * fingerprint of the questions array so re-mounting replays the summary instead of re-prompting. + */ +const AskUserQuestionMessage = ({data}: DataMessagePartProps) => { + const questions = useMemo(() => data.questions ?? [], [data.questions]); + + const fingerprint = useMemo(() => fingerprintQuestions(questions), [questions]); + + const isAnswered = useAiChatAskedQuestionsStore((state) => state.hasAnswered(fingerprint)); + const persistedAnswer = useAiChatAskedQuestionsStore((state) => state.getAnswer(fingerprint)); + const markAnswered = useAiChatAskedQuestionsStore((state) => state.markAnswered); + + const [stepIndex, setStepIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + + const threadRuntime = useThreadRuntime(); + + if (questions.length === 0) { + return null; + } + + if (isAnswered) { + return ; + } + + const totalSteps = questions.length; + const currentQuestion = questions[stepIndex]; + const isLastStep = stepIndex === totalSteps - 1; + + const submitStep = (answer: string) => { + const nextAnswers = {...answers, [stepIndex]: answer}; + + setAnswers(nextAnswers); + + if (!isLastStep) { + setStepIndex(stepIndex + 1); + + return; + } + + const summary = buildAnswerSummary(questions, nextAnswers); + const messageText = totalSteps === 1 ? `User picked: ${answer}` : `User picked:\n${summary}`; + + markAnswered(fingerprint, summary); + + threadRuntime.append({ + content: [{text: messageText, type: 'text'}], + role: 'user', + }); + }; + + const goBack = () => { + if (stepIndex > 0) { + setStepIndex(stepIndex - 1); + } + }; + + return ( +
+ + +
{currentQuestion.question}
+ + + + {stepIndex > 0 && ( +
+ +
+ )} +
+ ); +}; + +type WizardHeaderPropsType = { + header?: string; + stepIndex: number; + totalSteps: number; +}; + +const WizardHeader = ({header, stepIndex, totalSteps}: WizardHeaderPropsType) => { + if (totalSteps === 1) { + if (!header) { + return null; + } + + return
{header}
; + } + + return ( +
+ + Question {stepIndex + 1} of {totalSteps} + + + {header && {header}} +
+ ); +}; + +type StepBodyPropsType = { + initialAnswer?: string; + isLastStep: boolean; + onSubmit: (answer: string) => void; + question: AskUserQuestionDataI['questions'][number]; +}; + +const StepBody = ({initialAnswer, isLastStep, onSubmit, question}: StepBodyPropsType) => { + if (question.multiSelect) { + return ( + + ); + } + + return ; +}; + +type SingleSelectStepPropsType = { + isLastStep: boolean; + onSubmit: (answer: string) => void; + question: AskUserQuestionDataI['questions'][number]; +}; + +const SingleSelectStep = ({isLastStep, onSubmit, question}: SingleSelectStepPropsType) => { + const [otherTyping, setOtherTyping] = useState(false); + const [otherValue, setOtherValue] = useState(''); + + // Suppress our injected "Other…" affordance if the LLM already supplied an Other-style option — keeps the + // UI from showing two duplicate "Other" entries side by side. + const llmSuppliedOther = question.options.some((option) => isOtherLabel(option.label)); + + const handleClick = (label: string) => { + if (label === OTHER_OPTION_LABEL || isOtherLabel(label)) { + setOtherTyping(true); + + return; + } + + onSubmit(label); + }; + + const handleOtherSubmit = () => { + const trimmed = otherValue.trim(); + + if (trimmed.length === 0) { + return; + } + + onSubmit(trimmed); + }; + + if (otherTyping) { + return ( +
+ setOtherValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleOtherSubmit(); + } + + if (event.key === 'Escape') { + event.preventDefault(); + + setOtherTyping(false); + setOtherValue(''); + } + }} + placeholder="Type your answer…" + value={otherValue} + /> + +
+ ); + } + + if (question.options.length > COMBOBOX_OPTION_THRESHOLD) { + const comboBoxItems = question.options.map((option) => ({label: option.label, value: option.label})); + + if (!llmSuppliedOther) { + comboBoxItems.push({label: 'Other…', value: OTHER_OPTION_LABEL}); + } + + return ( + { + if (item) { + handleClick(item.value as string); + } + }} + value={undefined} + /> + ); + } + + return ( +
+ {question.options.map((option) => ( +
+ ); +}; + +type MultiSelectStepPropsType = { + initialAnswer?: string; + isLastStep: boolean; + onSubmit: (answer: string) => void; + question: AskUserQuestionDataI['questions'][number]; +}; + +const MultiSelectStep = ({initialAnswer, isLastStep, onSubmit, question}: MultiSelectStepPropsType) => { + // Restore checkbox state when the user navigates Previous → Next without changing their selection. + const initialSet = useMemo( + () => (initialAnswer ? new Set(initialAnswer.split(', ')) : new Set()), + [initialAnswer] + ); + + const [selectedLabels, setSelectedLabels] = useState>(initialSet); + + const toggle = (label: string) => { + setSelectedLabels((previous) => { + const next = new Set(previous); + + if (next.has(label)) { + next.delete(label); + } else { + next.add(label); + } + + return next; + }); + }; + + const handleSubmit = () => { + if (selectedLabels.size === 0) { + return; + } + + onSubmit(Array.from(selectedLabels).join(', ')); + }; + + if (question.options.length > COMBOBOX_OPTION_THRESHOLD) { + const multiSelectOptions = question.options.map((option) => ({label: option.label, value: option.label})); + + return ( +
+ setSelectedLabels(new Set(values))} + options={multiSelectOptions} + value={Array.from(selectedLabels)} + /> + +
+
+
+ ); + } + + return ( +
+ {question.options.map((option) => ( + + ))} + +
+
+
+ ); +}; + +const AnsweredSummary = ({persistedAnswer}: {persistedAnswer?: string}) => { + if (!persistedAnswer) { + return ( +
+ + + + Picked: (answered) + +
+ ); + } + + // Multi-line summary means the wizard had multiple questions and we stashed the labeled + // "- Q → A" list as the persisted answer. Render as a block so each Q/A pair is on its own line. + const isMultiLine = persistedAnswer.includes('\n'); + + if (!isMultiLine) { + return ( +
+ + + + Picked: {persistedAnswer} + +
+ ); + } + + return ( +
+ + +
+ Picked: + +
{persistedAnswer}
+
+
+ ); +}; + +function buildAnswerSummary(questions: AskUserQuestionDataI['questions'], answers: Record): string { + return questions.map((question, index) => `- ${question.question} → ${answers[index] ?? ''}`).join('\n'); +} + +function fingerprintQuestions(questions: AskUserQuestionDataI['questions']): string { + return questions + .map((question) => `${question.question}::${question.options.map((option) => option.label).join(',')}`) + .join('||'); +} + +export default AskUserQuestionMessage; diff --git a/client/src/shared/components/ai-chat/messages/CreateConnectionMessage.tsx b/client/src/shared/components/ai-chat/messages/CreateConnectionMessage.tsx new file mode 100644 index 00000000000..5c94b46f7f1 --- /dev/null +++ b/client/src/shared/components/ai-chat/messages/CreateConnectionMessage.tsx @@ -0,0 +1,110 @@ +import Button from '@/components/Button/Button'; +import ConnectionDialog from '@/shared/components/connection/ConnectionDialog'; +import {Connection} from '@/shared/middleware/automation/configuration'; +import {useCreateConnectionMutation} from '@/shared/mutations/automation/connections.mutations'; +import {useGetComponentDefinitionsQuery} from '@/shared/queries/automation/componentDefinitions.queries'; +import {ConnectionKeys, useGetConnectionTagsQuery} from '@/shared/queries/automation/connections.queries'; +import {useGetComponentDefinitionQuery} from '@/shared/queries/platform/componentDefinitions.queries'; +import {useEnvironmentStore} from '@/shared/stores/useEnvironmentStore'; +import {DataMessagePartProps} from '@assistant-ui/react'; +import {useQueryClient} from '@tanstack/react-query'; +import {CheckIcon} from 'lucide-react'; +import {useState} from 'react'; + +export interface CreateConnectionDataI { + componentLabel: string; + componentName: string; + kind: 'create-connection'; + suggestedName?: string; +} + +/** + * Renders the LLM's createConnection tool result as a single "Connect <Component>" button that opens + * the ConnectionDialog. Companion to {@code SelectConnectionMessage} (the "pick an existing connection" + * intent): splitting the two intents across separate tools keeps each UI unambiguous — this one only + * creates, never offers a picker. + */ +const CreateConnectionMessage = ({data}: DataMessagePartProps) => { + const [createdConnection, setCreatedConnection] = useState<{id: number; name: string} | undefined>(); + const [dialogOpen, setDialogOpen] = useState(false); + + const currentEnvironmentId = useEnvironmentStore((state) => state.currentEnvironmentId); + + const connectionTagsQueryResult = useGetConnectionTagsQuery(); + + const queryClient = useQueryClient(); + + const { + data: componentDefinitions, + error: componentDefinitionsError, + isError: componentDefinitionsIsError, + isLoading: componentDefinitionsLoading, + refetch: refetchComponentDefinitions, + } = useGetComponentDefinitionsQuery({ + connectionDefinitions: true, + }); + + const {data: targetComponentDefinition} = useGetComponentDefinitionQuery( + {componentName: data.componentName, componentVersion: 1}, + Boolean(data.componentName) + ); + + const handleConnectionCreate = async (newConnectionId: number) => { + await queryClient.invalidateQueries({queryKey: ConnectionKeys.connections}); + + const fallbackName = data.suggestedName || `${data.componentLabel} connection`; + + setCreatedConnection({id: newConnectionId, name: fallbackName}); + }; + + if (componentDefinitionsLoading) { + return null; + } + + if (componentDefinitionsIsError || !componentDefinitions) { + return ( +
+ + Could not load component definitions + {componentDefinitionsError ? `: ${componentDefinitionsError.message}` : ''}. + + +
+ ); + } + + if (createdConnection) { + return ( +
+ + + + Connection ready: {createdConnection.name} + +
+ ); + } + + return ( +
+
+ ); +}; + +export default CreateConnectionMessage; diff --git a/client/src/shared/components/ai-chat/messages/RunErrorMessage.tsx b/client/src/shared/components/ai-chat/messages/RunErrorMessage.tsx new file mode 100644 index 00000000000..c02c701b66b --- /dev/null +++ b/client/src/shared/components/ai-chat/messages/RunErrorMessage.tsx @@ -0,0 +1,26 @@ +import {DataMessagePartProps} from '@assistant-ui/react'; +import {AlertCircleIcon} from 'lucide-react'; + +export interface RunErrorDataI { + message: string; +} + +/** + * Renders a RUN_ERROR inline in the assistant bubble with red foreground, a left border, and an alert icon, + * so the failure reads as a distinct system error rather than a normal assistant reply. + */ +const RunErrorMessage = ({data}: DataMessagePartProps) => { + return ( +
+ + +
{data.message}
+
+ ); +}; + +export default RunErrorMessage; diff --git a/client/src/shared/components/ai-chat/messages/SelectConnectionMessage.tsx b/client/src/shared/components/ai-chat/messages/SelectConnectionMessage.tsx new file mode 100644 index 00000000000..ccbb47f5cfc --- /dev/null +++ b/client/src/shared/components/ai-chat/messages/SelectConnectionMessage.tsx @@ -0,0 +1,129 @@ +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/Select/Select'; +import {useWorkspaceStore} from '@/pages/automation/stores/useWorkspaceStore'; +import EnvironmentBadge from '@/shared/components/EnvironmentBadge'; +import {useGetWorkspaceConnectionsQuery} from '@/shared/queries/automation/connections.queries'; +import {useGetConnectionDefinitionQuery} from '@/shared/queries/platform/connectionDefinitions.queries'; +import {DataMessagePartProps, useThreadRuntime} from '@assistant-ui/react'; +import {CheckIcon} from 'lucide-react'; +import {useEffect, useMemo, useState} from 'react'; + +export interface SelectConnectionDataI { + componentLabel: string; + componentName: string; + kind: 'select-connection'; +} + +/** + * Renders the LLM's selectConnection tool result as a dropdown of the workspace's existing connections for a + * component. Companion to {@code CreateConnectionMessage} (the "create new" intent). On pick, the choice is + * dispatched as a system message ("User picked: ") via the assistant-ui thread runtime so the agent's + * next turn reads it from chat memory. The dropdown dims once a later message lands on the thread. + */ +const SelectConnectionMessage = ({data}: DataMessagePartProps) => { + const [pickedConnection, setPickedConnection] = useState<{id: number; name: string} | undefined>(); + const [supersededByLaterMessage, setSupersededByLaterMessage] = useState(false); + + const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + + const threadRuntime = useThreadRuntime(); + + const {data: connectionDefinition} = useGetConnectionDefinitionQuery( + {componentName: data.componentName, componentVersion: 1}, + Boolean(data.componentName) + ); + + const {data: existingConnections} = useGetWorkspaceConnectionsQuery( + { + componentName: data.componentName, + connectionVersion: connectionDefinition?.version, + id: currentWorkspaceId!, + }, + Boolean(connectionDefinition?.version) && currentWorkspaceId != null + ); + + const connections = useMemo(() => existingConnections ?? [], [existingConnections]); + + useEffect(() => { + const initialMessageCount = threadRuntime.getState().messages.length; + + return threadRuntime.subscribe(() => { + const currentCount = threadRuntime.getState().messages.length; + + if (currentCount > initialMessageCount) { + setSupersededByLaterMessage(true); + } + }); + }, [threadRuntime]); + + const handleSelectChange = (value: string) => { + const connectionId = Number(value); + const connection = (existingConnections ?? []).find((candidate) => candidate.id === connectionId); + + if (!connection || connection.id == null) { + return; + } + + setPickedConnection({id: connection.id, name: connection.name}); + + threadRuntime.append({ + content: [{text: `User picked: ${connection.name} (ID: ${connection.id})`, type: 'text'}], + role: 'system', + }); + }; + + if (pickedConnection) { + return ( +
+ + + + Picked: {pickedConnection.name} + +
+ ); + } + + const isEmpty = (existingConnections?.length ?? 0) === 0; + + if (isEmpty) { + return ( +
+ No existing {data.componentLabel} connection in this workspace. Ask the assistant to create one. +
+ ); + } + + return ( +
+ +
+ ); +}; + +export default SelectConnectionMessage; diff --git a/client/src/shared/components/ai-chat/messages/SelectPropertyOptionMessage.tsx b/client/src/shared/components/ai-chat/messages/SelectPropertyOptionMessage.tsx new file mode 100644 index 00000000000..bc27100e269 --- /dev/null +++ b/client/src/shared/components/ai-chat/messages/SelectPropertyOptionMessage.tsx @@ -0,0 +1,101 @@ +import ComboBox from '@/components/ComboBox/ComboBox'; +import {DataMessagePartProps, useThreadRuntime} from '@assistant-ui/react'; +import {CheckIcon} from 'lucide-react'; +import {useEffect, useState} from 'react'; + +export interface SelectPropertyOptionItemI { + label: string; + value: string; +} + +export interface SelectPropertyOptionDataI { + componentName: string; + kind: 'select-property-option'; + options: SelectPropertyOptionItemI[]; + propertyName: string; + truncated?: boolean; +} + +/** + * Renders the selectPropertyOption / selectTriggerPropertyOption tool result as a searchable picker of all + * options the tool fetched from the connection (taken straight from the tool result, not re-emitted by the + * LLM). On pick, the option's real value is submitted as a system message "User picked: