diff --git a/workspaces/ballerina/ballerina-core/src/state-machine-types.ts b/workspaces/ballerina/ballerina-core/src/state-machine-types.ts index c4bc61cbe04..3366b973489 100644 --- a/workspaces/ballerina/ballerina-core/src/state-machine-types.ts +++ b/workspaces/ballerina/ballerina-core/src/state-machine-types.ts @@ -204,7 +204,8 @@ export type ChatNotify = | EvalsToolResult | UsageMetricsEvent | TaskApprovalRequest - | GeneratedSourcesEvent; + | GeneratedSourcesEvent + | ConnectorGenerationNotification; export interface ChatStart { type: "start"; @@ -295,6 +296,31 @@ export interface GeneratedSourcesEvent { fileArray: SourceFile[]; } +export interface ConnectorGenerationNotification { + type: "connector_generation_notification"; + requestId: string; + stage: "requesting_input" | "input_received" | "generating" | "generated" | "skipped" | "error"; + serviceName?: string; + serviceDescription?: string; + spec?: { + version: string; + title: string; + description?: string; + baseUrl?: string; + endpointCount: number; + methods: string[]; + }; + connector?: { + moduleName: string; + importStatement: string; + }; + error?: { + message: string; + code: string; + }; + message: string; +} + export const stateChanged: NotificationType = { method: 'stateChanged' }; export const onDownloadProgress: NotificationType = { method: 'onDownloadProgress' }; export const onChatNotify: NotificationType = { method: 'onChatNotify' }; @@ -374,6 +400,7 @@ export type AIChatMachineStateValue = | 'TaskReview' | 'ApprovedTask' | 'RejectedTask' + | 'WaitingForConnectorSpec' | 'Completed' | 'PartiallyCompleted' | 'Error'; @@ -396,6 +423,9 @@ export enum AIChatMachineEventType { RESTORE_STATE = 'RESTORE_STATE', ERROR = 'ERROR', RETRY = 'RETRY', + CONNECTOR_GENERATION_REQUESTED = 'CONNECTOR_GENERATION_REQUESTED', + PROVIDE_CONNECTOR_SPEC = 'PROVIDE_CONNECTOR_SPEC', + SKIP_CONNECTOR_GENERATION = 'SKIP_CONNECTOR_GENERATION', } export interface ChatMessage { @@ -459,6 +489,14 @@ export interface AIChatMachineContext { projectId?: string; currentApproval?: UserApproval; autoApproveEnabled?: boolean; + currentSpec?: { + requestId: string; + spec?: any; + provided?: boolean; + skipped?: boolean; + comment?: string; + }; + previousState?: AIChatMachineStateValue; } export type AIChatMachineSendableEvent = @@ -478,7 +516,10 @@ export type AIChatMachineSendableEvent = | { type: AIChatMachineEventType.RESET } | { type: AIChatMachineEventType.RESTORE_STATE; payload: { state: AIChatMachineContext } } | { type: AIChatMachineEventType.ERROR; payload: { message: string } } - | { type: AIChatMachineEventType.RETRY }; + | { type: AIChatMachineEventType.RETRY } + | { type: AIChatMachineEventType.CONNECTOR_GENERATION_REQUESTED; payload: { requestId: string; serviceName?: string; serviceDescription?: string; fromState?: AIChatMachineStateValue } } + | { type: AIChatMachineEventType.PROVIDE_CONNECTOR_SPEC; payload: { requestId: string; spec: any; inputMethod: 'file' | 'paste' | 'url'; sourceIdentifier?: string } } + | { type: AIChatMachineEventType.SKIP_CONNECTOR_GENERATION; payload: { requestId: string; comment?: string } }; export enum LoginMethod { BI_INTEL = 'biIntel', @@ -546,3 +587,14 @@ export const aiChatStateChanged: NotificationType = { m export const sendAIChatStateEvent: RequestType = { method: 'sendAIChatStateEvent' }; export const getAIChatContext: RequestType = { method: 'getAIChatContext' }; export const getAIChatUIHistory: RequestType = { method: 'getAIChatUIHistory' }; + +// Connector Generator RPC methods +export interface ConnectorGeneratorResponsePayload { + requestId: string; + action: 'provide' | 'skip'; + spec?: any; + inputMethod?: 'file' | 'paste' | 'url'; + sourceIdentifier?: string; + comment?: string; +} +export const sendConnectorGeneratorResponse: RequestType = { method: 'sendConnectorGeneratorResponse' }; diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/design.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/design.ts index 384350699a7..802f1436c4b 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/design.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/design.ts @@ -28,9 +28,9 @@ import { getLibraryProviderTool } from "../libs/libraryProviderTool"; import { GenerationType, getAllLibraries, LIBRARY_PROVIDER_TOOL } from "../libs/libs"; import { Library } from "../libs/libs_types"; import { AIChatStateMachine } from "../../../../views/ai-panel/aiChatMachine"; -import { getTempProject, FileModificationInfo } from "../../utils/temp-project-utils"; -import { formatCodebaseStructure } from "./utils"; +import { getTempProject } from "../../utils/temp-project-utils"; import { getSystemPrompt, getUserPrompt } from "./prompts"; +import { createConnectorGeneratorTool, CONNECTOR_GENERATOR_TOOL } from "../libs/connectorGeneratorTool"; import { LangfuseExporter } from 'langfuse-vercel'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; @@ -61,7 +61,7 @@ export async function generateDesignCore(params: GenerateAgentCodeRequest, event const modifiedFiles: string[] = []; - const userMessageContent = getUserPrompt(params.usecase, hasHistory, tempProjectPath); + const userMessageContent = getUserPrompt(params.usecase, hasHistory, tempProjectPath, project.projectName); const allMessages: ModelMessage[] = [ { role: "system", @@ -84,6 +84,7 @@ export async function generateDesignCore(params: GenerateAgentCodeRequest, event const tools = { [TASK_WRITE_TOOL_NAME]: createTaskWriteTool(eventHandler, tempProjectPath, modifiedFiles), [LIBRARY_PROVIDER_TOOL]: getLibraryProviderTool(libraryDescriptions, GenerationType.CODE_GENERATION), + [CONNECTOR_GENERATOR_TOOL]: createConnectorGeneratorTool(eventHandler, tempProjectPath, project.projectName, modifiedFiles), [FILE_WRITE_TOOL_NAME]: createWriteTool(createWriteExecute(tempProjectPath, modifiedFiles)), [FILE_SINGLE_EDIT_TOOL_NAME]: createEditTool(createEditExecute(tempProjectPath, modifiedFiles)), [FILE_BATCH_EDIT_TOOL_NAME]: createBatchEditTool(createMultiEditExecute(tempProjectPath, modifiedFiles)), @@ -128,7 +129,14 @@ export async function generateDesignCore(params: GenerateAgentCodeRequest, event if (toolName === "LibraryProviderTool") { selectedLibraries = (part.input as any)?.libraryNames || []; - } else if ([FILE_WRITE_TOOL_NAME, FILE_SINGLE_EDIT_TOOL_NAME, FILE_BATCH_EDIT_TOOL_NAME, FILE_READ_TOOL_NAME].includes(toolName)) { + } else if ( + [ + FILE_WRITE_TOOL_NAME, + FILE_SINGLE_EDIT_TOOL_NAME, + FILE_BATCH_EDIT_TOOL_NAME, + FILE_READ_TOOL_NAME, + ].includes(toolName) + ) { const input = part.input as any; if (input && input.file_path) { let fileName = input.file_path; @@ -157,8 +165,14 @@ export async function generateDesignCore(params: GenerateAgentCodeRequest, event } else if (toolName === "LibraryProviderTool") { const libraryNames = (part.output as Library[]).map((lib) => lib.name); const fetchedLibraries = libraryNames.filter((name) => selectedLibraries.includes(name)); - } - else if ([FILE_WRITE_TOOL_NAME, FILE_SINGLE_EDIT_TOOL_NAME, FILE_BATCH_EDIT_TOOL_NAME, FILE_READ_TOOL_NAME].includes(toolName)) { + } else if ( + [ + FILE_WRITE_TOOL_NAME, + FILE_SINGLE_EDIT_TOOL_NAME, + FILE_BATCH_EDIT_TOOL_NAME, + FILE_READ_TOOL_NAME, + ].includes(toolName) + ) { } else { eventHandler({ type: "tool_result", toolName }); } @@ -170,6 +184,10 @@ export async function generateDesignCore(params: GenerateAgentCodeRequest, event eventHandler({ type: "error", content: getErrorMessage(error) }); break; } + case "text-start": { + eventHandler({ type: "content_block", content: " \n" }); + break; + } case "abort": { console.log("[Design] Aborted by user."); let messagesToSave: any[] = []; @@ -221,7 +239,7 @@ Generation stopped by user. The last in-progress task was not saved. Files have await langfuseExporter.forceFlush(); break; } - } + } } } @@ -308,37 +326,3 @@ function saveToolResult( }] }); } - -/** - * Formats file modifications into XML structure for Claude - * TODO: This function is currently not used. Can be removed if workspace modification - * tracking is not needed in the future. - */ -function formatModifications(modifications: FileModificationInfo[]): string { - if (modifications.length === 0) { - return ''; - } - - const modifiedFiles = modifications.filter(m => m.type === 'modified').map(m => m.filePath); - const newFiles = modifications.filter(m => m.type === 'new').map(m => m.filePath); - const deletedFiles = modifications.filter(m => m.type === 'deleted').map(m => m.filePath); - - let text = '\n'; - text += 'The following changes were detected in the workspace since the last session. '; - text += 'You do not need to acknowledge or repeat these changes in your response. '; - text += 'This information is provided for your awareness only.\n\n'; - - if (modifiedFiles.length > 0) { - text += '\n' + modifiedFiles.join('\n') + '\n\n\n'; - } - if (newFiles.length > 0) { - text += '\n' + newFiles.join('\n') + '\n\n\n'; - } - if (deletedFiles.length > 0) { - text += '\n' + deletedFiles.join('\n') + '\n\n\n'; - } - - text += ''; - return text; -} - diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/prompts.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/prompts.ts index 97c850cff5a..e5db820a2cb 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/prompts.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/prompts.ts @@ -2,6 +2,7 @@ import { DIAGNOSTICS_TOOL_NAME } from "../libs/diagnostics_tool"; import { LIBRARY_PROVIDER_TOOL } from "../libs/libs"; import { TASK_WRITE_TOOL_NAME } from "../libs/task_write_tool"; import { FILE_BATCH_EDIT_TOOL_NAME, FILE_SINGLE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME } from "../libs/text_editor_tool"; +import { CONNECTOR_GENERATOR_TOOL } from "../libs/connectorGeneratorTool"; import { formatCodebaseStructure } from "./utils"; /** @@ -64,8 +65,11 @@ This plan will be visible to the user and the execution will be guided on the ta 5. Once plan is APPROVED (success: true in tool response), IMMEDIATELY start the execution cycle: **For each task:** - - Mark task as in_progress using ${TASK_WRITE_TOOL_NAME} (send ALL tasks) + - Mark task as in_progress using ${TASK_WRITE_TOOL_NAME} and immediately start implementation in parallel (single message with multiple tool calls) - Implement the task completely (write the Ballerina code) + - When implementing external API integrations: + - First check ${LIBRARY_PROVIDER_TOOL} for known services (Stripe, GitHub, etc.) + - If NOT available, call ${CONNECTOR_GENERATOR_TOOL} to generate connector from OpenAPI spec - Before marking the task as completed, use the ${DIAGNOSTICS_TOOL_NAME} tool to check for compilation errors and fix them. Introduce a a new subtask if needed to fix errors. - Mark task as completed using ${TASK_WRITE_TOOL_NAME} (send ALL tasks) - The tool will wait for TASK COMPLETION APPROVAL from the user @@ -94,6 +98,8 @@ When generating Ballerina code strictly follow these syntax and structure guidel - In the library API documentation, if the service type is specified as generic, adhere to the instructions specified there on writing the service. - For GraphQL service related queries, if the user hasn't specified their own GraphQL Schema, write the proposed GraphQL schema for the user query right after the explanation before generating the Ballerina code. Use the same names as the GraphQL Schema when defining record types. +### Local Connectors +- If the codebase structure shows connector modules in generated/moduleName, import using: import packageName.moduleName ### Code Structure - Define required configurables for the query. Use only string, int, decimal, boolean types in configurable variables. @@ -117,7 +123,13 @@ When generating Ballerina code strictly follow these syntax and structure guidel - To narrow down a union type(or optional type), always declare a separate variable and then use that variable in the if condition. ### File modifications -- You must apply changes to the existing source code using the provided ${[FILE_BATCH_EDIT_TOOL_NAME, FILE_SINGLE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME].join(", ")} tools. The complete existing source code will be provided in the section of the user prompt. +- You must apply changes to the existing source code using the provided ${[ + FILE_BATCH_EDIT_TOOL_NAME, + FILE_SINGLE_EDIT_TOOL_NAME, + FILE_WRITE_TOOL_NAME, + ].join( + ", " + )} tools. The complete existing source code will be provided in the section of the user prompt. - When making replacements inside an existing file, provide the **exact old string** and the **exact new string** with all newlines, spaces, and indentation, being mindful to replace nearby occurrences together to minimize the number of tool calls. - Do not modify documentation such as .md files unless explicitly asked to be modified in the query. - Do not add/modify toml files (Config.toml/Ballerina.toml/Dependencies.toml). @@ -130,14 +142,15 @@ When generating Ballerina code strictly follow these syntax and structure guidel * @param usecase User's query/requirement * @param hasHistory Whether chat history exists * @param tempProjectPath Path to temp project (used when hasHistory is false) + * @param packageName Name of the Ballerina package */ -export function getUserPrompt(usecase: string, hasHistory: boolean, tempProjectPath: string) { +export function getUserPrompt(usecase: string, hasHistory: boolean, tempProjectPath: string, packageName: string) { const content = []; if (!hasHistory) { content.push({ type: 'text' as const, - text: formatCodebaseStructure(tempProjectPath) + text: formatCodebaseStructure(tempProjectPath, packageName) }); } diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/utils.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/utils.ts index 339f6089d13..aabb6521771 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/utils.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/design/utils.ts @@ -19,6 +19,8 @@ import { workspace } from "vscode"; import { addToIntegration } from "../../../../rpc-managers/ai-panel/utils"; import * as fs from 'fs'; import * as path from 'path'; +import * as vscode from 'vscode'; +import type { TextEdit } from 'vscode-languageserver-protocol'; /** * File extensions to include in codebase structure @@ -44,6 +46,29 @@ const CODEBASE_STRUCTURE_IGNORE_FILES = [ 'Dependencies.toml' ]; +/** + * Files that require path sanitization (temp paths replaced with workspace paths) + */ +const FILES_REQUIRING_PATH_SANITIZATION = [ + 'Ballerina.toml' +]; + +/** + * Sanitizes temp directory paths in file content by replacing them with workspace paths + * @param content File content that may contain temp directory paths + * @param tempPath Temporary project path to be replaced + * @param workspacePath Workspace path to replace with + * @returns Sanitized content with workspace paths + */ +function sanitizeTempPaths(content: string, tempPath: string, workspacePath: string): string { + // Normalize paths to forward slashes for consistent replacement + const normalizedTempPath = tempPath.replace(/\\/g, '/'); + const normalizedWorkspacePath = workspacePath.replace(/\\/g, '/'); + + // Replace all occurrences of temp path with workspace path + return content.replace(new RegExp(normalizedTempPath, 'g'), normalizedWorkspacePath); +} + /** * Integrates code from temp directory to workspace * @param tempProjectPath Path to the temporary project directory @@ -74,7 +99,15 @@ export async function integrateCodeToWorkspace(tempProjectPath: string, modified const fullPath = path.join(tempProjectPath, relativePath); if (fs.existsSync(fullPath)) { - const content = fs.readFileSync(fullPath, 'utf-8'); + let content = fs.readFileSync(fullPath, 'utf-8'); + + // Check if this file requires path sanitization + const fileName = path.basename(relativePath); + if (FILES_REQUIRING_PATH_SANITIZATION.includes(fileName)) { + content = sanitizeTempPaths(content, tempProjectPath, workspaceFolderPath); + console.log(`[Design Integration] Sanitized temp paths in: ${relativePath}`); + } + fileChanges.push({ filePath: relativePath, content: content @@ -125,9 +158,10 @@ ${sourceFile.content} * Formats complete codebase structure into XML for Claude * Used when starting a new session without history * @param tempProjectPath Path to the temporary project directory + * @param packageName Name of the Ballerina package * @returns Formatted XML string with codebase structure */ -export function formatCodebaseStructure(tempProjectPath: string): string { +export function formatCodebaseStructure(tempProjectPath: string, packageName: string): string { const allFiles: string[] = []; function collectFiles(dir: string, basePath: string = '') { @@ -157,7 +191,7 @@ export function formatCodebaseStructure(tempProjectPath: string): string { collectFiles(tempProjectPath); let text = '\n'; - text += 'This is the complete structure of the codebase you are working with. '; + text += `This is the complete structure of the codebase you are working with (Package: ${packageName}). `; text += 'You do not need to acknowledge or list these files in your response. '; text += 'This information is provided for your awareness only.\n\n'; text += '\n' + allFiles.join('\n') + '\n\n'; @@ -165,3 +199,39 @@ export function formatCodebaseStructure(tempProjectPath: string): string { return text; } + +/** + * Applies LSP text edits to create or modify a file + * @param filePath Absolute path to the file + * @param textEdits Array of LSP TextEdit objects + */ +export async function applyTextEdits(filePath: string, textEdits: TextEdit[]): Promise { + const workspaceEdit = new vscode.WorkspaceEdit(); + const fileUri = vscode.Uri.file(filePath); + const dirPath = path.dirname(filePath); + const dirUri = vscode.Uri.file(dirPath); + + try { + await vscode.workspace.fs.createDirectory(dirUri); + workspaceEdit.createFile(fileUri, { ignoreIfExists: true }); + } catch (error) { + console.error(`[applyTextEdits] Error creating file or directory:`, error); + } + + for (const edit of textEdits) { + const range = new vscode.Range( + edit.range.start.line, + edit.range.start.character, + edit.range.end.line, + edit.range.end.character + ); + workspaceEdit.replace(fileUri, range, edit.newText); + } + + try { + await vscode.workspace.applyEdit(workspaceEdit); + } catch (error) { + console.error(`[applyTextEdits] Error applying edits to ${filePath}:`, error); + throw error; + } +} diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/event.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/event.ts index bc6bf239874..9cbe374a70c 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/event.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/event.ts @@ -15,7 +15,7 @@ // under the License. import { ChatNotify, Command } from "@wso2/ballerina-core"; -import { sendContentAppendNotification, sendContentReplaceNotification, sendDiagnosticMessageNotification, sendErrorNotification, sendMessagesNotification, sendMessageStartNotification, sendMessageStopNotification, sendIntermidateStateNotification, sendToolCallNotification, sendToolResultNotification, sendTaskApprovalRequestNotification, sendAbortNotification, sendSaveChatNotification, sendGeneratedSourcesNotification } from "./utils"; +import { sendContentAppendNotification, sendContentReplaceNotification, sendDiagnosticMessageNotification, sendErrorNotification, sendMessagesNotification, sendMessageStartNotification, sendMessageStopNotification, sendIntermidateStateNotification, sendToolCallNotification, sendToolResultNotification, sendTaskApprovalRequestNotification, sendAbortNotification, sendSaveChatNotification, sendGeneratedSourcesNotification, sendConnectorGenerationNotification } from "./utils"; export type CopilotEventHandler = (event: ChatNotify) => void; @@ -75,6 +75,9 @@ export function createWebviewEventHandler(command: Command): CopilotEventHandler case "generated_sources": sendGeneratedSourcesNotification(event.fileArray); break; + case "connector_generation_notification": + sendConnectorGenerationNotification(event); + break; default: console.warn(`Unhandled event type: ${event}`); break; diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/libs/connectorGeneratorTool.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/libs/connectorGeneratorTool.ts new file mode 100644 index 00000000000..d352539aa48 --- /dev/null +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/libs/connectorGeneratorTool.ts @@ -0,0 +1,561 @@ +// Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com/) All Rights Reserved. + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { tool } from "ai"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as yaml from "js-yaml"; +import { z } from "zod"; +import { + SpecFetcherInput, + SpecFetcherResult, + ParsedSpec, + ParsedService, + ParsedEndpoint, + ParsedSchema, + HttpMethod, +} from "./openapi-spec-types"; +import { CopilotEventHandler } from "../event"; +import { AIChatMachineEventType } from "@wso2/ballerina-core"; +import { AIChatStateMachine } from "../../../../views/ai-panel/aiChatMachine"; +import { langClient } from "../../activator"; +import { applyTextEdits } from "../design/utils"; + +export const CONNECTOR_GENERATOR_TOOL = "ConnectorGeneratorTool"; + +const SpecFetcherInputSchema = z.object({ + serviceName: z.string().describe("Name of the service/API that needs specification"), + serviceDescription: z.string().optional().describe("Optional description of what the service is for"), +}); + +export function createConnectorGeneratorTool(eventHandler?: CopilotEventHandler, tempProjectPath?: string, projectName?: string, modifiedFiles?: string[]) { + return tool({ + description: `Generates Ballerina connector modules from OpenAPI specifications for services not available in LibraryProviderTool. + +Use this tool when: +- You need a connector for an internal/custom API or third-party service without Ballerina support +- LibraryProviderTool does not have the service you need +- You are implementing HTTP client integrations during service_design or connections_init tasks + +The tool will: +1. Request OpenAPI spec from user (supports JSON and YAML formats) +2. Generate complete Ballerina connector module with client class, typed methods, record types, and authentication +3. Save the spec to resources/specs/ directory +4. Generate connector files in generated/moduleName submodule + +Returns complete connector information (DO NOT read files, use the returned content directly): +- moduleName: Name of the generated submodule +- importStatement: Import statement to use in your code (e.g., "import project.moduleName") +- generatedFiles: Array with path and COMPLETE CONTENT of each generated .bal file + * Each file object contains: { path: "relative/path/to/file.bal", content: "full file content" } + * The content field contains the entire generated code - use it directly without reading files + +# Example +**Query**: Integrate with Acme Corp's custom REST API for order management. +**Tool Call**: Call with serviceName: "Acme Corp API", serviceDescription: "Order management REST API" +**Result**: Returns importStatement and generatedFiles with complete content → Use importStatement in your code`, + inputSchema: SpecFetcherInputSchema, + execute: async (input: SpecFetcherInput): Promise => { + return await ConnectorGeneratorTool(input, eventHandler, tempProjectPath, projectName, modifiedFiles); + }, + }); +} + +export async function ConnectorGeneratorTool( + input: SpecFetcherInput, + eventHandler?: CopilotEventHandler, + tempProjectPath?: string, + projectName?: string, + modifiedFiles?: string[] +): Promise { + try { + if (!eventHandler) { + return createErrorResult( + "INVALID_INPUT", + "Event handler is required for spec fetcher tool", + input.serviceName + ); + } + + if (!tempProjectPath) { + return createErrorResult( + "INVALID_INPUT", + "tempProjectPath is required for ConnectorGeneratorTool", + input.serviceName + ); + } + + const requestId = crypto.randomUUID(); + const userInput = await requestSpecFromUser(requestId, input, eventHandler); + + if (!userInput.provided) { + return handleUserSkip(requestId, input.serviceName, userInput.comment, eventHandler); + } + + const { rawSpec, parsedSpec, originalContent, format } = parseAndValidateSpec(userInput.spec); + + const { specFilePath, sanitizedServiceName } = await saveSpecToWorkspace( + originalContent, + format, + rawSpec, + input.serviceName, + tempProjectPath, + modifiedFiles + ); + + sendGeneratingNotification(requestId, input.serviceName, parsedSpec, eventHandler); + + const { moduleName, importStatement, generatedFiles } = await generateConnector( + specFilePath, + tempProjectPath, + sanitizedServiceName, + projectName, + modifiedFiles + ); + + return handleSuccess( + requestId, + input.serviceName, + parsedSpec, + moduleName, + importStatement, + generatedFiles, + eventHandler + ); + } catch (error: any) { + return handleError(error, input.serviceName, eventHandler); + } +} + +async function requestSpecFromUser( + requestId: string, + input: SpecFetcherInput, + eventHandler: CopilotEventHandler +): Promise<{ provided: boolean; spec?: any; comment?: string }> { + eventHandler({ + type: "connector_generation_notification", + requestId, + stage: "requesting_input", + serviceName: input.serviceName, + serviceDescription: input.serviceDescription, + message: `Please provide OpenAPI specification for ${input.serviceName}${ + input.serviceDescription ? ` (${input.serviceDescription})` : "" + }`, + }); + + const currentState = AIChatStateMachine.service().getSnapshot().value; + AIChatStateMachine.sendEvent({ + type: AIChatMachineEventType.CONNECTOR_GENERATION_REQUESTED, + payload: { + requestId, + serviceName: input.serviceName, + serviceDescription: input.serviceDescription, + fromState: currentState as any, + }, + }); + + return waitForUserResponse(requestId); +} + +function handleUserSkip( + requestId: string, + serviceName: string, + comment: string | undefined, + eventHandler: CopilotEventHandler +): SpecFetcherResult { + eventHandler({ + type: "connector_generation_notification", + requestId, + stage: "skipped", + serviceName, + message: `Skipped providing spec for ${serviceName}${comment ? ": " + comment : ""}`, + }); + + return { + success: false, + message: `User skipped providing OpenAPI specification for ${serviceName}. Proceed without generating connector or ask user to provide the spec later.`, + error: `User skipped providing spec for ${serviceName}${comment ? ": " + comment : ""}`, + errorCode: "USER_SKIPPED", + details: "User chose not to provide the OpenAPI specification", + }; +} + +function parseAndValidateSpec( + spec: any +): { rawSpec: any; parsedSpec: ParsedSpec; originalContent: string; format: "json" | "yaml" } { + const specContent = typeof spec === "string" ? spec : JSON.stringify(spec); + const { spec: rawSpec, format } = parseSpec(specContent); + const parsedSpec = parseOpenApiSpec(rawSpec); + return { rawSpec, parsedSpec, originalContent: specContent, format }; +} + +async function saveSpecToWorkspace( + originalContent: string, + format: "json" | "yaml", + rawSpec: any, + serviceName: string, + tempProjectPath: string, + modifiedFiles?: string[] +): Promise<{ specFilePath: string; sanitizedServiceName: string }> { + const sanitizedServiceName = serviceName.toLowerCase().replace(/[^a-z0-9]+/g, "_"); + const specsDir = path.join(tempProjectPath, "resources", "specs"); + const fileExtension = format === "yaml" ? "yaml" : "json"; + const specFilePath = path.join(specsDir, `${sanitizedServiceName}.${fileExtension}`); + + if (!fs.existsSync(specsDir)) { + fs.mkdirSync(specsDir, { recursive: true }); + } + + const contentToSave = format === "yaml" ? originalContent : JSON.stringify(rawSpec, null, 2); + fs.writeFileSync(specFilePath, contentToSave, "utf-8"); + + if (modifiedFiles) { + const relativeSpecPath = path.relative(tempProjectPath, specFilePath); + modifiedFiles.push(relativeSpecPath); + } + + return { specFilePath, sanitizedServiceName }; +} + +function sendGeneratingNotification( + requestId: string, + serviceName: string, + parsedSpec: ParsedSpec, + eventHandler: CopilotEventHandler +): void { + eventHandler({ + type: "connector_generation_notification", + requestId, + stage: "generating", + serviceName, + spec: { + version: parsedSpec.version, + title: parsedSpec.title, + description: parsedSpec.description, + baseUrl: parsedSpec.baseUrl, + endpointCount: parsedSpec.endpointCount, + methods: parsedSpec.methods, + }, + message: `Generating connector for "${parsedSpec.title}"...`, + }); +} + +async function generateConnector( + specFilePath: string, + tempProjectPath: string, + moduleName: string, + projectName?: string, + modifiedFiles?: string[] +): Promise<{ moduleName: string; importStatement: string; generatedFiles: Array<{ path: string; content: string }> }> { + const importStatement = `import ${projectName || "project"}.${moduleName}`; + const generatedFiles: Array<{ path: string; content: string }> = []; + + const response = await langClient.openApiGenerateClient({ + openApiContractPath: specFilePath, + projectPath: tempProjectPath, + module: moduleName, + }); + + if (!response.source || !response.source.textEditsMap) { + throw new Error("LS API returned empty textEditsMap"); + } + + const textEditsMap = new Map(Object.entries(response.source.textEditsMap)); + + for (const [filePath, edits] of textEditsMap.entries()) { + await applyTextEdits(filePath, edits); + + const relativePath = path.relative(tempProjectPath, filePath); + + // Add .bal files to generatedFiles for agent visibility + if (filePath.endsWith(".bal") && edits.length > 0) { + generatedFiles.push({ + path: relativePath, + content: edits[0].newText, + }); + } + + // Track all generated files (including Ballerina.toml) for integration + if (modifiedFiles) { + modifiedFiles.push(relativePath); + } + } + + return { moduleName, importStatement, generatedFiles }; +} + +function handleSuccess( + requestId: string, + serviceName: string, + parsedSpec: ParsedSpec, + moduleName: string, + importStatement: string, + generatedFiles: Array<{ path: string; content: string }>, + eventHandler: CopilotEventHandler +): SpecFetcherResult { + eventHandler({ + type: "connector_generation_notification", + requestId, + stage: "generated", + serviceName, + spec: { + version: parsedSpec.version, + title: parsedSpec.title, + description: parsedSpec.description, + baseUrl: parsedSpec.baseUrl, + endpointCount: parsedSpec.endpointCount, + methods: parsedSpec.methods, + }, + connector: { + moduleName, + importStatement, + }, + message: `Generated connector module "${moduleName}" for "${parsedSpec.title}"`, + }); + + return { + success: true, + message: `Connector successfully generated for "${parsedSpec.title}". Use the import statement: ${importStatement}`, + connector: { + moduleName, + importStatement, + generatedFiles, + }, + }; +} + +function handleError(error: any, serviceName: string, eventHandler?: CopilotEventHandler): SpecFetcherResult { + const errorMessage = error.message || "Unknown error"; + let errorCode: "USER_SKIPPED" | "INVALID_SPEC" | "PARSE_ERROR" | "UNSUPPORTED_VERSION" | "INVALID_INPUT"; + + if (errorMessage.includes("Unsupported OpenAPI version")) { + errorCode = "UNSUPPORTED_VERSION"; + } else if (errorMessage.includes("JSON") || errorMessage.includes("YAML")) { + errorCode = "PARSE_ERROR"; + } else if (errorMessage.includes("required")) { + errorCode = "INVALID_INPUT"; + } else { + errorCode = "INVALID_SPEC"; + } + + if (eventHandler) { + eventHandler({ + type: "connector_generation_notification", + requestId: crypto.randomUUID(), + stage: "error", + serviceName, + error: { + message: errorMessage, + code: errorCode, + }, + message: `Failed to parse spec for ${serviceName}: ${errorMessage}`, + }); + } + + return createErrorResult(errorCode, errorMessage, serviceName, error.stack); +} + +function waitForUserResponse(requestId: string): Promise<{ provided: boolean; spec?: any; comment?: string }> { + return new Promise((resolve) => { + const subscription = AIChatStateMachine.service().subscribe((state) => { + if (state.value !== "WaitingForConnectorSpec" && state.context.currentSpec?.requestId === requestId) { + const provided = state.context.currentSpec.provided === true; + const spec = state.context.currentSpec.spec; + const comment = state.context.currentSpec.comment; + + subscription.unsubscribe(); + resolve(provided && spec ? { provided: true, spec } : { provided: false, comment }); + } + }); + }); +} + +function createErrorResult( + errorCode: "USER_SKIPPED" | "INVALID_SPEC" | "PARSE_ERROR" | "UNSUPPORTED_VERSION" | "INVALID_INPUT", + errorMessage: string, + serviceName: string, + details?: string +): SpecFetcherResult { + const errorDescriptions = { + PARSE_ERROR: "The spec format is invalid.", + UNSUPPORTED_VERSION: "The OpenAPI version is not supported.", + INVALID_INPUT: "Invalid input provided.", + INVALID_SPEC: "The spec is invalid or malformed.", + USER_SKIPPED: "User skipped providing specification.", + }; + + return { + success: false, + message: `Failed to process OpenAPI specification for ${serviceName}. ${errorDescriptions[errorCode]} You may need to manually implement the integration or ask the user for a valid spec.`, + error: errorMessage, + errorCode, + details, + }; +} + +function parseSpec(content: string): { spec: any; format: "json" | "yaml" } { + try { + const result = JSON.parse(content); + return { spec: result, format: "json" }; + } catch (jsonError: any) { + try { + const result = yaml.load(content) as any; + return { spec: result, format: "yaml" }; + } catch (yamlError: any) { + throw new Error("Invalid spec format. Both JSON and YAML parsing failed."); + } + } +} + +function parseOpenApiSpec(spec: any): ParsedSpec { + const version = spec.openapi || spec.swagger || "unknown"; + + if (!version.startsWith("3.") && !version.startsWith("2.")) { + throw new Error(`Unsupported OpenAPI version: ${version}`); + } + + const info = spec.info || {}; + const title = info.title || "Untitled API"; + const description = info.description; + + const baseUrl = extractBaseUrl(spec); + const services = extractServices(spec); + const endpointCount = services.reduce((sum, service) => sum + service.endpoints.length, 0); + const methods = extractMethods(spec); + const schemas = extractSchemas(spec); + const securitySchemes = extractSecuritySchemes(spec); + + return { + version, + title, + description, + baseUrl, + endpointCount, + methods, + services, + schemas, + securitySchemes, + }; +} + +function extractBaseUrl(spec: any): string | undefined { + if (spec.servers && spec.servers.length > 0) { + return spec.servers[0].url; + } + if (spec.host) { + const scheme = spec.schemes?.[0] || "https"; + const basePath = spec.basePath || ""; + return `${scheme}://${spec.host}${basePath}`; + } + return undefined; +} + +function extractMethods(spec: any): HttpMethod[] { + const methods = new Set(); + const paths = spec.paths || {}; + + for (const path in paths) { + for (const method of Object.keys(paths[path])) { + const upperMethod = method.toUpperCase(); + if (["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(upperMethod)) { + methods.add(upperMethod as HttpMethod); + } + } + } + + return Array.from(methods); +} + +function extractServices(spec: any): ParsedService[] { + const paths = spec.paths || {}; + const serviceMap = new Map(); + + for (const path in paths) { + const pathItem = paths[path]; + + for (const method of Object.keys(pathItem)) { + const upperMethod = method.toUpperCase(); + if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(upperMethod)) { + continue; + } + + const operation = pathItem[method]; + const tags = operation.tags || ["default"]; + + const endpoint: ParsedEndpoint = { + path, + method: upperMethod as HttpMethod, + operationId: operation.operationId, + summary: operation.summary, + parameters: operation.parameters?.map((p: any) => p.name) || [], + requestContentTypes: operation.requestBody?.content + ? Object.keys(operation.requestBody.content) + : undefined, + responseContentTypes: operation.responses?.["200"]?.content + ? Object.keys(operation.responses["200"].content) + : undefined, + responseType: operation.responses?.["200"]?.description, + }; + + for (const tag of tags) { + if (!serviceMap.has(tag)) { + serviceMap.set(tag, []); + } + serviceMap.get(tag)!.push(endpoint); + } + } + } + + const services: ParsedService[] = []; + for (const [name, endpoints] of serviceMap.entries()) { + const tagInfo = spec.tags?.find((t: any) => t.name === name); + services.push({ + name, + description: tagInfo?.description, + endpoints, + }); + } + + return services; +} + +function extractSchemas(spec: any): ParsedSchema[] { + const schemas: ParsedSchema[] = []; + const components = spec.components || spec.definitions || {}; + const schemaObjects = components.schemas || components; + + for (const schemaName in schemaObjects) { + const schema = schemaObjects[schemaName]; + schemas.push({ + name: schemaName, + type: schema.type || "object", + properties: schema.properties ? Object.keys(schema.properties) : undefined, + }); + } + + return schemas; +} + +function extractSecuritySchemes(spec: any): string[] | undefined { + if (spec.components?.securitySchemes) { + return Object.keys(spec.components.securitySchemes); + } + if (spec.securityDefinitions) { + return Object.keys(spec.securityDefinitions); + } + return undefined; +} diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/libs/openapi-spec-types.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/libs/openapi-spec-types.ts new file mode 100644 index 00000000000..4bc7750d361 --- /dev/null +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/libs/openapi-spec-types.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com/) All Rights Reserved. + +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export interface SpecFetcherInput { + serviceName: string; + serviceDescription?: string; +} + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + +export interface ParsedEndpoint { + path: string; + method: HttpMethod; + operationId?: string; + summary?: string; + parameters?: string[]; + requestContentTypes?: string[]; + responseContentTypes?: string[]; + responseType?: string; +} + +export interface ParsedService { + name: string; + description?: string; + endpoints: ParsedEndpoint[]; +} + +export interface ParsedSchema { + name: string; + type: string; + properties?: string[]; +} + +export interface ParsedSpec { + version: string; + title: string; + description?: string; + baseUrl?: string; + endpointCount: number; + methods: HttpMethod[]; + services: ParsedService[]; + schemas?: ParsedSchema[]; + securitySchemes?: string[]; +} + +export interface SpecFetcherSuccess { + success: true; + message?: string; + spec?: ParsedSpec; + connector?: { + moduleName: string; + importStatement: string; + generatedFiles: Array<{ + path: string; + content: string; + }>; + }; +} + +export interface SpecFetcherError { + success: false; + message?: string; + error: string; + errorCode: "USER_SKIPPED" | "INVALID_SPEC" | "PARSE_ERROR" | "UNSUPPORTED_VERSION" | "INVALID_INPUT"; + details?: string; +} + +export type SpecFetcherResult = SpecFetcherSuccess | SpecFetcherError; diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/utils.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/utils.ts index 4e30b04a75f..ce3e475d92a 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/utils.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/utils.ts @@ -268,6 +268,10 @@ export function sendGeneratedSourcesNotification(fileArray: SourceFile[]): void sendAIPanelNotification(msg); } +export function sendConnectorGenerationNotification(event: ChatNotify & { type: "connector_generation_notification" }): void { + sendAIPanelNotification(event); +} + function sendAIPanelNotification(msg: ChatNotify): void { RPCLayer._messenger.sendNotification(onChatNotify, { type: "webview", webviewType: AiPanelWebview.viewType }, msg); } diff --git a/workspaces/ballerina/ballerina-extension/src/views/ai-panel/aiChatMachine.ts b/workspaces/ballerina/ballerina-extension/src/views/ai-panel/aiChatMachine.ts index d68cb1840e3..fb51c5e4533 100644 --- a/workspaces/ballerina/ballerina-extension/src/views/ai-panel/aiChatMachine.ts +++ b/workspaces/ballerina/ballerina-extension/src/views/ai-panel/aiChatMachine.ts @@ -24,7 +24,6 @@ import { AIChatMachineContext, AIChatMachineEventType, AIChatMachineSendableEven import { workspace } from 'vscode'; import { GenerateAgentCodeRequest } from '@wso2/ballerina-core/lib/rpc-types/ai-panel/interfaces'; import { generateDesign } from '../../features/ai/service/design/design'; -import { cleanupTempProject, getTempProjectPath } from '../../features/ai/utils/temp-project-utils'; const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -109,6 +108,8 @@ const chatMachine = createMachine event.payload.message, }), }, + [AIChatMachineEventType.CONNECTOR_GENERATION_REQUESTED]: { + target: 'WaitingForConnectorSpec', + actions: assign({ + previousState: (ctx, event, meta) => { + if (event.payload.fromState) { + return event.payload.fromState; + } + const currentState = meta?.state?.value as AIChatMachineStateValue; + if (currentState) { + return currentState; + } + return ctx.previousState || 'GeneratingPlan'; + }, + currentSpec: (_ctx, event) => ({ + requestId: event.payload.requestId, + }), + }), + }, }, states: { Idle: { @@ -383,7 +402,7 @@ const chatMachine = createMachine { + console.log('[State Machine] PROVIDE_CONNECTOR_SPEC: previousState =', ctx.previousState); + return ctx.previousState === 'GeneratingPlan'; + }, + actions: assign({ + currentSpec: (ctx, event) => ({ + ...ctx.currentSpec, + requestId: event.payload.requestId, + spec: event.payload.spec, + provided: true, + }), + }), + }, + { + target: 'Initiating', + cond: (ctx) => ctx.previousState === 'Initiating', + actions: assign({ + currentSpec: (ctx, event) => ({ + ...ctx.currentSpec, + requestId: event.payload.requestId, + spec: event.payload.spec, + provided: true, + }), + }), + }, + { + target: 'ExecutingTask', + actions: assign({ + currentSpec: (ctx, event) => ({ + ...ctx.currentSpec, + requestId: event.payload.requestId, + spec: event.payload.spec, + provided: true, + }), + }), + }, + ], + [AIChatMachineEventType.SKIP_CONNECTOR_GENERATION]: [ + { + target: 'GeneratingPlan', + cond: (ctx) => { + console.log('[State Machine] SKIP_CONNECTOR_GENERATION: previousState =', ctx.previousState); + return ctx.previousState === 'GeneratingPlan'; + }, + actions: assign({ + currentSpec: (ctx, event) => ({ + ...ctx.currentSpec, + requestId: event.payload.requestId, + skipped: true, + comment: event.payload.comment, + }), + }), + }, + { + target: 'Initiating', + cond: (ctx) => ctx.previousState === 'Initiating', + actions: assign({ + currentSpec: (ctx, event) => ({ + ...ctx.currentSpec, + requestId: event.payload.requestId, + skipped: true, + comment: event.payload.comment, + }), + }), + }, + { + target: 'ExecutingTask', + actions: assign({ + currentSpec: (ctx, event) => ({ + ...ctx.currentSpec, + requestId: event.payload.requestId, + skipped: true, + comment: event.payload.comment, + }), + }), + }, + ], + }, + }, Error: { - entry: 'cleanupTempProject', on: { [AIChatMachineEventType.RETRY]: [ { @@ -661,15 +764,6 @@ const chatStateService = interpret( actions: { saveChatState: (context) => saveChatState(context), clearChatState: (context) => clearChatStateAction(context), - cleanupTempProject: () => { - try { - const tempProjectPath = getTempProjectPath(); - console.log(`[AIChatMachine] Cleaning up temp project: ${tempProjectPath}`); - cleanupTempProject(tempProjectPath); - } catch (error) { - console.error('[AIChatMachine] Failed to cleanup temp project:', error); - } - }, }, }) ); diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/commandTemplates/data/commandTemplates.const.ts b/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/commandTemplates/data/commandTemplates.const.ts index 48510167d2d..f98cad481e4 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/commandTemplates/data/commandTemplates.const.ts +++ b/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/commandTemplates/data/commandTemplates.const.ts @@ -197,10 +197,16 @@ export const NATURAL_PROGRAMMING_TEMPLATES: TemplateDefinition[] = [ export const suggestedCommandTemplates: AIPanelPrompt[] = [ { type: "command-template", - command: Command.Code, + command: Command.Design, templateId: TemplateId.Wildcard, text: "write a hello world http service", }, + { + type: "command-template", + command: Command.Design, + templateId: TemplateId.Wildcard, + text: "I need to build a pet store application that manages pets, store orders, and users. Can you help me integrate with the Petstore API?", + }, { type: "command-template", command: Command.Design, diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/components/AIChat/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/components/AIChat/index.tsx index 0b6daeccf1f..bd60887fc6b 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/components/AIChat/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/components/AIChat/index.tsx @@ -56,6 +56,7 @@ import { AIChatInputRef } from "../AIChatInput"; import ProgressTextSegment from "../ProgressTextSegment"; import ToolCallSegment from "../ToolCallSegment"; import TodoSection from "../TodoSection"; +import { ConnectorGeneratorSegment } from "../ConnectorGeneratorSegment"; import RoleContainer from "../RoleContainter"; import { Attachment, AttachmentStatus, TaskApprovalRequest } from "@wso2/ballerina-core"; @@ -545,6 +546,47 @@ const AIChat: React.FC = () => { } } else if (type === "generated_sources") { setCurrentFileArray(response.fileArray); + } else if (type === "connector_generation_notification") { + const connectorNotification = response as any; + const connectorJson = JSON.stringify({ + requestId: connectorNotification.requestId, + stage: connectorNotification.stage, + serviceName: connectorNotification.serviceName, + serviceDescription: connectorNotification.serviceDescription, + spec: connectorNotification.spec, + connector: connectorNotification.connector, + error: connectorNotification.error, + message: connectorNotification.message, + inputMethod: connectorNotification.inputMethod, + sourceIdentifier: connectorNotification.sourceIdentifier + }); + + setMessages((prevMessages) => { + const newMessages = [...prevMessages]; + if (newMessages.length > 0) { + const lastMessageContent = newMessages[newMessages.length - 1].content; + + const escapeRegex = (str: string): string => { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }; + + const searchPattern = `{"requestId":"${connectorNotification.requestId}"`; + + if (lastMessageContent.includes(searchPattern)) { + const replacePattern = new RegExp( + `[^<]*${escapeRegex(connectorNotification.requestId)}[^<]*`, + 's' + ); + newMessages[newMessages.length - 1].content = lastMessageContent.replace( + replacePattern, + `${connectorJson}` + ); + } else { + newMessages[newMessages.length - 1].content += `\n\n${connectorJson}`; + } + } + return newMessages; + }); } else if (type === "diagnostics") { const content = response.diagnostics; currentDiagnosticsRef.current = content; @@ -1793,6 +1835,13 @@ const AIChat: React.FC = () => { isLoading={isLoading && isLastMessage} /> ); + } else if (segment.type === SegmentType.SpecFetcher) { + return ( + + ); } else if (segment.type === SegmentType.Attachment) { return ( @@ -2091,6 +2140,7 @@ export enum SegmentType { References = "References", TestScenario = "TestScenario", Button = "Button", + SpecFetcher = "SpecFetcher", } interface Segment { @@ -2161,7 +2211,7 @@ export function splitContent(content: string): Segment[] { // Combined regex to capture either ``` code ``` or Text const regex = - /\s*```(\w+)\s*([\s\S]*?)```\s*<\/code>|([\s\S]*?)<\/progress>|([\s\S]*?)<\/toolcall>|([\s\S]*?)<\/todo>|([\s\S]*?)<\/attachment>|([\s\S]*?)<\/scenario>|([\s\S]*?)<\/button>|([\s\S]*?)|([\s\S]*?)/g; + /\s*```(\w+)\s*([\s\S]*?)```\s*<\/code>|([\s\S]*?)<\/progress>|([\s\S]*?)<\/toolcall>|([\s\S]*?)<\/todo>|([\s\S]*?)<\/attachment>|([\s\S]*?)<\/scenario>|([\s\S]*?)<\/button>|([\s\S]*?)|([\s\S]*?)|([\s\S]*?)<\/connectorgenerator>/g; let match; let lastIndex = 0; @@ -2286,6 +2336,23 @@ export function splitContent(content: string): Segment[] { text: match[13].trim(), loading: false, }); + } else if (match[14]) { + // block matched + const connectorData = match[14]; + + updateLastProgressSegmentLoading(); + try { + const parsedData = JSON.parse(connectorData); + segments.push({ + type: SegmentType.SpecFetcher, + loading: false, + text: "", + specData: parsedData + }); + } catch (error) { + // If parsing fails, show as text + console.error("Failed to parse connector generator data:", error); + } } // Update lastIndex to the end of the current match diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/components/ConnectorGeneratorSegment.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/components/ConnectorGeneratorSegment.tsx new file mode 100644 index 00000000000..9e9b18f7812 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/AIPanel/components/ConnectorGeneratorSegment.tsx @@ -0,0 +1,723 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState } from "react"; +import styled from "@emotion/styled"; +import { AIChatMachineEventType } from "@wso2/ballerina-core"; + +const Container = styled.div<{ variant: string }>` + padding: 16px; + border-radius: 8px; + margin: 12px 0; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + + ${(props: { variant: string }) => + props.variant === "requesting-input" && + ` + background-color: var(--vscode-editor-background); + border: 2px solid var(--vscode-focusBorder); + `} + + ${(props: { variant: string }) => + props.variant === "error" && + ` + background-color: var(--vscode-inputValidation-errorBackground); + border: 1px solid var(--vscode-inputValidation-errorBorder); + `} + + ${(props: { variant: string }) => + props.variant === "provided" && + ` + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-testing-iconPassed); + opacity: 0.95; + `} + + ${(props: { variant: string }) => + props.variant === "skipped" && + ` + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-testing-iconFailed); + opacity: 0.95; + `} + + ${(props: { variant: string }) => + props.variant === "generating" && + ` + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-focusBorder); + `} +`; + +const Header = styled.div` + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +`; + +const TitleSection = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +`; + +const Title = styled.span` + font-weight: 600; + font-size: 15px; + color: var(--vscode-foreground); +`; + +const Subtitle = styled.span` + font-size: 13px; + color: var(--vscode-descriptionForeground); + font-style: italic; +`; + +const Icon = styled.span` + font-size: 20px; + flex-shrink: 0; +`; + +const Message = styled.div` + margin-bottom: 16px; + padding: 12px; + background-color: var(--vscode-editorWidget-background); + border-radius: 4px; + font-size: 13px; + color: var(--vscode-foreground); +`; + +const InputMethods = styled.div` + margin-bottom: 16px; +`; + +const InputMethodTabs = styled.div` + display: flex; + gap: 4px; + margin-bottom: 12px; + border-bottom: 1px solid var(--vscode-panel-border); +`; + +const MethodTab = styled.button<{ active: boolean }>` + padding: 8px 16px; + background: transparent; + border: none; + border-bottom: 2px solid + ${(props: { active: boolean }) => (props.active ? "var(--vscode-focusBorder)" : "transparent")}; + color: ${(props: { active: boolean }) => (props.active ? "var(--vscode-focusBorder)" : "var(--vscode-foreground)")}; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + + &:hover { + background-color: var(--vscode-list-hoverBackground); + } +`; + +const InputMethodContent = styled.div` + padding: 16px; + background-color: var(--vscode-input-background); + border-radius: 4px; + border: 1px solid var(--vscode-input-border); +`; + +const PasteInputSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const SpecPasteInput = styled.textarea` + width: 100%; + padding: 12px; + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + resize: vertical; + min-height: 150px; + + &:focus { + outline: none; + border-color: var(--vscode-focusBorder); + } +`; + +const FileInputSection = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 24px; +`; + +const FileUploadLabel = styled.label` + cursor: pointer; +`; + +const FileInputHidden = styled.input` + display: none; +`; + +const FileUploadButton = styled.span` + display: inline-block; + padding: 10px 24px; + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-radius: 4px; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + + &:hover { + background-color: var(--vscode-button-hoverBackground); + } +`; + +const FileInputHelp = styled.p` + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin: 0; +`; + +const UrlInputSection = styled.div` + display: flex; + gap: 12px; +`; + +const SpecUrlInput = styled.input` + flex: 1; + padding: 8px 12px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + font-family: var(--vscode-font-family); + font-size: 13px; + + &:focus { + outline: none; + border-color: var(--vscode-focusBorder); + } +`; + +const Actions = styled.div` + display: flex; + gap: 8px; + margin-top: 16px; +`; + +const ActionButton = styled.button<{ variant: string }>` + padding: 8px 20px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + ${(props: { variant: string }) => + props.variant === "submit" && + ` + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + + &:hover:not(:disabled) { + background-color: var(--vscode-button-hoverBackground); + } + `} + + ${(props: { variant: string }) => + props.variant === "skip" && + ` + background-color: transparent; + color: var(--vscode-button-foreground); + border: 1px solid var(--vscode-button-border); + + &:hover:not(:disabled) { + background-color: var(--vscode-button-secondaryHoverBackground); + } + `} + + ${(props: { variant: string }) => + props.variant === "skip-confirm" && + ` + background-color: var(--vscode-inputValidation-warningBackground); + color: var(--vscode-inputValidation-warningForeground); + border: 1px solid var(--vscode-inputValidation-warningBorder); + + &:hover { + opacity: 0.9; + } + `} + + ${(props: { variant: string }) => + props.variant === "cancel" && + ` + background-color: transparent; + color: var(--vscode-button-foreground); + border: 1px solid var(--vscode-button-border); + + &:hover { + background-color: var(--vscode-button-secondaryHoverBackground); + } + `} + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const SkipForm = styled.div` + margin-top: 16px; + padding: 12px; + background-color: var(--vscode-editorWidget-background); + border-radius: 4px; +`; + +const SkipCommentInput = styled.textarea` + width: 100%; + padding: 8px; + border: 1px solid var(--vscode-input-border); + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border-radius: 4px; + font-family: var(--vscode-font-family); + font-size: 13px; + resize: vertical; + margin-bottom: 8px; + + &:focus { + outline: none; + border-color: var(--vscode-focusBorder); + } +`; + +const Details = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +`; + +const DetailRow = styled.div` + display: flex; + gap: 8px; + font-size: 13px; +`; + +const DetailLabel = styled.span` + font-weight: 600; + color: var(--vscode-descriptionForeground); + min-width: 80px; +`; + +const DetailValue = styled.span` + color: var(--vscode-foreground); + flex: 1; +`; + +const MethodsValue = styled(DetailValue)` + display: flex; + gap: 4px; + flex-wrap: wrap; +`; + +const MethodBadge = styled.span<{ method: string }>` + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + + ${(props: { method: string }) => { + const method = props.method.toLowerCase(); + if (method === "get") return "background-color: #61affe; color: #fff;"; + if (method === "post") return "background-color: #49cc90; color: #fff;"; + if (method === "put") return "background-color: #fca130; color: #fff;"; + if (method === "patch") return "background-color: #50e3c2; color: #fff;"; + if (method === "delete") return "background-color: #f93e3e; color: #fff;"; + return "background-color: #9012fe; color: #fff;"; + }} +`; + +const ErrorContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ErrorMessage = styled.div` + color: var(--vscode-inputValidation-errorForeground); + font-weight: 500; +`; + +const ErrorCode = styled.div` + font-size: 12px; + color: var(--vscode-descriptionForeground); +`; + +const ErrorService = styled.div` + font-size: 12px; + color: var(--vscode-descriptionForeground); +`; + +const Status = styled.div` + font-size: 12px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + margin-top: 8px; +`; + +interface ConnectorGeneratorData { + requestId: string; + stage: "requesting_input" | "input_received" | "generating" | "generated" | "skipped" | "error"; + serviceName?: string; + serviceDescription?: string; + spec?: { + version: string; + title: string; + description?: string; + baseUrl?: string; + endpointCount: number; + methods: string[]; + }; + connector?: { + moduleName: string; + importStatement: string; + }; + error?: { + message: string; + code: string; + }; + message: string; +} + +interface ConnectorGeneratorSegmentProps { + data: ConnectorGeneratorData; + rpcClient: any; +} + +export const ConnectorGeneratorSegment: React.FC = ({ data, rpcClient }) => { + const [inputMethod, setInputMethod] = useState<"file" | "paste" | "url">("file"); + const [specContent, setSpecContent] = useState(""); + const [specUrl, setSpecUrl] = useState(""); + const [skipComment, setSkipComment] = useState(""); + const [showSkipInput, setShowSkipInput] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = async (event) => { + const content = event.target?.result as string; + await handleSubmit("file", content, file.name); + }; + reader.onerror = (error) => { + console.error("[ConnectorGenerator UI] File read error:", error); + }; + reader.readAsText(file); + }; + + const handleSubmit = async (method: "file" | "paste" | "url", content?: string, identifier?: string) => { + setIsProcessing(true); + + let specData: any; + let sourceId: string | undefined; + + try { + if (method === "url") { + const response = await fetch(specUrl); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + specData = await response.text(); + sourceId = specUrl; + } else if (method === "paste") { + specData = specContent; + sourceId = "pasted-content"; + } else if (method === "file" && content) { + specData = content; + sourceId = identifier; + } + + rpcClient.sendAIChatStateEvent({ + type: AIChatMachineEventType.PROVIDE_CONNECTOR_SPEC, + payload: { + requestId: data.requestId, + spec: specData, + inputMethod: method, + sourceIdentifier: sourceId, + }, + }); + } catch (error: any) { + console.error("[ConnectorGenerator UI] Error in handleSubmit:", error); + setIsProcessing(false); + } + }; + + const handleSkip = () => { + if (showSkipInput) { + rpcClient.sendAIChatStateEvent({ + type: AIChatMachineEventType.SKIP_CONNECTOR_GENERATION, + payload: { + requestId: data.requestId, + comment: skipComment.trim() || undefined, + }, + }); + setShowSkipInput(false); + setSkipComment(""); + } else { + setShowSkipInput(true); + } + }; + + const handleCancelSkip = () => { + setShowSkipInput(false); + setSkipComment(""); + }; + + const currentStage = data.stage; + + if (currentStage === "requesting_input") { + return ( + +
+ 📋 + + Generate Connector: {data.serviceName} + {data.serviceDescription && {data.serviceDescription}} + +
+ + {data.message} + + + + setInputMethod("file")}> + Upload File + + setInputMethod("url")}> + Fetch from URL + + setInputMethod("paste")}> + Paste Content + + + + + {inputMethod === "paste" && ( + + setSpecContent(e.target.value)} + rows={8} + /> + handleSubmit("paste")} + disabled={!specContent.trim() || isProcessing} + > + {isProcessing ? "Processing..." : "Submit"} + + + )} + + {inputMethod === "file" && ( + + + + + {isProcessing ? "Processing..." : "Choose File (.json, .yaml)"} + + + Select your OpenAPI specification file + + )} + + {inputMethod === "url" && ( + + setSpecUrl(e.target.value)} + /> + handleSubmit("url")} + disabled={!specUrl.trim() || isProcessing} + > + {isProcessing ? "Fetching..." : "Fetch & Submit"} + + + )} + + + + {!showSkipInput ? ( + + + Skip + + + ) : ( + + setSkipComment(e.target.value)} + rows={2} + /> + + + Confirm Skip + + + Cancel + + + + )} +
+ ); + } + + if (currentStage === "generating") { + return ( + +
+ ⚙️ + Generating connector... +
+ + {data.spec ? `Generating connector for ${data.spec.title}` : "Processing specification..."} + +
+ ); + } + + if (currentStage === "error" && data.error) { + return ( + +
+ ⚠️ + Failed to Generate Connector +
+ + {data.error.message} + Error Code: {data.error.code} + {data.serviceName && Service: {data.serviceName}} + +
+ ); + } + + if (currentStage === "generated") { + return ( + +
+ + Connector Generated: {data.spec?.title || data.serviceName} +
+ +
+ {data.connector && ( + <> + + Module: + {data.connector.moduleName} + + + + Import: + {data.connector.importStatement} + + + )} + + {data.spec && ( + <> + + API Version: + {data.spec.version} + + + {data.spec.baseUrl && ( + + Base URL: + {data.spec.baseUrl} + + )} + + + Endpoints: + {data.spec.endpointCount} + + + + Methods: + + {data.spec.methods.map((method) => ( + + {method} + + ))} + + + + )} +
+ + Connector Ready to Use +
+ ); + } + + if (currentStage === "skipped") { + return ( + +
+ + Connector Generation Skipped: {data.serviceName || "Service"} +
+ Skipped connector generation +
+ ); + } + + return null; +};