diff --git a/.eslintrc b/.eslintrc index ef96779d..a70b8391 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,6 @@ { "parserOptions": { - "ecmaVersion": 2017 + "ecmaVersion": 2018 }, "env": { "es6": true diff --git a/.gitignore b/.gitignore index 5094c245..7b1f49d5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ FRONTEND.md # Distribution folders dist package + +# AI +AGENTS.md +CLAUDE.md \ No newline at end of file diff --git a/.jshintrc b/.jshintrc index 388e9183..5f3bec3f 100644 --- a/.jshintrc +++ b/.jshintrc @@ -58,7 +58,7 @@ "maxdepth": 3, // {int} Max number statements per function - "maxstatements": 30, + "maxstatements": 60, // {int} Max cyclomatic complexity per function. The cyclomatic complexity of a section of source code is the count // of the number of linearly independent paths through the source code. @@ -83,8 +83,8 @@ // true: Allow ES5 syntax (ex: getters and setters) "es5": false, - // true: Allow ES.next (ES6) syntax (ex: `const`) - "esnext": true, + // Set specific ECMAScript version + "esversion": 11, // true: Allow Mozilla specific syntax (extends and overrides esnext features)(ex: `for each`, multiple try/catch, function expression…) "moz": false, @@ -114,7 +114,7 @@ "laxcomma": false, // true: Tolerate functions being defined in loops - "loopfunc": false, + "loopfunc": true, // true: Tolerate multi-line strings "multistr": false, @@ -164,6 +164,8 @@ "sinon": true, "should": true, "Event": true, - "ace": true + "ace": true, + "self": true, + "AbortController": true } } diff --git a/app/html/panel/ui5/index.html b/app/html/panel/ui5/index.html index 914ea113..3a37798a 100644 --- a/app/html/panel/ui5/index.html +++ b/app/html/panel/ui5/index.html @@ -17,6 +17,7 @@ Application Information OData Requests Elements Registry + AI Assistant @@ -157,6 +158,12 @@ + + + + + + diff --git a/app/manifest.json b/app/manifest.json index 81011d1e..db0d3058 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -36,7 +36,8 @@ "permissions": [ "scripting", "contextMenus", - "activeTab" + "activeTab", + "storage" ], "host_permissions": [ "http://*/*", diff --git a/app/scripts/background/main.js b/app/scripts/background/main.js index 9265c7c2..6366d185 100644 --- a/app/scripts/background/main.js +++ b/app/scripts/background/main.js @@ -154,4 +154,307 @@ // Page actions are disabled by default and enabled on select tabs chrome.action.disable(); }); + + // ================================================================================ + // Prompt API Integration (Gemini Nano) + // ================================================================================ + + let promptAPISession = null; + let promptAPIController = null; + + /** + * Check if Prompt API is supported + */ + function isPromptAPISupported() { + return 'LanguageModel' in self; + } + + /** + * Initialize Prompt API session + */ + async function initPromptAPISession(options, signal) { + const availability = await self.LanguageModel.availability(); + + if (availability === 'unavailable') { + throw new Error('AI Model is not available on this device.'); + } + + const sessionOptions = { + signal + }; + + // Add download progress monitoring if callback provided + if (options.onProgress) { + sessionOptions.monitor = function(m) { + m.addEventListener('downloadprogress', (e) => { + options.onProgress(e.loaded || 0); + }); + }; + } + + return await self.LanguageModel.create(sessionOptions); + } + + /** + * Handle check availability request + */ + async function handleCheckAvailability(port) { + + if (!isPromptAPISupported()) { + port.postMessage({ + type: 'availability', + status: 'unavailable', + message: 'Prompt API not supported - LanguageModel not found in self' + }); + return; + } + + try { + const availability = await self.LanguageModel.availability(); + + let status; + let message; + if (availability === 'available') { + status = 'ready'; + message = 'Gemini Nano is ready to use'; + } else if (availability === 'downloadable') { + status = 'needs-download'; + message = 'Gemini Nano needs to be downloaded (~22GB)'; + } else if (availability === 'downloading') { + status = 'downloading'; + message = 'Gemini Nano is currently downloading'; + } else if (availability === 'unavailable') { + status = 'unavailable'; + message = 'Gemini Nano is not available on this device'; + } else { + status = 'unavailable'; + message = `Gemini Nano status unknown. Availability returned: "${availability}"`; + } + + port.postMessage({ + type: 'availability', + status: status, + message: message + }); + } catch (error) { + console.error('[Background] Error checking availability:', error); + port.postMessage({ + type: 'availability', + status: 'error', + message: `Error: ${error.message}` + }); + } + } + + /** + * Handle download model request + */ + async function handleDownloadModel(port) { + + if (!isPromptAPISupported()) { + port.postMessage({ + type: 'error', + message: 'Prompt API not supported' + }); + return; + } + + // Abort any existing operation + if (promptAPIController) { + promptAPIController.abort(); + } + promptAPIController = new AbortController(); + + try { + promptAPISession = await initPromptAPISession({ + onProgress: (progress) => { + port.postMessage({ + type: 'download-progress', + progress: progress + }); + } + }, promptAPIController.signal); + + port.postMessage({ + type: 'download-complete' + }); + } catch (error) { + console.error('[Background] Error downloading model:', error); + port.postMessage({ + type: 'error', + message: error.message + }); + } finally { + promptAPIController = null; + } + } + + /** + * Handle create session request + */ + async function handleCreateSession(data, port) { + + if (!isPromptAPISupported()) { + port.postMessage({ + type: 'error', + message: 'Prompt API not supported' + }); + return; + } + + try { + // Destroy existing session if any + if (promptAPISession) { + promptAPISession.destroy(); + promptAPISession = null; + } + + promptAPISession = await initPromptAPISession({}, new AbortController().signal); + + port.postMessage({ + type: 'session-created' + }); + } catch (error) { + console.error('[Background] Error creating session:', error); + port.postMessage({ + type: 'error', + message: error.message + }); + } + } + + /** + * Handle streaming prompt request + */ + async function handlePromptStreaming(data, port) { + + // Validate messages structure + if (!data || !Array.isArray(data.messages)) { + port.postMessage({ + type: 'error', + message: 'Invalid messages format: expected array' + }); + return; + } + + if (!promptAPISession) { + port.postMessage({ + type: 'error', + message: 'No active session' + }); + return; + } + + // Abort any existing operation + if (promptAPIController) { + promptAPIController.abort(); + } + promptAPIController = new AbortController(); + + try { + const stream = await promptAPISession.promptStreaming( + data.messages, + { signal: promptAPIController.signal } + ); + + for await (const chunk of stream) { + if (promptAPIController.signal.aborted) { + break; + } + + port.postMessage({ + type: 'chunk', + content: chunk + }); + } + + if (!promptAPIController.signal.aborted) { + port.postMessage({ + type: 'complete' + }); + } + } catch (error) { + console.error('[Background] Error during streaming:', error); + port.postMessage({ + type: 'error', + message: error.message + }); + } finally { + promptAPIController = null; + } + } + + /** + * Handle get usage info request + */ + function handleGetUsageInfo(port) { + if (!promptAPISession) { + port.postMessage({ + type: 'usage-info', + data: null + }); + return; + } + + const inputUsage = promptAPISession.inputUsage || 0; + const inputQuota = promptAPISession.inputQuota || 4096; + const percentUsed = Math.round((inputUsage / inputQuota) * 100); + + port.postMessage({ + type: 'usage-info', + data: { + inputUsage: inputUsage, + inputQuota: inputQuota, + percentUsed: percentUsed + } + }); + } + + /** + * Handle destroy session request + */ + function handleDestroySession(port) { + if (promptAPISession) { + promptAPISession.destroy(); + promptAPISession = null; + } + + if (promptAPIController) { + promptAPIController.abort(); + promptAPIController = null; + } + + port.postMessage({ + type: 'session-destroyed' + }); + } + + // Listen for long-lived connections for Prompt API + chrome.runtime.onConnect.addListener((port) => { + if (port.name === 'prompt-api') { + port.onMessage.addListener((message) => { + switch (message.type) { + case 'check-availability': + handleCheckAvailability(port); + break; + case 'download-model': + handleDownloadModel(port); + break; + case 'create-session': + handleCreateSession(message.data, port); + break; + case 'prompt-streaming': + handlePromptStreaming(message.data, port); + break; + case 'get-usage-info': + handleGetUsageInfo(port); + break; + case 'destroy-session': + handleDestroySession(port); + break; + } + }); + } + }); + }()); diff --git a/app/scripts/devtools/panel/ui5/main.js b/app/scripts/devtools/panel/ui5/main.js index bc424f08..fb05d73b 100644 --- a/app/scripts/devtools/panel/ui5/main.js +++ b/app/scripts/devtools/panel/ui5/main.js @@ -1,5 +1,4 @@ -// jshint maxstatements:52 (function () { 'use strict'; @@ -24,6 +23,7 @@ var XMLDetailView = require('../../../modules/ui/XMLDetailView.js'); var ControllerDetailView = require('../../../modules/ui/ControllerDetailView.js'); var OElementsRegistryMasterView = require('../../../modules/ui/OElementsRegistryMasterView.js'); + var AIChat = require('../../../modules/ui/AIChat.js'); // Apply theme // ================================================================================ @@ -428,6 +428,14 @@ onSelectionChange: displayFrameData }); + // AI Chat component + var aiChat = new AIChat('ai-chat', { + getAppInfo: function () { + var currentFrameId = framesSelect.getSelectedId(); + return frameData[currentFrameId] ? frameData[currentFrameId].applicationInformation : null; + } + }); + // ================================================================================ // Communication // ================================================================================ @@ -482,6 +490,9 @@ if (framesSelect.getSelectedId() === frameId) { controlTree.setData(message.controlTree); + + // Set URL for AI Chat history + aiChat.setUrl(frameData[frameId].url); appInfo.setData(message.applicationInformation); oElementsRegistryMasterView.setData(message.elementRegistry); } @@ -559,6 +570,27 @@ // Close possible open binding info and/or methods info controlBindingsSplitter.hideEndContainer(); + + // Update AI Chat context with control data + var controlId = message.controlProperties.own && message.controlProperties.own.options && message.controlProperties.own.options.controlId; + var controlType = null; + if (message.controlProperties.own && message.controlProperties.own.options && message.controlProperties.own.options.title) { + var titleMatch = message.controlProperties.own.options.title.match(/\(([^)]+)\)<\/span>$/); + if (titleMatch) { + controlType = titleMatch[1]; + } + } + + aiChat.updateContext({ + control: { + type: controlType, + id: controlId, + properties: message.controlProperties, + bindings: message.controlBindings, + aggregations: message.controlAggregations + }, + appInfo: frameData[frameId].applicationInformation + }); } }, diff --git a/app/scripts/modules/ai/AISessionManager.js b/app/scripts/modules/ai/AISessionManager.js new file mode 100644 index 00000000..8d08f1a1 --- /dev/null +++ b/app/scripts/modules/ai/AISessionManager.js @@ -0,0 +1,495 @@ +'use strict'; + +/** + * AISessionManager - Proxy for communicating with background script for Prompt API. + * Uses chrome.runtime.connect to establish a long-lived port connection. + * @constructor + */ +function AISessionManager() { + this._port = null; + this._messageHandlers = {}; + this._isConnected = false; + this._hasActiveSession = false; +} + +/** + * Connect to background script. + * @private + */ +AISessionManager.prototype._connect = function () { + if (this._isConnected) { + return; + } + + this._port = chrome.runtime.connect({ name: 'prompt-api' }); + this._isConnected = true; + + // Set up message listener + this._port.onMessage.addListener((message) => { + const handler = this._messageHandlers[message.type]; + if (handler) { + handler(message); + } + }); + + // Handle disconnect + this._port.onDisconnect.addListener(() => { + this._isConnected = false; + this._hasActiveSession = false; + this._port = null; + }); +}; + +/** + * Register a message handler. + * @private + * @param {string} type - Message type + * @param {Function} handler - Handler function + */ +AISessionManager.prototype._on = function (type, handler) { + this._messageHandlers[type] = handler; +}; + +/** + * Remove a message handler. + * @private + * @param {string} type - Message type + */ +AISessionManager.prototype._off = function (type) { + delete this._messageHandlers[type]; +}; + +/** + * Send a message to background script. + * @private + * @param {Object} message + */ +AISessionManager.prototype._send = function (message) { + this._connect(); + this._port.postMessage(message); +}; + +/** + * Check if the Prompt API is available. + * @returns {Promise<{available: boolean, status: string, message: string}>} + */ +AISessionManager.prototype.checkAvailability = function () { + return new Promise((resolve) => { + this._connect(); + + const handler = (message) => { + this._off('availability'); + resolve({ + available: message.status === 'ready' || message.status === 'needs-download', + status: message.status, + message: message.message + }); + }; + + this._on('availability', handler); + this._send({ type: 'check-availability' }); + }); +}; + +/** + * Download the Gemini Nano model. + * @param {Function} onProgress - Callback for download progress (0-1) + * @returns {Promise} + */ +AISessionManager.prototype.downloadModel = function (onProgress) { + return new Promise((resolve, reject) => { + this._connect(); + + const progressHandler = (message) => { + if (onProgress && typeof onProgress === 'function') { + onProgress(message.progress); + } + }; + + const completeHandler = (message) => { + this._off('download-progress'); + this._off('download-complete'); + this._off('error'); + this._hasActiveSession = true; + resolve(); + }; + + const errorHandler = (message) => { + this._off('download-progress'); + this._off('download-complete'); + this._off('error'); + reject(new Error(message.message)); + }; + + this._on('download-progress', progressHandler); + this._on('download-complete', completeHandler); + this._on('error', errorHandler); + + this._send({ type: 'download-model' }); + }); +}; + +/** + * Get default system prompt for UI5 expert assistant. + * @private + * @param {Object} appInfo - Application information + * @returns {string} + */ +AISessionManager.prototype._getDefaultSystemPrompt = function (appInfo) { + let prompt = `You are an AI assistant embedded in the UI5 Inspector, specialized in SAP UI5, OpenUI5, and UI5 Web Components. Your role is to help developers understand, debug, and build UI5-based applications. +Provide clear, accurate, and practical guidance on components, APIs, accessibility, theming, layout, performance, and best practices. Prefer concise answers, but explain reasoning when needed. Use code snippets where helpful and format code clearly. +Assume familiarity with JavaScript, HTML, and modern frameworks. When information is uncertain or version-dependent, say so clearly. Do not invent APIs or unsupported features. +You cannot browse the web or open links. If external content is required, ask the user to paste it. +Be neutral, direct, and developer-focused. Avoid marketing language, unnecessary filler, and generic disclaimers. Respond in the user's language and adapt tone to the context.`; + + if (appInfo) { + prompt += '\n\nCurrent Application Context:\n'; + + if (appInfo.common && appInfo.common.data) { + const frameworkInfo = appInfo.common.data.OpenUI5 || appInfo.common.data.SAPUI5; + if (frameworkInfo) { + prompt += `- Framework: ${frameworkInfo}\n`; + } + } + + if (appInfo.configurationComputed && appInfo.configurationComputed.data && appInfo.configurationComputed.data.theme) { + prompt += `- Theme: ${appInfo.configurationComputed.data.theme}\n`; + } + + if (appInfo.loadedLibraries && appInfo.loadedLibraries.data) { + const libraries = Object.keys(appInfo.loadedLibraries.data); + if (libraries.length > 0) { + prompt += `- Loaded Libraries: ${libraries.join(', ')}\n`; + } + } + } + + return prompt; +}; + +/** + * Create a new AI session (empty, stateless). + * @returns {Promise} - True if session created successfully + */ +AISessionManager.prototype.createSession = function () { + return new Promise((resolve, reject) => { + this._connect(); + + const handler = (message) => { + this._off('session-created'); + this._off('error'); + this._hasActiveSession = true; + resolve(true); + }; + + const errorHandler = (message) => { + this._off('session-created'); + this._off('error'); + reject(new Error(message.message)); + }; + + this._on('session-created', handler); + this._on('error', errorHandler); + + this._send({ + type: 'create-session', + data: {} + }); + }); +}; + +/** + * Truncate JSON string if needed. + * @private + * @param {Object} data - Data to stringify + * @param {number} maxLength - Maximum length + * @returns {string} + */ +AISessionManager.prototype._truncateJson = function (data, maxLength) { + try { + var json = JSON.stringify(data, null, 2); + if (json.length > maxLength) { + return json.substring(0, maxLength) + '... [truncated]'; + } + return json; + } catch (e) { + return '(Data available but cannot serialize)'; + } +}; + +/** + * Add properties to context string. + * @private + */ +AISessionManager.prototype._addPropertiesContext = function (control, maxLength) { + var props = control.properties; + if (!props || !props.own || !props.own.data) { + return ''; + } + var keys = Object.keys(props.own.data); + if (keys.length === 0) { + return ''; + } + var propsJson = JSON.stringify(props.own.data); + if (propsJson.length > maxLength) { + propsJson = propsJson.substring(0, maxLength) + '... [truncated]'; + } + return '- Properties: ' + propsJson + '\n'; +}; + +/** + * Add bindings to context string. + * @private + */ +AISessionManager.prototype._addBindingsContext = function (bindings, maxLength) { + if (!bindings || Object.keys(bindings).length === 0) { + return ''; + } + var result = '- Bindings (' + Object.keys(bindings).length + '):\n'; + result += this._truncateJson(bindings, maxLength) + '\n'; + return result; +}; + +/** + * Add aggregations to context string. + * @private + */ +AISessionManager.prototype._addAggregationsContext = function (aggregations, maxLength) { + if (!aggregations || !aggregations.own || !aggregations.own.data) { + return ''; + } + var keys = Object.keys(aggregations.own.data); + if (keys.length === 0) { + return ''; + } + var result = '- Aggregations (' + keys.length + '):\n'; + result += this._truncateJson(aggregations.own.data, maxLength) + '\n'; + return result; +}; + +/** + * Format prompt with optional context. + * @private + * @param {string} prompt - User prompt + * @param {Object} context - Optional context + * @returns {string} + */ +AISessionManager.prototype._formatPrompt = function (prompt, context) { + var MAX_SECTION_LENGTH = 2000; + + if (!context || !context.control) { + return prompt; + } + + var control = context.control; + var contextString = 'Current UI5 Control Context:\n'; + contextString += '- Type: ' + (control.type || 'Unknown') + '\n'; + contextString += '- ID: ' + (control.id || 'None') + '\n'; + + contextString += this._addPropertiesContext(control, MAX_SECTION_LENGTH); + contextString += this._addBindingsContext(control.bindings, MAX_SECTION_LENGTH); + contextString += this._addAggregationsContext(control.aggregations, MAX_SECTION_LENGTH); + + return contextString + '\nUser Question: ' + prompt; +}; + +/** + * Build messages array for prompt API. + * @private + * @param {string} userMessage - Current user message + * @param {Array} conversationHistory - Previous messages [{ role, content }] + * @param {Object} context - Optional context (control data, appInfo) + * @returns {Array} - Messages array [{ role, content }] + */ +AISessionManager.prototype._buildMessagesArray = function (userMessage, conversationHistory, context) { + const messages = []; + + const systemPrompt = this._getDefaultSystemPrompt(context ? context.appInfo : null); + messages.push({ role: 'system', content: systemPrompt }); + + if (conversationHistory && Array.isArray(conversationHistory)) { + conversationHistory.forEach(msg => { + messages.push({ role: msg.role, content: msg.content }); + }); + } + + const formattedMessage = this._formatPrompt(userMessage, context); + messages.push({ role: 'user', content: formattedMessage }); + + return messages; +}; + +/** + * Send a prompt and get a streaming response. + * @param {string} userMessage - Current user message + * @param {Array} conversationHistory - Previous messages [{ role, content }] + * @param {Object} context - Optional context (control data, appInfo) + * @returns {Promise} - Object with methods to handle streaming + */ +AISessionManager.prototype.promptStreaming = function (userMessage, conversationHistory, context) { + return new Promise((resolve, reject) => { + this._connect(); + + if (!this._hasActiveSession) { + reject(new Error('No active session. Call createSession() first.')); + return; + } + + const messages = this._buildMessagesArray(userMessage, conversationHistory, context); + let streamHandlers = { + onChunk: null, + onComplete: null, + onError: null + }; + + // Create async iterable for streaming + const stream = { + [Symbol.asyncIterator]: async function* () { + const chunkPromises = []; + let resolveChunk; + let rejectChunk; + let isComplete = false; + let error = null; + + const chunkHandler = (message) => { + if (resolveChunk) { + resolveChunk(message.content); + resolveChunk = null; + } else { + chunkPromises.push(Promise.resolve(message.content)); + } + }; + + const completeHandler = (message) => { + isComplete = true; + if (resolveChunk) { + resolveChunk({ done: true }); + } + }; + + const errorHandler = (message) => { + error = new Error(message.message); + if (rejectChunk) { + rejectChunk(error); + } + }; + + streamHandlers.onChunk = chunkHandler; + streamHandlers.onComplete = completeHandler; + streamHandlers.onError = errorHandler; + + while (!isComplete && !error) { + let chunk; + if (chunkPromises.length > 0) { + chunk = await chunkPromises.shift(); + } else { + chunk = await new Promise((res, rej) => { + resolveChunk = res; + rejectChunk = rej; + }); + } + + if (chunk && chunk.done) { + break; + } + + if (chunk) { + yield chunk; + } + } + + if (error) { + throw error; + } + } + }; + + // Set up handlers + const chunkHandler = (message) => { + if (streamHandlers.onChunk) { + streamHandlers.onChunk(message); + } + }; + + const completeHandler = (message) => { + if (streamHandlers.onComplete) { + streamHandlers.onComplete(message); + } + this._off('chunk'); + this._off('complete'); + this._off('error'); + }; + + const errorHandler = (message) => { + if (streamHandlers.onError) { + streamHandlers.onError(message); + } + this._off('chunk'); + this._off('complete'); + this._off('error'); + }; + + this._on('chunk', chunkHandler); + this._on('complete', completeHandler); + this._on('error', errorHandler); + + // Send messages array + this._send({ + type: 'prompt-streaming', + data: { + messages: messages + } + }); + + // Resolve with the stream + resolve(stream); + }); +}; + +/** + * Get session usage information. + * @returns {Promise} - {inputUsage, inputQuota, percentUsed} + */ +AISessionManager.prototype.getUsageInfo = function () { + return new Promise((resolve) => { + this._connect(); + + const handler = (message) => { + this._off('usage-info'); + resolve(message.data); + }; + + this._on('usage-info', handler); + this._send({ type: 'get-usage-info' }); + }); +}; + +/** + * Destroy the current session and free resources. + */ +AISessionManager.prototype.destroy = function () { + if (this._isConnected) { + this._send({ type: 'destroy-session' }); + this._hasActiveSession = false; + } + + // Clear handlers + this._messageHandlers = {}; + + // Disconnect port + if (this._port) { + this._port.disconnect(); + this._port = null; + this._isConnected = false; + } +}; + +/** + * Check if a session is currently active. + * @returns {boolean} + */ +AISessionManager.prototype.hasActiveSession = function () { + return this._hasActiveSession; +}; + +module.exports = AISessionManager; diff --git a/app/scripts/modules/ai/ChatStorageManager.js b/app/scripts/modules/ai/ChatStorageManager.js new file mode 100644 index 00000000..1dc37ddd --- /dev/null +++ b/app/scripts/modules/ai/ChatStorageManager.js @@ -0,0 +1,94 @@ +'use strict'; + +/** + * ChatStorageManager - Manages chat history persistence in chrome.storage.local. + * @constructor + */ +function ChatStorageManager() { + this._maxMessages = 50; +} + +/** + * Get storage key for a URL. + * @private + * @param {string} url + * @returns {string} + */ +ChatStorageManager.prototype._getKey = function (url) { + return 'ai_chat_' + (url || 'default').replace(/[^a-zA-Z0-9]/g, '_'); +}; + +/** + * Load chat history for a specific URL. + * @param {string} url - The URL to load history for + * @returns {Promise} - Array of message objects + */ +ChatStorageManager.prototype.loadHistory = function (url) { + return new Promise((resolve, reject) => { + const key = this._getKey(url); + + chrome.storage.local.get([key], (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + resolve(result[key] || []); + }); + }); +}; + +/** + * Save a message to chat history. + * @param {string} url - The URL to save history for + * @param {Object} message - Message object {role, content, timestamp} + * @returns {Promise} + */ +ChatStorageManager.prototype.saveMessage = function (url, message) { + return new Promise((resolve, reject) => { + const key = this._getKey(url); + + chrome.storage.local.get([key], (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + let messages = result[key] || []; + messages.push({ role: message.role, content: message.content }); + + if (messages.length > this._maxMessages) { + messages = messages.slice(-this._maxMessages); + } + + chrome.storage.local.set({ [key]: messages }, () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + resolve(); + }); + }); + }); +}; + +/** + * Clear chat history for a specific URL. + * @param {string} url - The URL to clear history for + * @returns {Promise} + */ +ChatStorageManager.prototype.clearHistory = function (url) { + return new Promise((resolve, reject) => { + const key = this._getKey(url); + + chrome.storage.local.remove([key], () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + resolve(); + }); + }); +}; + +module.exports = ChatStorageManager; diff --git a/app/scripts/modules/ui/AIChat.js b/app/scripts/modules/ui/AIChat.js new file mode 100644 index 00000000..b03d341b --- /dev/null +++ b/app/scripts/modules/ui/AIChat.js @@ -0,0 +1,1123 @@ +'use strict'; + +var AISessionManager = require('../ai/AISessionManager.js'); +var ChatStorageManager = require('../ai/ChatStorageManager.js'); + +/** + * AIChat - UI component for AI chat interface. + * @param {string} containerId - ID of container element + * @param {Object} options - Configuration options + * @constructor + */ +function AIChat(containerId, options) { + this._container = document.getElementById(containerId); + this._options = options || {}; + + this._sessionManager = new AISessionManager(); + this._storageManager = new ChatStorageManager(); + + this._currentUrl = null; + this._currentContext = null; + this._messages = []; + this._isStreaming = false; + this._streamingMessageElement = null; + this._streamingMessageHeader = null; + this._getAppInfo = options.getAppInfo || null; + this._hasShownUsageWarning = false; + this._maxJsonDepth = 10; + this._renderDebounceTimer = null; + this._pendingRender = null; + + this.init(); +} + +/** + * Initialize the AIChat component. + */ +AIChat.prototype.init = function () { + this._render(); + this._attachEventListeners(); + this._checkModelAvailability(); +}; + +/** + * Render the chat UI. + * @private + */ +AIChat.prototype._render = function () { + this._container.innerHTML = ` +
+
+
+ + Checking model status... +
+ + +
+ +
+
+

UI5 AI Assistant

+

Ask questions about UI5 controls, debugging, or general development topics.

+

Select a control in the Control Inspector to automatically include context in your questions.

+
+
+ +
+ +
+ + +
+ +
+ + +
+ `; +}; + +/** + * Attach event listeners. + * @private + */ +AIChat.prototype._attachEventListeners = function () { + const input = document.getElementById('ai-input'); + const sendButton = document.getElementById('ai-send-button'); + const downloadButton = document.getElementById('ai-download-button'); + const clearHistoryButton = document.getElementById('ai-clear-history-button'); + const contextClearButton = document.getElementById('ai-context-clear-button'); + + // Send message on button click + sendButton.addEventListener('click', () => { + this._handleSendMessage(); + }); + + // Send message on Enter + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this._handleSendMessage(); + } + }); + + // Enable/disable send button based on input + input.addEventListener('input', () => { + const hasText = input.value.trim().length > 0; + const canSend = hasText && !this._isStreaming; + sendButton.disabled = !canSend; + }); + + // Download model button + downloadButton.addEventListener('click', () => { + this._handleDownloadModel(); + }); + + // Clear history button + clearHistoryButton.addEventListener('click', () => { + this._handleClearHistory(); + }); + + // Clear context button + contextClearButton.addEventListener('click', () => { + this._clearContext(); + }); + + // Confirmation dialog buttons + const confirmOk = document.getElementById('ai-confirm-ok'); + const confirmCancel = document.getElementById('ai-confirm-cancel'); + const confirmDialog = document.getElementById('ai-confirm-dialog'); + + confirmOk.addEventListener('click', () => { + this._hideConfirmDialog(); + this._performClearHistory(); + }); + + confirmCancel.addEventListener('click', () => { + this._hideConfirmDialog(); + }); + + // Click on overlay to cancel + confirmDialog.querySelector('.confirm-overlay').addEventListener('click', () => { + this._hideConfirmDialog(); + }); + + // ESC key to close dialog + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const dialog = document.getElementById('ai-confirm-dialog'); + if (dialog && dialog.style.display !== 'none') { + this._hideConfirmDialog(); + } + } + }); +}; + +/** + * Check model availability and update UI. + * @private + */ +AIChat.prototype._checkModelAvailability = async function () { + try { + const availability = await this._sessionManager.checkAvailability(); + + if (availability.status === 'ready') { + this._renderModelStatus('ready', 0, 'Gemini Nano is ready'); + // Initialize session to show token counter + await this._initializeSession(); + } else if (availability.status === 'needs-download') { + this._renderModelStatus('needs-download', 0, availability.message); + } else { + this._renderModelStatus('unavailable', 0, availability.message); + } + } catch (error) { + this._renderModelStatus('error', 0, `Error: ${error.message}`); + } +}; + +/** + * Initialize AI session. + * @private + */ +AIChat.prototype._initializeSession = async function () { + try { + await this._sessionManager.createSession(); + document.getElementById('ai-clear-history-button').style.display = 'inline-block'; + this._updateTokenCounter(); + } catch (error) { + this._addSystemMessage(`Error initializing session: ${error.message}`); + } +}; + +/** + * Handle model download. + * @private + */ +AIChat.prototype._handleDownloadModel = async function () { + const downloadButton = document.getElementById('ai-download-button'); + const input = document.getElementById('ai-input'); + const sendButton = document.getElementById('ai-send-button'); + + // Disable UI during download + downloadButton.disabled = true; + input.disabled = true; + sendButton.disabled = true; + + try { + this._renderModelStatus('downloading', 0, 'Starting download...'); + + await this._sessionManager.downloadModel((progress) => { + const percent = Math.round(progress * 100); + this._renderModelStatus('downloading', progress, `Downloading: ${percent}%`); + }); + + this._renderModelStatus('ready', 1, 'Model ready!'); + + // Initialize session after download + await this._initializeSession(); + + // Re-enable UI after successful download + input.disabled = false; + + } catch (error) { + this._renderModelStatus('error', 0, `Download failed: ${error.message}`); + downloadButton.disabled = false; + input.disabled = false; + } +}; + +/** + * Handle send message. + * @private + */ +AIChat.prototype._handleSendMessage = async function () { + const input = document.getElementById('ai-input'); + const userMessage = input.value.trim(); + + if (!userMessage || this._isStreaming) { + return; + } + + if (!this._sessionManager.hasActiveSession()) { + try { + await this._initializeSession(); + } catch (error) { + this._addSystemMessage(`Failed to create session: ${error.message}`); + return; + } + } + + // Clear input + input.value = ''; + document.getElementById('ai-send-button').disabled = true; + + // Add user message to UI + this._addMessage('user', userMessage); + + // Save user message to storage + await this._storageManager.saveMessage(this._currentUrl, { + role: 'user', + content: userMessage, + timestamp: Date.now() + }); + + // Get AI response + try { + this._isStreaming = true; + + // Create placeholder for AI response without copy button (will add after completion) + const messageElement = this._addMessage('assistant', '', false); + this._streamingMessageElement = messageElement.querySelector('.message-content'); + this._streamingMessageHeader = messageElement.querySelector('.message-header'); + + // Add loading indicator as DOM elements (not HTML string to avoid escaping) + const loadingIndicator = document.createElement('span'); + loadingIndicator.className = 'loading-indicator'; + loadingIndicator.textContent = 'Thinking'; + const loadingDots = document.createElement('span'); + loadingDots.className = 'loading-dots'; + loadingIndicator.appendChild(loadingDots); + this._streamingMessageElement.appendChild(loadingIndicator); + + // Build conversation history (exclude the placeholder we just added) + const conversationHistory = this._messages.slice(0, -1); + + // Get app info for context + var appInfo = null; + if (this._getAppInfo) { + appInfo = this._getAppInfo(); + } else if (this._currentContext) { + appInfo = this._currentContext.appInfo; + } + + // Build context object + const context = { + control: this._currentContext ? this._currentContext.control : null, + appInfo: appInfo + }; + + // Get streaming response + const stream = await this._sessionManager.promptStreaming( + userMessage, + conversationHistory, + context + ); + + let fullResponse = ''; + + // Process stream + for await (const chunk of stream) { + fullResponse += chunk; + this._debouncedRender(fullResponse); + } + + // Final render after stream completes + if (this._renderDebounceTimer) { + clearTimeout(this._renderDebounceTimer); + this._renderDebounceTimer = null; + } + this._streamingMessageElement.innerHTML = this._parseMarkdown(fullResponse); + this._initializeJsonViewers(this._streamingMessageElement); + + // Add copy button now that response is complete + const copyButton = document.createElement('button'); + copyButton.className = 'copy-response-button'; + copyButton.title = 'Copy response'; + copyButton.setAttribute('aria-label', 'Copy response'); + copyButton.textContent = 'Copy'; + copyButton.addEventListener('click', (e) => { + this._copyToClipboard(fullResponse, e.currentTarget); + }); + this._streamingMessageHeader.appendChild(copyButton); + + // Save AI response to storage + await this._storageManager.saveMessage(this._currentUrl, { + role: 'assistant', + content: fullResponse, + timestamp: Date.now() + }); + + this._isStreaming = false; + this._streamingMessageElement = null; + this._streamingMessageHeader = null; + + // Update token counter + this._updateTokenCounter(); + + } catch (error) { + this._addSystemMessage(`Error: ${error.message}`); + this._isStreaming = false; + this._streamingMessageElement = null; + this._streamingMessageHeader = null; + } +}; + +/** + * Handle clear history. + * @private + */ +AIChat.prototype._handleClearHistory = function () { + this._showConfirmDialog(); +}; + +/** + * Show confirmation dialog. + * @private + */ +AIChat.prototype._showConfirmDialog = function () { + const dialog = document.getElementById('ai-confirm-dialog'); + dialog.style.display = 'flex'; + + // Store the element that had focus + this._previousFocus = document.activeElement; + + // Focus the cancel button (safer default) + const cancelButton = document.getElementById('ai-confirm-cancel'); + if (cancelButton) { + cancelButton.focus(); + } +}; + +/** + * Hide confirmation dialog. + * @private + */ +AIChat.prototype._hideConfirmDialog = function () { + const dialog = document.getElementById('ai-confirm-dialog'); + dialog.style.display = 'none'; + + // Restore focus to the element that had focus before dialog opened + if (this._previousFocus) { + this._previousFocus.focus(); + } +}; + +/** + * Perform clear history action. + * @private + */ +AIChat.prototype._performClearHistory = async function () { + try { + await this._storageManager.clearHistory(this._currentUrl); + + // Clear messages from UI + this._messages = []; + const messagesContainer = document.getElementById('ai-messages-container'); + messagesContainer.innerHTML = ` +
+

UI5 AI Assistant

+

Chat history cleared. Ask me anything!

+
+ `; + + // Destroy and recreate session to reset token counter + this._sessionManager.destroy(); + await this._initializeSession(); + + // Reset usage warning flag + this._hasShownUsageWarning = false; + + this._addSystemMessage('Chat history cleared'); + + } catch (error) { + this._addSystemMessage(`Error clearing history: ${error.message}`); + } +}; + +/** + * Render model status banner. + * @param {string} status - Status: 'ready', 'needs-download', 'downloading', 'unavailable', 'error' + * @param {number} progress - Download progress (0-1) + * @param {string} message - Status message + */ +AIChat.prototype._renderModelStatus = function (status, progress, message) { + const banner = document.getElementById('ai-status-banner'); + const statusText = banner.querySelector('.status-text'); + const downloadButton = document.getElementById('ai-download-button'); + + banner.className = 'ai-status-banner status-' + status; + statusText.textContent = message; + + // Show/hide download button + if (status === 'needs-download') { + downloadButton.style.display = 'inline-block'; + downloadButton.disabled = false; + } else if (status === 'downloading') { + downloadButton.style.display = 'inline-block'; + downloadButton.disabled = true; + } else { + downloadButton.style.display = 'none'; + } +}; + +/** + * Add a message to the chat UI. + * @param {string} role - 'user', 'assistant', or 'system' + * @param {string} content - Message content + * @param {boolean} showCopyButton - Whether to show copy button for assistant messages (default: true) + * @returns {HTMLElement} - The message element + */ +AIChat.prototype._addMessage = function (role, content, showCopyButton) { + const messagesContainer = document.getElementById('ai-messages-container'); + + // Remove welcome message if it exists + const welcomeMessage = messagesContainer.querySelector('.ai-welcome-message'); + if (welcomeMessage) { + welcomeMessage.remove(); + } + + const messageElement = document.createElement('div'); + messageElement.className = 'ai-message message-' + role; + + // Use markdown rendering for AI responses, escape HTML for user/system messages + const formattedContent = role === 'assistant' ? this._parseMarkdown(content) : this._escapeHtml(content); + + // Default showCopyButton to true for assistant messages + const shouldShowCopyButton = role === 'assistant' && (showCopyButton === undefined || showCopyButton === true); + + messageElement.innerHTML = ` +
+ ${role === 'user' ? 'You' : role === 'assistant' ? 'AI' : 'System'} + ${shouldShowCopyButton ? '' : ''} +
+
${formattedContent}
+ `; + + messagesContainer.appendChild(messageElement); + + // Initialize JSON viewers if this is an assistant message + if (role === 'assistant') { + const contentElement = messageElement.querySelector('.message-content'); + this._initializeJsonViewers(contentElement); + + // Add copy button event listener if button exists + const copyButton = messageElement.querySelector('.copy-response-button'); + if (copyButton) { + copyButton.addEventListener('click', (e) => { + this._copyToClipboard(content, e.currentTarget); + }); + } + } + + // Force scroll to bottom when adding new message + this._scrollToBottom(true); + + this._messages.push({ role, content }); + + return messageElement; +}; + +/** + * Add a system message. + * @param {string} message + */ +AIChat.prototype._addSystemMessage = function (message) { + this._addMessage('system', message); +}; + +/** + * Escape HTML to prevent XSS. + * @private + * @param {string} text + * @returns {string} + */ +AIChat.prototype._escapeHtml = function (text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +}; + +/** + * Parse markdown to HTML for AI responses. + * @private + * @param {string} text - Markdown text + * @returns {string} - HTML string + */ +AIChat.prototype._parseMarkdown = function (text) { + const placeholders = { codeBlocks: [], inlineCode: [] }; + + // Step 1: Extract code blocks and inline code + let html = this._extractCodeBlocks(text, placeholders); + html = this._extractInlineCode(html, placeholders); + + // Step 2: Escape HTML + html = this._escapeHtml(html); + + // Step 3: Apply markdown formatting + html = this._applyMarkdownFormatting(html); + + // Step 4: Convert line breaks BEFORE restoring code (so
doesn't appear inside tags) + html = html.replace(/\n/g, '
'); + + // Step 5: Restore code (after line breaks so code blocks keep their original formatting) + html = this._restoreInlineCode(html, placeholders.inlineCode); + html = this._restoreCodeBlocks(html, placeholders.codeBlocks); + + return html; +}; + +/** + * Extract code blocks from text. + * @private + */ +AIChat.prototype._extractCodeBlocks = function (text, placeholders) { + return text.replace(/```([\w]*)?\n([\s\S]*?)```/g, (match, lang, code) => { + const index = placeholders.codeBlocks.length; + const trimmedCode = code.trim(); + const isJson = lang === 'json' || (!lang && /^[\[\{]/.test(trimmedCode)); + + if (isJson) { + try { + placeholders.codeBlocks.push({ type: 'json', data: JSON.parse(trimmedCode) }); + } catch (e) { + placeholders.codeBlocks.push({ type: 'code', lang: 'plaintext', code: trimmedCode }); + } + } else { + placeholders.codeBlocks.push({ type: 'code', lang: lang || 'plaintext', code: trimmedCode }); + } + + return `___CODEBLOCK_${index}___`; + }); +}; + +/** + * Extract inline code from text. + * @private + */ +AIChat.prototype._extractInlineCode = function (text, placeholders) { + return text.replace(/`([^`]+)`/g, (match, code) => { + const index = placeholders.inlineCode.length; + placeholders.inlineCode.push(code); + return `___INLINECODE_${index}___`; + }); +}; + +/** + * Apply markdown formatting (bold, italic, links). + * @private + */ +AIChat.prototype._applyMarkdownFormatting = function (text) { + return text + .replace(/\*\*([^*]+)\*\*/g, '$1') // Bold + .replace(/\b__([^_]+)__\b/g, '$1') // Bold (alt) + .replace(/(?]+)(?$1') // Italic + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); // Links +}; + +/** + * Restore inline code. + * @private + */ +AIChat.prototype._restoreInlineCode = function (text, inlineCode) { + inlineCode.forEach((code, index) => { + text = text.replace(`___INLINECODE_${index}___`, `${this._escapeHtml(code)}`); + }); + return text; +}; + +/** + * Restore code blocks. + * @private + */ +AIChat.prototype._restoreCodeBlocks = function (text, codeBlocks) { + codeBlocks.forEach((block, index) => { + let replacement; + if (block.type === 'json') { + replacement = this._createJsonViewer(block.data); + } else { + // Use data attribute approach like JSON viewer + replacement = this._createCodeViewer(block.code, block.lang); + } + text = text.replace(`___CODEBLOCK_${index}___`, replacement); + }); + return text; +}; + +/** + * Create JSON viewer HTML. + * @private + */ +AIChat.prototype._createJsonViewer = function (data) { + const jsonString = JSON.stringify(data).replace(/'/g, '''); + return `
`; +}; + +/** + * Create code viewer HTML. + * @private + */ +AIChat.prototype._createCodeViewer = function (code, lang) { + const escapedCode = code.replace(/'/g, ''').replace(/"/g, '"'); + return `
`; +}; + +/** + * Render interactive JSON viewer with expand/collapse. + * @private + */ +AIChat.prototype._renderJsonValue = function (value, key, isLast, depth) { + depth = depth || 0; + + if (depth > this._maxJsonDepth) { + const comma = isLast ? '' : ','; + return this._renderJsonLine(key, `[Max depth reached]${comma}`); + } + + const comma = isLast ? '' : ','; + const handlers = { + null: () => this._renderJsonLine(key, `null${comma}`), + boolean: () => this._renderJsonLine(key, `${value}${comma}`), + number: () => this._renderJsonLine(key, `${value}${comma}`), + string: () => this._renderJsonString(key, value, comma), + array: () => this._renderJsonArray(key, value, comma, depth), + object: () => this._renderJsonObject(key, value, comma, depth) + }; + + const type = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value; + return handlers[type] ? handlers[type]() : this._renderJsonLine(key, `${this._escapeHtml(String(value))}${comma}`); +}; + +/** + * Render JSON string value. + * @private + */ +AIChat.prototype._renderJsonString = function (key, value, comma) { + const escaped = this._escapeHtml(value); + return this._renderJsonLine(key, `"${escaped}"${comma}`); +}; + +/** + * Render JSON array. + * @private + */ +AIChat.prototype._renderJsonArray = function (key, value, comma, depth) { + if (value.length === 0) { + return this._renderJsonLine(key, `[]${comma}`); + } + + const id = 'json-' + Math.random().toString(36).substr(2, 9); + const keyHtml = key ? `"${this._escapeHtml(key)}": ` : ''; + const items = value.map((item, i) => this._renderJsonValue(item, null, i === value.length - 1, depth + 1)).join(''); + + return `
${keyHtml} [${value.length} items
+
${items}
]${comma}
`; +}; + +/** + * Render JSON object. + * @private + */ +AIChat.prototype._renderJsonObject = function (key, value, comma, depth) { + const keys = Object.keys(value); + if (keys.length === 0) { + return this._renderJsonLine(key, `{}${comma}`); + } + + const id = 'json-' + Math.random().toString(36).substr(2, 9); + const keyHtml = key ? `"${this._escapeHtml(key)}": ` : ''; + const items = keys.map((k, i) => this._renderJsonValue(value[k], k, i === keys.length - 1, depth + 1)).join(''); + + return `
${keyHtml} {${keys.length} keys
+
${items}
}${comma}
`; +}; + +/** + * Render a single JSON line. + * @private + * @param {string} key - Key name (null for array items) + * @param {string} content - HTML content + * @returns {string} - HTML string + */ +AIChat.prototype._renderJsonLine = function (key, content) { + let html = '
'; + + if (key !== null) { + html += '"' + this._escapeHtml(key) + '": '; + } + + html += content; + html += '
'; + + return html; +}; + +/** + * Initialize JSON viewers in a message element. + * @private + */ +AIChat.prototype._initializeJsonViewers = function (element) { + // Initialize JSON viewers + element.querySelectorAll('.json-viewer').forEach(viewer => { + const jsonData = viewer.getAttribute('data-json'); + if (!jsonData) { + return; + } + + try { + const parsed = JSON.parse(jsonData); + viewer.innerHTML = `
+ +
${this._renderJsonValue(parsed, null, true)}
+
`; + + this._setupJsonToggleHandlers(viewer); + + // Add copy button event listener + const copyButton = viewer.querySelector('.copy-code-button'); + if (copyButton) { + copyButton.addEventListener('click', (e) => { + this._copyToClipboard(JSON.stringify(parsed, null, 2), e.currentTarget); + }); + } + } catch (e) { + viewer.textContent = `Error rendering JSON: ${e.message}`; + console.error('JSON viewer error:', e); + } + }); + + // Initialize code viewers + element.querySelectorAll('.code-viewer').forEach(viewer => { + const code = viewer.getAttribute('data-code'); + const lang = viewer.getAttribute('data-lang'); + if (!code) { + return; + } + + try { + viewer.innerHTML = this._renderCodeBlock(code, lang); + + // Add copy button event listener + const copyButton = viewer.querySelector('.copy-code-button'); + if (copyButton) { + copyButton.addEventListener('click', (e) => { + this._copyToClipboard(code, e.currentTarget); + }); + } + } catch (e) { + viewer.textContent = `Error rendering code: ${e.message}`; + console.error('Code viewer error:', e); + } + }); +}; + +/** + * Setup toggle handlers for JSON expand/collapse. + * @private + */ +AIChat.prototype._setupJsonToggleHandlers = function (viewer) { + viewer.querySelectorAll('.json-toggle').forEach(toggle => { + toggle.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + + const content = document.getElementById(toggle.getAttribute('data-target')); + if (content) { + const isCollapsed = content.style.display === 'none'; + content.style.display = isCollapsed ? 'block' : 'none'; + toggle.textContent = isCollapsed ? '▼' : '▶'; + } + }); + }); +}; + +/** + * Render code block as DOM elements. + * @private + */ +AIChat.prototype._renderCodeBlock = function (code, lang) { + const lines = code.split('\n'); + const linesHtml = lines.map(line => { + const escapedLine = this._escapeHtml(line || ' '); + return `
${escapedLine}
`; + }).join(''); + + const langLabel = lang && lang !== 'plaintext' ? `
${lang}
` : ''; + const copyButton = ''; + + return `
${langLabel}${copyButton}
${linesHtml}
`; +}; + +/** + * Check if user is scrolled to bottom (within threshold). + * @private + * @returns {boolean} + */ +AIChat.prototype._isScrolledToBottom = function () { + const messagesContainer = document.getElementById('ai-messages-container'); + if (!messagesContainer) { + return true; + } + + const threshold = 100; // pixels from bottom + const scrollPosition = messagesContainer.scrollTop + messagesContainer.clientHeight; + const scrollHeight = messagesContainer.scrollHeight; + + return scrollHeight - scrollPosition < threshold; +}; + +/** + * Scroll messages container to bottom (only if user is already at bottom). + * @private + * @param {boolean} force - Force scroll even if user scrolled up + */ +AIChat.prototype._scrollToBottom = function (force) { + const messagesContainer = document.getElementById('ai-messages-container'); + if (!messagesContainer || messagesContainer.scrollHeight === undefined) { + return; + } + + // Only auto-scroll if user is already at bottom, or if forced + if (force || this._isScrolledToBottom()) { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } +}; + +/** + * Debounced render for streaming content to improve performance. + * @private + * @param {string} content - Content to render + */ +AIChat.prototype._debouncedRender = function (content) { + this._pendingRender = content; + + if (this._renderDebounceTimer) { + return; + } + + this._renderDebounceTimer = setTimeout(() => { + if (this._pendingRender && this._streamingMessageElement) { + this._streamingMessageElement.innerHTML = this._parseMarkdown(this._pendingRender); + this._initializeJsonViewers(this._streamingMessageElement); + // Don't force scroll during streaming - let user scroll freely + this._scrollToBottom(false); + } + this._renderDebounceTimer = null; + }, 50); // 50ms debounce +}; + +/** + * Update token counter display. + * @private + */ +AIChat.prototype._updateTokenCounter = async function () { + const counter = document.getElementById('ai-token-counter'); + const input = document.getElementById('ai-input'); + const sendButton = document.getElementById('ai-send-button'); + + try { + const usageInfo = await this._sessionManager.getUsageInfo(); + + if (usageInfo) { + counter.textContent = `Tokens: ${usageInfo.inputUsage}/${usageInfo.inputQuota} (${usageInfo.percentUsed}%)`; + + // Remove all warning classes first + counter.classList.remove('warning', 'warning-critical', 'quota-exhausted'); + + if (usageInfo.percentUsed >= 100) { + counter.classList.add('quota-exhausted'); + input.disabled = true; + sendButton.disabled = true; + input.placeholder = 'Token quota exhausted. Clear history to continue.'; + } else if (usageInfo.percentUsed >= 90) { + counter.classList.add('warning-critical'); + } else if (usageInfo.percentUsed >= 70) { + counter.classList.add('warning'); + } + + // Show usage warning when reaching 70% (only once per session) + this._checkTokenUsageWarning(usageInfo.percentUsed); + } else { + counter.textContent = ''; + } + } catch (error) { + counter.textContent = ''; + } +}; + +/** + * Check if token usage warning should be displayed. + * @private + * @param {number} percentUsed - Percentage of token quota used + */ +AIChat.prototype._checkTokenUsageWarning = function (percentUsed) { + // Show warning at 70% usage, only once per session + if (percentUsed >= 70 && !this._hasShownUsageWarning) { + this._hasShownUsageWarning = true; + + const warningMessage = '💡 Your conversation is getting long (' + percentUsed + '% of token limit used). ' + + 'For faster responses and better performance, consider clearing the chat history to start fresh. ' + + 'Click "Clear History" button above.'; + + this._addSystemMessage(warningMessage); + } +}; + +/** + * Clear current context. + * @private + */ +AIChat.prototype._clearContext = function () { + this._currentContext = null; + const contextInfo = document.getElementById('ai-context-info'); + contextInfo.style.display = 'none'; + + // Add a system message to inform AI that context was cleared + this._addSystemMessage('❌ Context cleared - no control is currently selected'); +}; + +/** + * Update current context (control and app info). + * @param {Object} context - {control, appInfo} + */ +AIChat.prototype.updateContext = function (context) { + this._currentContext = context; + + const contextInfo = document.getElementById('ai-context-info'); + const contextText = contextInfo.querySelector('.context-text'); + + if (context && context.control) { + contextInfo.style.display = 'flex'; + contextText.textContent = `Context: ${context.control.type || 'Control'} (${context.control.id || 'no ID'})`; + } else { + contextInfo.style.display = 'none'; + } +}; + +/** + * Called when AI tab is activated. + */ +AIChat.prototype.onTabActivated = function () { + // Load chat history if we have a URL + if (this._currentUrl) { + this._loadHistory(); + } + + // Force scroll to bottom when context changes + this._scrollToBottom(true); +}; + +/** + * Set current inspected URL. + * @param {string} url + */ +AIChat.prototype.setUrl = function (url) { + if (this._currentUrl !== url) { + this._currentUrl = url; + this._loadHistory(); + } +}; + +/** + * Load chat history from storage. + * @private + */ +AIChat.prototype._loadHistory = async function () { + try { + const messages = await this._storageManager.loadHistory(this._currentUrl); + + if (messages.length > 0) { + const messagesContainer = document.getElementById('ai-messages-container'); + messagesContainer.innerHTML = ''; + + messages.forEach(msg => { + this._addMessage(msg.role, msg.content); + }); + + document.getElementById('ai-clear-history-button').style.display = 'inline-block'; + // Force scroll to bottom when loading history + this._scrollToBottom(true); + } + } catch (error) { + // Fail silently + } +}; + +/** + * Copy text to clipboard. + * @private + * @param {string} text - Text to copy + * @param {HTMLElement} button - The button element that triggered the copy + */ +AIChat.prototype._copyToClipboard = function (text, button) { + // Create temporary textarea (must be visible for copy to work) + const textarea = document.createElement('textarea'); + textarea.value = text; + // Position off-screen but keep visible (opacity 0 can block copy in some contexts) + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.left = '-9999px'; + textarea.setAttribute('readonly', ''); + document.body.appendChild(textarea); + + // Select the text + textarea.focus(); + textarea.select(); + + // For iOS compatibility + textarea.setSelectionRange(0, text.length); + + try { + // Execute copy command + const successful = document.execCommand('copy'); + + if (successful) { + // Change button text to "Copied!" + const originalText = button.textContent; + button.textContent = 'Copied!'; + button.disabled = true; + + // Revert back after 1.5 seconds + setTimeout(() => { + button.textContent = originalText; + button.disabled = false; + }, 1500); + } else { + console.error('execCommand returned false'); + this._addSystemMessage('Failed to copy to clipboard'); + } + } catch (err) { + console.error('Copy failed:', err); + this._addSystemMessage('Failed to copy to clipboard'); + } finally { + // Clean up + document.body.removeChild(textarea); + } +}; + +/** + * Destroy the component and cleanup. + */ +AIChat.prototype.destroy = function () { + this._sessionManager.destroy(); +}; + +module.exports = AIChat; diff --git a/app/scripts/modules/ui/ControlTree.js b/app/scripts/modules/ui/ControlTree.js index 39c31cd4..75c5b96e 100644 --- a/app/scripts/modules/ui/ControlTree.js +++ b/app/scripts/modules/ui/ControlTree.js @@ -439,6 +439,11 @@ ControlTree.prototype._selectTreeElement = function (targetElement) { * @param {Element} target - DOM element to which need to be scrolled */ ControlTree.prototype._scrollToElement = function (target) { + // Guard against null elements or missing layout properties (e.g., in test environments) + if (!target || !this._treeContainer || this._treeContainer.offsetHeight === undefined) { + return; + } + var desiredViewBottomPosition = this._treeContainer.offsetHeight - this._treeContainer.offsetTop + this._treeContainer.scrollTop; if (target.offsetTop > desiredViewBottomPosition || target.offsetTop < this._treeContainer.scrollTop) { diff --git a/app/styles/less/modules/AIChat.less b/app/styles/less/modules/AIChat.less new file mode 100644 index 00000000..e1f672d9 --- /dev/null +++ b/app/styles/less/modules/AIChat.less @@ -0,0 +1,785 @@ +// AIChat component styles + +.ai-chat-wrapper { + display: flex; + flex-direction: column; + height: 100%; + background-color: @bg-white; +} + +#ai-chat { + width: 100%; +} + +// Status Banner +.ai-status-banner { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid @border-gray-lighter; + background-color: @bg-white; + + .status-content { + display: flex; + align-items: center; + gap: 8px; + } + + .status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: @gray; + } + + .status-text { + font-size: 13px; + color: @text-color; + } + + .download-button, + .clear-history-button { + padding: 6px 12px; + border: 1px solid @lighter-blue; + background-color: @lighter-blue; + color: @white; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-left: 8px; + + &:hover:not(:disabled) { + background-color: darken(@lighter-blue, 10%); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .clear-history-button { + background-color: @gray; + border-color: @gray; + + &:hover:not(:disabled) { + background-color: @gray-dark; + } + } + + // Status-specific styles + &.status-ready { + background-color: fade(@lighter-blue, 20%); + border-color: @border-gray-lighter; + + .status-indicator { + background-color: @lighter-blue; + } + } + + &.status-needs-download { + background-color: fade(@purple, 15%); + border-color: @border-gray-lighter; + + .status-indicator { + background-color: @purple; + } + } + + &.status-downloading { + background-color: fade(@lighter-blue, 15%); + border-color: @border-gray-lighter; + + .status-indicator { + background-color: @lighter-blue; + animation: pulse 1.5s ease-in-out infinite; + } + } + + &.status-unavailable, + &.status-error { + background-color: fade(@red-saturated, 15%); + border-color: @border-gray-lighter; + + .status-indicator { + background-color: @red-saturated; + } + + .status-text { + white-space: pre-line; + font-size: 11px; + } + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +// Messages Container +.ai-messages-container { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.ai-welcome-message { + text-align: center; + padding: 32px 16px; + color: @gray-dark; + + h3 { + margin: 0 0 12px 0; + color: @text-color; + font-size: 18px; + } + + p { + margin: 8px 0; + font-size: 13px; + line-height: 1.5; + } +} + +// Message Bubbles +.ai-message { + display: flex; + flex-direction: column; + max-width: 80%; + animation: fadeIn 0.2s ease-in; + + &.message-user { + align-self: flex-end; + max-width: 70%; + + .message-header { + justify-content: flex-end; + } + + .message-content { + background-color: @lighter-blue; + color: @white; + border-radius: 12px 12px 0 12px; + padding: 10px 12px; + } + } + + &.message-assistant { + align-self: flex-start; + + .message-content { + background-color: @bg-white; + color: @white; + border: 1px solid @border-gray-lighter; + border-radius: 12px 12px 12px 0; + } + } + + &.message-system { + align-self: center; + max-width: 90%; + + .message-content { + background-color: @bg-gray-lightest; + color: @text-color; + border-radius: 8px; + text-align: center; + font-size: 12px; + font-style: italic; + } + } +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + font-size: 11px; + color: @gray-dark; + padding: 0 8px; + + .message-role { + font-weight: 600; + } + + .copy-response-button { + padding: 4px 8px; + border: 1px solid @border-gray-lighter; + background-color: @bg-white; + color: @text-color; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + transition: all 0.2s; + + &:hover { + background-color: @lighter-blue; + color: @white; + border-color: @lighter-blue; + } + + &:active { + transform: scale(0.95); + } + } +} + +.message-content { + padding: 10px 14px; + word-wrap: break-word; + white-space: normal; + font-size: 13px; + line-height: 1.5; + + // Markdown styling + code { + background-color: fade(@gray, 20%); + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + } + + pre { + background-color: fade(@gray, 15%); + border: 1px solid @border-gray-lighter; + border-radius: 6px; + padding: 12px; + overflow-x: auto; + margin: 8px 0; + + code { + background: none; + padding: 0; + font-size: 12px; + line-height: 1.4; + } + } + + strong { + font-weight: 600; + } + + em { + font-style: italic; + } + + a { + color: @lighter-blue; + text-decoration: underline; + + &:hover { + opacity: 0.8; + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Input Area +.ai-input-area { + border-top: 1px solid @border-gray-lighter; + background-color: @bg-white; + padding: 12px 16px; +} + +.context-info { + display: none; + align-items: center; + gap: 6px; + padding: 8px 12px; + margin-bottom: 8px; + background-color: fade(@lighter-blue, 15%); + border-radius: 6px; + font-size: 12px; + color: @text-color; + + .context-icon { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 2px; + position: relative; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + border: 2px solid @lighter-blue; + border-radius: 50%; + } + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 4px; + height: 4px; + background-color: @lighter-blue; + border-radius: 50%; + } + } + + .context-text { + flex: 1; + font-weight: 500; + } + + .context-clear-button { + background: none; + border: none; + color: @text-color; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + opacity: 0.6; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + } +} + +.input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.ai-input { + flex: 1; + padding: 10px 12px; + border: 1px solid @border-gray-lighter; + border-radius: 6px; + font-family: inherit; + font-size: 13px; + resize: none; + background-color: @bg-white; + color: @text-color; + + &:focus { + outline: none; + border-color: @lighter-blue; + box-shadow: 0 0 0 2px fade(@lighter-blue, 10%); + } + + &::placeholder { + color: @gray; + } +} + +.ai-send-button { + padding: 10px 20px; + background-color: @lighter-blue; + color: @white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + transition: background-color 0.2s; + + &:hover:not(:disabled) { + background-color: darken(@lighter-blue, 10%); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.input-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding: 0 4px; +} + +.token-counter { + font-size: 12px; + color: @gray-dark; + font-weight: 500; + padding: 4px 8px; + background-color: fade(@gray, 10%); + border-radius: 4px; + + &.warning { + color: @red-saturated; + background-color: fade(@red-saturated, 10%); + font-weight: 600; + } +} + +// Scrollbar styling +.ai-messages-container { + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: @bg-gray-lightest; + } + + &::-webkit-scrollbar-thumb { + background: @gray; + border-radius: 4px; + + &:hover { + background: @gray-dark; + } + } +} + +// Responsive adjustments for narrow panels +@media (max-width: 600px) { + .ai-message { + max-width: 90%; + } + + .ai-status-banner { + flex-direction: column; + align-items: flex-start; + gap: 8px; + + .status-content { + width: 100%; + } + + .download-button, + .clear-history-button { + width: 100%; + margin-left: 0; + } + } +} + +// JSON Viewer styling +.json-viewer { + margin: 8px 0; + + .json-wrapper { + position: relative; + background: @bg-gray-lightest; + border: 1px solid @border-gray-lighter; + border-radius: 4px; + padding: 8px 12px 12px 12px; + overflow-x: auto; + } + + .copy-code-button { + position: absolute; + top: 6px; + right: 6px; + background: @lighter-blue; + color: @white; + border: none; + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; + font-size: 11px; + z-index: 10; + transition: all 0.2s; + opacity: 0.9; + + &:hover { + background: darken(@lighter-blue, 10%); + opacity: 1; + } + + &:active { + transform: scale(0.95); + } + } + + .json-tree { + padding-top: 4px; + } + + .json-tree { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 12px; + line-height: 1.6; + color: @text-color; + } + + .json-line { + padding-left: 16px; + position: relative; + } + + .json-content { + padding-left: 16px; + + > .json-line:last-child { + padding-left: 0; + } + } + + .json-toggle { + cursor: pointer; + user-select: none; + display: inline-block; + width: 14px; + color: @gray-dark; + font-size: 10px; + + &:hover { + color: @lighter-blue; + } + } + + .json-count { + color: @gray-dark; + font-size: 11px; + margin-left: 6px; + font-style: italic; + } + + .json-key { + color: #0451a5; // Blue for keys + font-weight: 600; + } + + .json-string { + color: #a31515; // Red for strings + } + + .json-number { + color: #098658; // Green for numbers + } + + .json-boolean { + color: #0000ff; // Blue for booleans + font-weight: 600; + } + + .json-null { + color: #808080; // Gray for null + font-style: italic; + } + + .json-bracket { + color: @text-color; + font-weight: bold; + } +} + +// Code Viewer styling (similar to JSON viewer) +.code-viewer { + margin: 8px 0; + + .code-wrapper { + position: relative; + background: @bg-gray-lightest; + border: 1px solid @border-gray-lighter; + border-radius: 4px; + padding: 8px 12px 12px 12px; + overflow-x: auto; + } + + .copy-code-button { + position: absolute; + top: 6px; + right: 6px; + background: @lighter-blue; + color: @white; + border: none; + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; + font-size: 11px; + z-index: 10; + transition: all 0.2s; + opacity: 0.9; + + &:hover { + background: darken(@lighter-blue, 10%); + opacity: 1; + } + + &:active { + transform: scale(0.95); + } + } + + .code-lang { + position: absolute; + top: 6px; + left: 6px; + background: @gray; + color: @white; + border-radius: 4px; + padding: 2px 8px; + font-size: 10px; + text-transform: uppercase; + font-weight: 600; + opacity: 0.7; + } + + .code-content { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 12px; + line-height: 1.6; + color: @text-color; + padding-top: 24px; // Add space above code for copy button and language label + } + + .code-line { + white-space: pre-wrap; + word-wrap: break-word; + + &:empty::before { + content: ' '; + display: inline-block; + } + } +} + +// Confirmation Dialog +.ai-confirm-dialog { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + + .confirm-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); + } + + .confirm-content { + position: relative; + background: @bg-white; + border-radius: 8px; + padding: 24px; + max-width: 400px; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + z-index: 1; + animation: confirmSlideIn 0.2s ease-out; + } + + .confirm-title { + font-size: 16px; + font-weight: 600; + color: @text-color; + margin-bottom: 12px; + } + + .confirm-message { + font-size: 13px; + color: @gray-dark; + line-height: 1.5; + margin-bottom: 20px; + } + + .confirm-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + } + + .confirm-button { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: background-color 0.2s; + + &.confirm-cancel { + background-color: @bg-gray-lightest; + color: @text-color; + + &:hover { + background-color: darken(@bg-gray-lightest, 10%); + } + } + + &.confirm-ok { + background-color: @red-saturated; + color: @white; + + &:hover { + background-color: darken(@red-saturated, 10%); + } + } + } +} + +@keyframes confirmSlideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.loading-indicator { + color: @gray; + font-style: italic; + + .loading-dots::after { + content: ''; + animation: loadingDots 1.5s steps(4, end) infinite; + } +} + +@keyframes loadingDots { + 0%, 20% { + content: ''; + } + 40% { + content: '.'; + } + 60% { + content: '..'; + } + 80%, 100% { + content: '...'; + } +} diff --git a/app/styles/less/themes/base/base.less b/app/styles/less/themes/base/base.less index a185f9de..0efa72e9 100644 --- a/app/styles/less/themes/base/base.less +++ b/app/styles/less/themes/base/base.less @@ -17,4 +17,5 @@ @import "../../modules/XMLView.less"; @import "../../modules/DataGrid.less"; @import "../../modules/ElementsRegistry.less"; +@import "../../modules/AIChat.less"; diff --git a/app/styles/less/themes/dark/dark.less b/app/styles/less/themes/dark/dark.less index 26008658..59f4be66 100644 --- a/app/styles/less/themes/dark/dark.less +++ b/app/styles/less/themes/dark/dark.less @@ -32,3 +32,36 @@ @bg-datagrid-header-background-color: @black; @bg-datagrid-th-sortable-background-color: #252525; @bg-datagrid-tr-hover-background-color: rgba(201, 135, 48, 0.1); + +// Dark theme specific overrides for AI Chat +.ai-message.message-assistant .message-content { + color: @white; +} + +// Dark theme JSON viewer colors +.json-viewer { + .json-content { + background: #1e1e1e; + border-color: #3c3c3c; + } + + .json-key { + color: #9cdcfe; // Light blue for keys + } + + .json-string { + color: #ce9178; // Light orange for strings + } + + .json-number { + color: #b5cea8; // Light green for numbers + } + + .json-boolean { + color: #569cd6; // Blue for booleans + } + + .json-null { + color: #808080; // Gray for null + } +} diff --git a/app/styles/less/themes/light/light.less b/app/styles/less/themes/light/light.less index 7703013e..ae8edd3c 100644 --- a/app/styles/less/themes/light/light.less +++ b/app/styles/less/themes/light/light.less @@ -32,3 +32,8 @@ @bg-datagrid-header-background-color: #f2f2f2; @bg-datagrid-th-sortable-background-color: #e6e6e6; @bg-datagrid-tr-hover-background-color: AliceBlue; + +// Light theme specific overrides for AI Chat +.ai-message.message-assistant .message-content { + color: @gray-darkest !important; +} diff --git a/tests/modules/ai/AISessionManager.spec.js b/tests/modules/ai/AISessionManager.spec.js new file mode 100644 index 00000000..d1d843ec --- /dev/null +++ b/tests/modules/ai/AISessionManager.spec.js @@ -0,0 +1,240 @@ +'use strict'; + +var AISessionManager = require('../../../app/scripts/modules/ai/AISessionManager.js'); + +describe('AISessionManager', function () { + var sessionManager; + + beforeEach(function () { + sessionManager = new AISessionManager(); + }); + + afterEach(function () { + sessionManager = null; + }); + + describe('Constructor', function () { + it('should initialize with null port', function () { + (sessionManager._port === null).should.be.true; + }); + + it('should initialize with empty message handlers', function () { + Object.keys(sessionManager._messageHandlers).should.have.lengthOf(0); + }); + + it('should initialize with disconnected state', function () { + sessionManager._isConnected.should.be.false; + }); + + it('should initialize with no active session', function () { + sessionManager._hasActiveSession.should.be.false; + }); + }); + + describe('#_on() and #_off()', function () { + it('should register message handler', function () { + var handler = function () {}; + sessionManager._on('test-type', handler); + + sessionManager._messageHandlers['test-type'].should.equal(handler); + }); + + it('should remove message handler', function () { + var handler = function () {}; + sessionManager._on('test-type', handler); + sessionManager._off('test-type'); + + (sessionManager._messageHandlers['test-type'] === undefined).should.be.true; + }); + }); + + describe('#_getDefaultSystemPrompt()', function () { + it('should return base prompt without app info', function () { + var prompt = sessionManager._getDefaultSystemPrompt(); + + prompt.should.contain('AI assistant'); + prompt.should.contain('UI5 Inspector'); + prompt.should.not.contain('Current Application Context'); + }); + + it('should include framework info when available', function () { + var appInfo = { + common: { + data: { + SAPUI5: '1.120.0' + } + } + }; + + var prompt = sessionManager._getDefaultSystemPrompt(appInfo); + + prompt.should.contain('Current Application Context'); + prompt.should.contain('Framework: 1.120.0'); + }); + + it('should include theme when available', function () { + var appInfo = { + configurationComputed: { + data: { + theme: 'sap_horizon' + } + } + }; + + var prompt = sessionManager._getDefaultSystemPrompt(appInfo); + + prompt.should.contain('Theme: sap_horizon'); + }); + + it('should include loaded libraries when available', function () { + var appInfo = { + loadedLibraries: { + data: { + 'sap.m': {}, + 'sap.ui.core': {} + } + } + }; + + var prompt = sessionManager._getDefaultSystemPrompt(appInfo); + + prompt.should.contain('Loaded Libraries: sap.m, sap.ui.core'); + }); + }); + + describe('#_truncateJson()', function () { + it('should return JSON string when under limit', function () { + var data = { key: 'value' }; + var result = sessionManager._truncateJson(data, 1000); + + result.should.contain('"key"'); + result.should.contain('"value"'); + }); + + it('should truncate JSON when over limit', function () { + var largeData = {}; + for (var i = 0; i < 200; i++) { + largeData['property' + i] = 'value'.repeat(20); + } + + var result = sessionManager._truncateJson(largeData, 100); + + result.should.contain('[truncated]'); + result.length.should.be.at.most(115); + }); + + it('should handle circular references gracefully', function () { + var circular = {}; + circular.self = circular; + + var result = sessionManager._truncateJson(circular, 1000); + + result.should.contain('cannot serialize'); + }); + }); + + describe('#_formatPrompt()', function () { + it('should return prompt unchanged when no context', function () { + var result = sessionManager._formatPrompt('Test prompt', null); + + result.should.equal('Test prompt'); + }); + + it('should return prompt unchanged when no control', function () { + var result = sessionManager._formatPrompt('Test prompt', {}); + + result.should.equal('Test prompt'); + }); + + it('should add control type and ID', function () { + var context = { + control: { + type: 'sap.m.Button', + id: 'myButton' + } + }; + + var result = sessionManager._formatPrompt('Test prompt', context); + + result.should.contain('Type: sap.m.Button'); + result.should.contain('ID: myButton'); + result.should.contain('User Question: Test prompt'); + }); + + it('should truncate large properties', function () { + var largeData = {}; + for (var i = 0; i < 200; i++) { + largeData['property' + i] = 'value'.repeat(20); + } + + var context = { + control: { + type: 'sap.m.Button', + properties: { + own: { + data: largeData + } + } + } + }; + + var result = sessionManager._formatPrompt('Test', context); + + result.should.contain('[truncated]'); + }); + }); + + describe('#_buildMessagesArray()', function () { + it('should include system prompt', function () { + var messages = sessionManager._buildMessagesArray('Hello', [], null); + + messages[0].role.should.equal('system'); + messages[0].content.should.contain('AI assistant'); + }); + + it('should include conversation history', function () { + var history = [ + { role: 'user', content: 'First question' }, + { role: 'assistant', content: 'First answer' } + ]; + + var messages = sessionManager._buildMessagesArray('Second question', history, null); + + messages.should.have.lengthOf(4); + messages[1].content.should.equal('First question'); + messages[2].content.should.equal('First answer'); + }); + + it('should format user message with context', function () { + var context = { + control: { + type: 'sap.m.Button', + id: 'btn1' + } + }; + + var messages = sessionManager._buildMessagesArray('What is this?', [], context); + + messages[1].role.should.equal('user'); + messages[1].content.should.contain('Type: sap.m.Button'); + messages[1].content.should.contain('What is this?'); + }); + + it('should handle empty conversation history', function () { + var messages = sessionManager._buildMessagesArray('Hello', null, null); + + messages.should.have.lengthOf(2); + }); + }); + + describe('#hasActiveSession()', function () { + it('should return false initially', function () { + sessionManager.hasActiveSession().should.be.false; + }); + + it('should return true after session set', function () { + sessionManager._hasActiveSession = true; + sessionManager.hasActiveSession().should.be.true; + }); + }); +}); diff --git a/tests/modules/ai/ChatStorageManager.spec.js b/tests/modules/ai/ChatStorageManager.spec.js new file mode 100644 index 00000000..b59d95a0 --- /dev/null +++ b/tests/modules/ai/ChatStorageManager.spec.js @@ -0,0 +1,53 @@ +'use strict'; + +var ChatStorageManager = require('../../../app/scripts/modules/ai/ChatStorageManager.js'); + +describe('ChatStorageManager', function () { + var storageManager; + + beforeEach(function () { + storageManager = new ChatStorageManager(); + }); + + afterEach(function () { + storageManager = null; + }); + + describe('Constructor', function () { + it('should initialize with max messages limit of 50', function () { + storageManager._maxMessages.should.equal(50); + }); + }); + + describe('#_getKey()', function () { + it('should generate key with ai_chat_ prefix', function () { + var key = storageManager._getKey('https://example.com'); + key.should.match(/^ai_chat_/); + }); + + it('should replace non-alphanumeric characters with underscores', function () { + var key = storageManager._getKey('https://example.com/path?query=value'); + key.should.equal('ai_chat_https___example_com_path_query_value'); + }); + + it('should handle null URL with default key', function () { + var key = storageManager._getKey(null); + key.should.equal('ai_chat_default'); + }); + + it('should handle undefined URL with default key', function () { + var key = storageManager._getKey(undefined); + key.should.equal('ai_chat_default'); + }); + + it('should handle special characters', function () { + var key = storageManager._getKey('http://test.com/?foo=bar&baz=qux'); + key.should.equal('ai_chat_http___test_com__foo_bar_baz_qux'); + }); + + it('should handle empty string with default', function () { + var key = storageManager._getKey(''); + key.should.equal('ai_chat_default'); + }); + }); +}); diff --git a/tests/modules/ui/AIChat.spec.js b/tests/modules/ui/AIChat.spec.js new file mode 100644 index 00000000..a97dce47 --- /dev/null +++ b/tests/modules/ui/AIChat.spec.js @@ -0,0 +1,329 @@ +'use strict'; + +var AIChat = require('../../../app/scripts/modules/ui/AIChat.js'); + +describe('AIChat', function () { + var fixtures = document.getElementById('fixtures'); + var aiChat; + + beforeEach(function () { + fixtures.innerHTML = '
'; + aiChat = new AIChat('ai-chat', { getAppInfo: function () { return null; } }); + }); + + afterEach(function () { + if (aiChat) { + aiChat = null; + } + fixtures.innerHTML = ''; + }); + + describe('Constructor & Initialization', function () { + it('should create instance with container ID', function () { + aiChat._container.should.exist; + aiChat._container.id.should.equal('ai-chat'); + }); + + it('should set default values', function () { + (aiChat._currentUrl === null).should.be.true; + (aiChat._currentContext === null).should.be.true; + aiChat._messages.should.be.an('array').with.lengthOf(0); + aiChat._isStreaming.should.be.false; + aiChat._maxJsonDepth.should.equal(10); + }); + + it('should render chat interface', function () { + document.querySelector('.ai-chat-wrapper').should.exist; + }); + }); + + describe('#_render()', function () { + it('should render chat wrapper with ARIA attributes', function () { + var wrapper = document.querySelector('.ai-chat-wrapper'); + wrapper.should.exist; + wrapper.getAttribute('role').should.equal('region'); + wrapper.getAttribute('aria-label').should.equal('AI Chat'); + }); + + it('should render messages container', function () { + var container = document.getElementById('ai-messages-container'); + container.should.exist; + container.getAttribute('role').should.equal('log'); + }); + + it('should render input with aria-label', function () { + var input = document.getElementById('ai-input'); + input.should.exist; + input.getAttribute('aria-label').should.equal('Message input'); + }); + + it('should render send button with aria-label', function () { + var button = document.getElementById('ai-send-button'); + button.should.exist; + button.getAttribute('aria-label').should.equal('Send message'); + }); + + it('should render dialog with ARIA attributes', function () { + var dialog = document.getElementById('ai-confirm-dialog'); + dialog.should.exist; + dialog.getAttribute('role').should.equal('dialog'); + dialog.getAttribute('aria-modal').should.equal('true'); + }); + }); + + describe('#_escapeHtml()', function () { + it('should escape < and >', function () { + var result = aiChat._escapeHtml('
'); + result.should.contain('<div>'); + }); + + it('should escape ampersand', function () { + var result = aiChat._escapeHtml('A & B'); + result.should.contain('&'); + }); + + it('should handle script tags', function () { + var result = aiChat._escapeHtml(''); + result.should.equal('<script>alert("xss")</script>'); + }); + + it('should handle quotes', function () { + var result = aiChat._escapeHtml('"quoted"'); + result.should.not.contain('<'); + }); + }); + + describe('#_parseMarkdown()', function () { + it('should escape HTML before formatting', function () { + var result = aiChat._parseMarkdown(''); + result.should.not.contain('', 'html'); + result.should.contain('<script>'); + }); + }); + + describe('#_createCodeViewer()', function () { + it('should create code viewer HTML', function () { + var result = aiChat._createCodeViewer('var x = 1;', 'javascript'); + result.should.contain('code-viewer'); + result.should.contain('data-code'); + }); + }); + + describe('#_createJsonViewer()', function () { + it('should create JSON viewer HTML', function () { + var result = aiChat._createJsonViewer({key: 'value'}); + result.should.contain('json-viewer'); + result.should.contain('data-json'); + }); + }); + }); + + describe('Message Handling', function () { + describe('#_addMessage()', function () { + it('should add user message', function () { + aiChat._addMessage('user', 'Hello'); + var container = document.getElementById('ai-messages-container'); + container.innerHTML.should.contain('Hello'); + }); + + it('should escape HTML in user messages', function () { + aiChat._addMessage('user', ''); + var container = document.getElementById('ai-messages-container'); + container.innerHTML.should.not.contain('