From 68b4748cd3d8dad70b339c09373bb60ae7451e78 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Tue, 13 Jan 2026 13:27:49 +0200 Subject: [PATCH 1/4] chore: ignore AGENTS.md file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5094c24..58785f9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ FRONTEND.md # Distribution folders dist package + +# AI +AGENTS.md \ No newline at end of file From 3673e75a84a7a95f983addd8b5f2eee31db67258 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Wed, 14 Jan 2026 10:00:05 +0200 Subject: [PATCH 2/4] feat: add prompt api support --- .eslintrc | 2 +- .gitignore | 3 +- .jshintrc | 12 +- app/html/panel/ui5/index.html | 7 + app/manifest.json | 3 +- app/scripts/background/main.js | 294 +++++++++++ app/scripts/devtools/panel/ui5/main.js | 36 +- app/scripts/modules/ai/AISessionManager.js | 428 ++++++++++++++++ app/scripts/modules/ai/ChatStorageManager.js | 94 ++++ app/scripts/modules/ui/AIChat.js | 506 +++++++++++++++++++ app/styles/less/modules/AIChat.less | 370 ++++++++++++++ app/styles/less/themes/base/base.less | 1 + tests/styles/themes/dark/dark.css | 305 +++++++++++ tests/styles/themes/light/light.css | 305 +++++++++++ 14 files changed, 2357 insertions(+), 9 deletions(-) create mode 100644 app/scripts/modules/ai/AISessionManager.js create mode 100644 app/scripts/modules/ai/ChatStorageManager.js create mode 100644 app/scripts/modules/ui/AIChat.js create mode 100644 app/styles/less/modules/AIChat.less diff --git a/.eslintrc b/.eslintrc index ef96779..a70b839 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,6 @@ { "parserOptions": { - "ecmaVersion": 2017 + "ecmaVersion": 2018 }, "env": { "es6": true diff --git a/.gitignore b/.gitignore index 58785f9..7b1f49d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ dist package # AI -AGENTS.md \ No newline at end of file +AGENTS.md +CLAUDE.md \ No newline at end of file diff --git a/.jshintrc b/.jshintrc index 388e918..5f3bec3 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 914ea11..b7cc52f 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 @@ -157,6 +158,12 @@ + + + + + + diff --git a/app/manifest.json b/app/manifest.json index 81011d1..db0d305 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 9265c7c..86c32d5 100644 --- a/app/scripts/background/main.js +++ b/app/scripts/background/main.js @@ -154,4 +154,298 @@ // 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) { + + 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 bc424f0..7357fe2 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,16 @@ onSelectionChange: displayFrameData }); + // AI Chat component + var aiChat = new AIChat('ai-chat', { + getAppInfo: function () { + var currentFrameId = framesSelect.getSelectedId(); + console.log('[main.js] getAppInfo called. currentFrameId:', currentFrameId); + console.log('[main.js] frameData:', frameData); + return frameData[currentFrameId] ? frameData[currentFrameId].applicationInformation : null; + } + }); + // ================================================================================ // Communication // ================================================================================ @@ -482,6 +492,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 +572,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 0000000..0761745 --- /dev/null +++ b/app/scripts/modules/ai/AISessionManager.js @@ -0,0 +1,428 @@ +'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: {} + }); + }); +}; + +/** + * Format prompt with optional context. + * @private + * @param {string} prompt - User prompt + * @param {Object} context - Optional context + * @returns {string} + */ +AISessionManager.prototype._formatPrompt = function (prompt, context) { + if (!context || !context.control) { + return prompt; + } + + let contextString = 'Current UI5 Control Context:\n'; + contextString += `- Type: ${context.control.type || 'Unknown'}\n`; + contextString += `- ID: ${context.control.id || 'None'}\n`; + + if (context.control.properties && context.control.properties.own && context.control.properties.own.data && Object.keys(context.control.properties.own.data).length > 0) { + const propsPreview = context.control.properties.own.data; + contextString += `- Properties: ${JSON.stringify(propsPreview)}\n`; + } + + if (context.control.bindings && Object.keys(context.control.bindings).length > 0) { + contextString += `- Has Bindings: ${Object.keys(context.control.bindings).length}\n`; + } + + 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 0000000..1dc37dd --- /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 0000000..1d31059 --- /dev/null +++ b/app/scripts/modules/ui/AIChat.js @@ -0,0 +1,506 @@ +'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._getAppInfo = options.getAppInfo || 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'); + + // Send message on button click + sendButton.addEventListener('click', () => { + this._handleSendMessage(); + }); + + // Send message on Ctrl/Cmd + Enter + input.addEventListener('keydown', (e) => { + if (!(e.ctrlKey || e.metaKey) && 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(); + }); +}; + +/** + * 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'); + } 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'); + downloadButton.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(); + + } catch (error) { + this._renderModelStatus('error', 0, `Download failed: ${error.message}`); + downloadButton.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 + const messageElement = this._addMessage('assistant', ''); + this._streamingMessageElement = messageElement.querySelector('.message-content'); + + // 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._streamingMessageElement.textContent = fullResponse; + this._scrollToBottom(); + } + + // Save AI response to storage + await this._storageManager.saveMessage(this._currentUrl, { + role: 'assistant', + content: fullResponse, + timestamp: Date.now() + }); + + this._isStreaming = false; + this._streamingMessageElement = null; + + // Update token counter + this._updateTokenCounter(); + + } catch (error) { + this._addSystemMessage(`Error: ${error.message}`); + this._isStreaming = false; + this._streamingMessageElement = null; + } +}; + +/** + * Handle clear history. + * @private + */ +AIChat.prototype._handleClearHistory = async function () { + if (!confirm('Clear chat history for this page? This cannot be undone.')) { + return; + } + + 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(); + + 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 + * @returns {HTMLElement} - The message element + */ +AIChat.prototype._addMessage = function (role, content) { + 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; + + const timestamp = new Date().toLocaleTimeString(); + + messageElement.innerHTML = ` +
+ ${role === 'user' ? 'You' : role === 'assistant' ? 'AI' : 'System'} + ${timestamp} +
+
${this._escapeHtml(content)}
+ `; + + messagesContainer.appendChild(messageElement); + this._scrollToBottom(); + + 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; +}; + +/** + * Scroll messages container to bottom. + * @private + */ +AIChat.prototype._scrollToBottom = function () { + const messagesContainer = document.getElementById('ai-messages-container'); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +}; + +/** + * Update token counter display. + * @private + */ +AIChat.prototype._updateTokenCounter = async function () { + const counter = document.getElementById('ai-token-counter'); + + try { + const usageInfo = await this._sessionManager.getUsageInfo(); + + if (usageInfo) { + counter.textContent = `Tokens: ${usageInfo.inputUsage}/${usageInfo.inputQuota} (${usageInfo.percentUsed}%)`; + counter.classList.toggle('warning', usageInfo.percentUsed >= 90); + } else { + counter.textContent = ''; + } + } catch (error) { + counter.textContent = ''; + } +}; + +/** + * 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(); + } + + // Scroll to bottom + this._scrollToBottom(); +}; + +/** + * 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'; + this._scrollToBottom(); + } + } catch (error) { + // Fail silently + } +}; + +/** + * Destroy the component and cleanup. + */ +AIChat.prototype.destroy = function () { + this._sessionManager.destroy(); +}; + +module.exports = AIChat; diff --git a/app/styles/less/modules/AIChat.less b/app/styles/less/modules/AIChat.less new file mode 100644 index 0000000..7adc242 --- /dev/null +++ b/app/styles/less/modules/AIChat.less @@ -0,0 +1,370 @@ +// AIChat component styles + +.ai-chat-wrapper { + display: flex; + flex-direction: column; + height: 100%; + background-color: #f5f5f5; +} + +#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 #ddd; + background-color: #fff; + + .status-content { + display: flex; + align-items: center; + gap: 8px; + } + + .status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #999; + } + + .status-text { + font-size: 13px; + color: #333; + } + + .download-button, + .clear-history-button { + padding: 6px 12px; + border: 1px solid #007bff; + background-color: #007bff; + color: #fff; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-left: 8px; + + &:hover:not(:disabled) { + background-color: #0056b3; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .clear-history-button { + background-color: #6c757d; + border-color: #6c757d; + + &:hover:not(:disabled) { + background-color: #5a6268; + } + } + + // Status-specific styles + &.status-ready { + background-color: #d4edda; + border-color: #c3e6cb; + + .status-indicator { + background-color: #28a745; + } + } + + &.status-needs-download { + background-color: #fff3cd; + border-color: #ffeaa7; + + .status-indicator { + background-color: #ffc107; + } + } + + &.status-downloading { + background-color: #d1ecf1; + border-color: #bee5eb; + + .status-indicator { + background-color: #17a2b8; + animation: pulse 1.5s ease-in-out infinite; + } + } + + &.status-unavailable, + &.status-error { + background-color: #f8d7da; + border-color: #f5c6cb; + + .status-indicator { + background-color: #dc3545; + } + + .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: #666; + + h3 { + margin: 0 0 12px 0; + color: #333; + 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; + + .message-header { + justify-content: flex-end; + } + + .message-content { + background-color: #007bff; + color: #fff; + border-radius: 12px 12px 0 12px; + } + } + + &.message-assistant { + align-self: flex-start; + + .message-content { + background-color: #fff; + color: #333; + border: 1px solid #ddd; + border-radius: 12px 12px 12px 0; + } + } + + &.message-system { + align-self: center; + max-width: 90%; + + .message-content { + background-color: #e9ecef; + color: #495057; + border-radius: 8px; + text-align: center; + font-size: 12px; + font-style: italic; + } + } +} + +.message-header { + display: flex; + justify-content: space-between; + margin-bottom: 4px; + font-size: 11px; + color: #666; + padding: 0 8px; + + .message-role { + font-weight: 600; + } + + .message-time { + opacity: 0.7; + } +} + +.message-content { + padding: 10px 14px; + word-wrap: break-word; + white-space: pre-wrap; + font-size: 13px; + line-height: 1.5; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Input Area +.ai-input-area { + border-top: 1px solid #ddd; + background-color: #fff; + padding: 12px 16px; +} + +.context-info { + display: none; + align-items: center; + gap: 6px; + padding: 8px 12px; + margin-bottom: 8px; + background-color: #e7f3ff; + border-radius: 6px; + font-size: 12px; + color: #004085; + + .context-icon { + font-size: 14px; + } + + .context-text { + flex: 1; + font-weight: 500; + } +} + +.input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.ai-input { + flex: 1; + padding: 10px 12px; + border: 1px solid #ccc; + border-radius: 6px; + font-family: inherit; + font-size: 13px; + resize: vertical; + max-height: 150px; + + &:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); + } + + &::placeholder { + color: #999; + } +} + +.ai-send-button { + padding: 10px 20px; + background-color: #007bff; + color: #fff; + 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: #0056b3; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.input-footer { + display: flex; + justify-content: flex-end; + margin-top: 6px; +} + +.token-counter { + font-size: 11px; + color: #666; + + &.warning { + color: #dc3545; + font-weight: 600; + } +} + +// Scrollbar styling +.ai-messages-container { + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + } + + &::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; + + &:hover { + background: #555; + } + } +} + +// 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; + } + } +} diff --git a/app/styles/less/themes/base/base.less b/app/styles/less/themes/base/base.less index a185f9d..0efa72e 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/tests/styles/themes/dark/dark.css b/tests/styles/themes/dark/dark.css index d813d2f..e1e4025 100644 --- a/tests/styles/themes/dark/dark.css +++ b/tests/styles/themes/dark/dark.css @@ -1250,3 +1250,308 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .elementsRegistry splitter { background-color: #000; } +.ai-chat-wrapper { + display: flex; + flex-direction: column; + height: 100%; + background-color: #f5f5f5; +} +#ai-chat { + width: 100%; +} +.ai-status-banner { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #ddd; + background-color: #fff; +} +.ai-status-banner .status-content { + display: flex; + align-items: center; + gap: 8px; +} +.ai-status-banner .status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #999; +} +.ai-status-banner .status-text { + font-size: 13px; + color: #333; +} +.ai-status-banner .download-button, +.ai-status-banner .clear-history-button { + padding: 6px 12px; + border: 1px solid #007bff; + background-color: #007bff; + color: #fff; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-left: 8px; +} +.ai-status-banner .download-button:hover:not(:disabled), +.ai-status-banner .clear-history-button:hover:not(:disabled) { + background-color: #0056b3; +} +.ai-status-banner .download-button:disabled, +.ai-status-banner .clear-history-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.ai-status-banner .clear-history-button { + background-color: #6c757d; + border-color: #6c757d; +} +.ai-status-banner .clear-history-button:hover:not(:disabled) { + background-color: #5a6268; +} +.ai-status-banner.status-ready { + background-color: #d4edda; + border-color: #c3e6cb; +} +.ai-status-banner.status-ready .status-indicator { + background-color: #28a745; +} +.ai-status-banner.status-needs-download { + background-color: #fff3cd; + border-color: #ffeaa7; +} +.ai-status-banner.status-needs-download .status-indicator { + background-color: #ffc107; +} +.ai-status-banner.status-downloading { + background-color: #d1ecf1; + border-color: #bee5eb; +} +.ai-status-banner.status-downloading .status-indicator { + background-color: #17a2b8; + animation: pulse 1.5s ease-in-out infinite; +} +.ai-status-banner.status-unavailable, +.ai-status-banner.status-error { + background-color: #f8d7da; + border-color: #f5c6cb; +} +.ai-status-banner.status-unavailable .status-indicator, +.ai-status-banner.status-error .status-indicator { + background-color: #dc3545; +} +.ai-status-banner.status-unavailable .status-text, +.ai-status-banner.status-error .status-text { + white-space: pre-line; + font-size: 11px; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} +.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: #666; +} +.ai-welcome-message h3 { + margin: 0 0 12px 0; + color: #333; + font-size: 18px; +} +.ai-welcome-message p { + margin: 8px 0; + font-size: 13px; + line-height: 1.5; +} +.ai-message { + display: flex; + flex-direction: column; + max-width: 80%; + animation: fadeIn 0.2s ease-in; +} +.ai-message.message-user { + align-self: flex-end; +} +.ai-message.message-user .message-header { + justify-content: flex-end; +} +.ai-message.message-user .message-content { + background-color: #007bff; + color: #fff; + border-radius: 12px 12px 0 12px; +} +.ai-message.message-assistant { + align-self: flex-start; +} +.ai-message.message-assistant .message-content { + background-color: #fff; + color: #333; + border: 1px solid #ddd; + border-radius: 12px 12px 12px 0; +} +.ai-message.message-system { + align-self: center; + max-width: 90%; +} +.ai-message.message-system .message-content { + background-color: #e9ecef; + color: #495057; + border-radius: 8px; + text-align: center; + font-size: 12px; + font-style: italic; +} +.message-header { + display: flex; + justify-content: space-between; + margin-bottom: 4px; + font-size: 11px; + color: #666; + padding: 0 8px; +} +.message-header .message-role { + font-weight: 600; +} +.message-header .message-time { + opacity: 0.7; +} +.message-content { + padding: 10px 14px; + word-wrap: break-word; + white-space: pre-wrap; + font-size: 13px; + line-height: 1.5; +} +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.ai-input-area { + border-top: 1px solid #ddd; + background-color: #fff; + padding: 12px 16px; +} +.context-info { + display: none; + align-items: center; + gap: 6px; + padding: 8px 12px; + margin-bottom: 8px; + background-color: #e7f3ff; + border-radius: 6px; + font-size: 12px; + color: #004085; +} +.context-info .context-icon { + font-size: 14px; +} +.context-info .context-text { + flex: 1; + font-weight: 500; +} +.input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; +} +.ai-input { + flex: 1; + padding: 10px 12px; + border: 1px solid #ccc; + border-radius: 6px; + font-family: inherit; + font-size: 13px; + resize: vertical; + max-height: 150px; +} +.ai-input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); +} +.ai-input::placeholder { + color: #999; +} +.ai-send-button { + padding: 10px 20px; + background-color: #007bff; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + transition: background-color 0.2s; +} +.ai-send-button:hover:not(:disabled) { + background-color: #0056b3; +} +.ai-send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.input-footer { + display: flex; + justify-content: flex-end; + margin-top: 6px; +} +.token-counter { + font-size: 11px; + color: #666; +} +.token-counter.warning { + color: #dc3545; + font-weight: 600; +} +.ai-messages-container::-webkit-scrollbar { + width: 8px; +} +.ai-messages-container::-webkit-scrollbar-track { + background: #f1f1f1; +} +.ai-messages-container::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} +.ai-messages-container::-webkit-scrollbar-thumb:hover { + background: #555; +} +@media (max-width: 600px) { + .ai-message { + max-width: 90%; + } + .ai-status-banner { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .ai-status-banner .status-content { + width: 100%; + } + .ai-status-banner .download-button, + .ai-status-banner .clear-history-button { + width: 100%; + margin-left: 0; + } +} diff --git a/tests/styles/themes/light/light.css b/tests/styles/themes/light/light.css index d4d0261..a4bb6ea 100644 --- a/tests/styles/themes/light/light.css +++ b/tests/styles/themes/light/light.css @@ -1250,3 +1250,308 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .elementsRegistry splitter { background-color: #f2f2f2; } +.ai-chat-wrapper { + display: flex; + flex-direction: column; + height: 100%; + background-color: #f5f5f5; +} +#ai-chat { + width: 100%; +} +.ai-status-banner { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #ddd; + background-color: #fff; +} +.ai-status-banner .status-content { + display: flex; + align-items: center; + gap: 8px; +} +.ai-status-banner .status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #999; +} +.ai-status-banner .status-text { + font-size: 13px; + color: #333; +} +.ai-status-banner .download-button, +.ai-status-banner .clear-history-button { + padding: 6px 12px; + border: 1px solid #007bff; + background-color: #007bff; + color: #fff; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-left: 8px; +} +.ai-status-banner .download-button:hover:not(:disabled), +.ai-status-banner .clear-history-button:hover:not(:disabled) { + background-color: #0056b3; +} +.ai-status-banner .download-button:disabled, +.ai-status-banner .clear-history-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.ai-status-banner .clear-history-button { + background-color: #6c757d; + border-color: #6c757d; +} +.ai-status-banner .clear-history-button:hover:not(:disabled) { + background-color: #5a6268; +} +.ai-status-banner.status-ready { + background-color: #d4edda; + border-color: #c3e6cb; +} +.ai-status-banner.status-ready .status-indicator { + background-color: #28a745; +} +.ai-status-banner.status-needs-download { + background-color: #fff3cd; + border-color: #ffeaa7; +} +.ai-status-banner.status-needs-download .status-indicator { + background-color: #ffc107; +} +.ai-status-banner.status-downloading { + background-color: #d1ecf1; + border-color: #bee5eb; +} +.ai-status-banner.status-downloading .status-indicator { + background-color: #17a2b8; + animation: pulse 1.5s ease-in-out infinite; +} +.ai-status-banner.status-unavailable, +.ai-status-banner.status-error { + background-color: #f8d7da; + border-color: #f5c6cb; +} +.ai-status-banner.status-unavailable .status-indicator, +.ai-status-banner.status-error .status-indicator { + background-color: #dc3545; +} +.ai-status-banner.status-unavailable .status-text, +.ai-status-banner.status-error .status-text { + white-space: pre-line; + font-size: 11px; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} +.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: #666; +} +.ai-welcome-message h3 { + margin: 0 0 12px 0; + color: #333; + font-size: 18px; +} +.ai-welcome-message p { + margin: 8px 0; + font-size: 13px; + line-height: 1.5; +} +.ai-message { + display: flex; + flex-direction: column; + max-width: 80%; + animation: fadeIn 0.2s ease-in; +} +.ai-message.message-user { + align-self: flex-end; +} +.ai-message.message-user .message-header { + justify-content: flex-end; +} +.ai-message.message-user .message-content { + background-color: #007bff; + color: #fff; + border-radius: 12px 12px 0 12px; +} +.ai-message.message-assistant { + align-self: flex-start; +} +.ai-message.message-assistant .message-content { + background-color: #fff; + color: #333; + border: 1px solid #ddd; + border-radius: 12px 12px 12px 0; +} +.ai-message.message-system { + align-self: center; + max-width: 90%; +} +.ai-message.message-system .message-content { + background-color: #e9ecef; + color: #495057; + border-radius: 8px; + text-align: center; + font-size: 12px; + font-style: italic; +} +.message-header { + display: flex; + justify-content: space-between; + margin-bottom: 4px; + font-size: 11px; + color: #666; + padding: 0 8px; +} +.message-header .message-role { + font-weight: 600; +} +.message-header .message-time { + opacity: 0.7; +} +.message-content { + padding: 10px 14px; + word-wrap: break-word; + white-space: pre-wrap; + font-size: 13px; + line-height: 1.5; +} +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.ai-input-area { + border-top: 1px solid #ddd; + background-color: #fff; + padding: 12px 16px; +} +.context-info { + display: none; + align-items: center; + gap: 6px; + padding: 8px 12px; + margin-bottom: 8px; + background-color: #e7f3ff; + border-radius: 6px; + font-size: 12px; + color: #004085; +} +.context-info .context-icon { + font-size: 14px; +} +.context-info .context-text { + flex: 1; + font-weight: 500; +} +.input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; +} +.ai-input { + flex: 1; + padding: 10px 12px; + border: 1px solid #ccc; + border-radius: 6px; + font-family: inherit; + font-size: 13px; + resize: vertical; + max-height: 150px; +} +.ai-input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); +} +.ai-input::placeholder { + color: #999; +} +.ai-send-button { + padding: 10px 20px; + background-color: #007bff; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + transition: background-color 0.2s; +} +.ai-send-button:hover:not(:disabled) { + background-color: #0056b3; +} +.ai-send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.input-footer { + display: flex; + justify-content: flex-end; + margin-top: 6px; +} +.token-counter { + font-size: 11px; + color: #666; +} +.token-counter.warning { + color: #dc3545; + font-weight: 600; +} +.ai-messages-container::-webkit-scrollbar { + width: 8px; +} +.ai-messages-container::-webkit-scrollbar-track { + background: #f1f1f1; +} +.ai-messages-container::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} +.ai-messages-container::-webkit-scrollbar-thumb:hover { + background: #555; +} +@media (max-width: 600px) { + .ai-message { + max-width: 90%; + } + .ai-status-banner { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .ai-status-banner .status-content { + width: 100%; + } + .ai-status-banner .download-button, + .ai-status-banner .clear-history-button { + width: 100%; + margin-left: 0; + } +} From 1937caa56b63ae830377383149e3549a52177946 Mon Sep 17 00:00:00 2001 From: Nikola Anachkov Date: Wed, 4 Feb 2026 17:23:39 +0200 Subject: [PATCH 3/4] feat: enhanced ai chat with interactive viewers and improved ux --- app/scripts/modules/ai/AISessionManager.js | 22 +- app/scripts/modules/ui/AIChat.js | 420 ++++++++++++++++++++- app/styles/less/modules/AIChat.less | 418 +++++++++++++++++--- app/styles/less/themes/dark/dark.less | 33 ++ app/styles/less/themes/light/light.less | 5 + tests/styles/themes/dark/dark.css | 386 ++++++++++++++++--- tests/styles/themes/light/light.css | 361 +++++++++++++++--- 7 files changed, 1474 insertions(+), 171 deletions(-) diff --git a/app/scripts/modules/ai/AISessionManager.js b/app/scripts/modules/ai/AISessionManager.js index 0761745..1a29241 100644 --- a/app/scripts/modules/ai/AISessionManager.js +++ b/app/scripts/modules/ai/AISessionManager.js @@ -214,13 +214,33 @@ AISessionManager.prototype._formatPrompt = function (prompt, context) { contextString += `- Type: ${context.control.type || 'Unknown'}\n`; contextString += `- ID: ${context.control.id || 'None'}\n`; + // Properties if (context.control.properties && context.control.properties.own && context.control.properties.own.data && Object.keys(context.control.properties.own.data).length > 0) { const propsPreview = context.control.properties.own.data; contextString += `- Properties: ${JSON.stringify(propsPreview)}\n`; } + // Bindings (detailed) if (context.control.bindings && Object.keys(context.control.bindings).length > 0) { - contextString += `- Has Bindings: ${Object.keys(context.control.bindings).length}\n`; + contextString += `- Bindings (${Object.keys(context.control.bindings).length}):\n`; + try { + contextString += JSON.stringify(context.control.bindings, null, 2) + '\n'; + } catch (e) { + contextString += ' (Bindings data available but cannot serialize)\n'; + } + } + + // Aggregations (detailed) + if (context.control.aggregations) { + const ownAggr = context.control.aggregations.own; + if (ownAggr && ownAggr.data && Object.keys(ownAggr.data).length > 0) { + contextString += `- Aggregations (${Object.keys(ownAggr.data).length}):\n`; + try { + contextString += JSON.stringify(ownAggr.data, null, 2) + '\n'; + } catch (e) { + contextString += ' (Aggregations data available but cannot serialize)\n'; + } + } } return contextString + '\nUser Question: ' + prompt; diff --git a/app/scripts/modules/ui/AIChat.js b/app/scripts/modules/ui/AIChat.js index 1d31059..9371910 100644 --- a/app/scripts/modules/ui/AIChat.js +++ b/app/scripts/modules/ui/AIChat.js @@ -22,6 +22,7 @@ function AIChat(containerId, options) { this._isStreaming = false; this._streamingMessageElement = null; this._getAppInfo = options.getAppInfo || null; + this._hasShownUsageWarning = false; this.init(); } @@ -67,14 +68,15 @@ AIChat.prototype._render = function () {
- + /> @@ -83,6 +85,18 @@ AIChat.prototype._render = function () {
+ + `; }; @@ -96,15 +110,16 @@ AIChat.prototype._attachEventListeners = function () { 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 Ctrl/Cmd + Enter + // Send message on Enter input.addEventListener('keydown', (e) => { - if (!(e.ctrlKey || e.metaKey) && e.key === 'Enter') { + if (e.key === 'Enter') { e.preventDefault(); this._handleSendMessage(); } @@ -126,6 +141,30 @@ AIChat.prototype._attachEventListeners = function () { 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(); + }); }; /** @@ -138,6 +177,8 @@ AIChat.prototype._checkModelAvailability = async function () { 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 { @@ -261,7 +302,8 @@ AIChat.prototype._handleSendMessage = async function () { // Process stream for await (const chunk of stream) { fullResponse += chunk; - this._streamingMessageElement.textContent = fullResponse; + this._streamingMessageElement.innerHTML = this._parseMarkdown(fullResponse); + this._initializeJsonViewers(this._streamingMessageElement); this._scrollToBottom(); } @@ -289,11 +331,33 @@ AIChat.prototype._handleSendMessage = async function () { * Handle clear history. * @private */ -AIChat.prototype._handleClearHistory = async function () { - if (!confirm('Clear chat history for this page? This cannot be undone.')) { - return; - } +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'; +}; + +/** + * Hide confirmation dialog. + * @private + */ +AIChat.prototype._hideConfirmDialog = function () { + const dialog = document.getElementById('ai-confirm-dialog'); + dialog.style.display = 'none'; +}; + +/** + * Perform clear history action. + * @private + */ +AIChat.prototype._performClearHistory = async function () { try { await this._storageManager.clearHistory(this._currentUrl); @@ -311,6 +375,9 @@ AIChat.prototype._handleClearHistory = async function () { this._sessionManager.destroy(); await this._initializeSession(); + // Reset usage warning flag + this._hasShownUsageWarning = false; + this._addSystemMessage('Chat history cleared'); } catch (error) { @@ -364,15 +431,25 @@ AIChat.prototype._addMessage = function (role, content) { const timestamp = new Date().toLocaleTimeString(); + // Use markdown rendering for AI responses, escape HTML for user/system messages + const formattedContent = role === 'assistant' ? this._parseMarkdown(content) : this._escapeHtml(content); + messageElement.innerHTML = `
${role === 'user' ? 'You' : role === 'assistant' ? 'AI' : 'System'} ${timestamp}
-
${this._escapeHtml(content)}
+
${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); + } + this._scrollToBottom(); this._messages.push({ role, content }); @@ -400,6 +477,293 @@ AIChat.prototype._escapeHtml = function (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) { + 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), + object: () => this._renderJsonObject(key, value, comma) + }; + + 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) { + 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)).join(''); + + return `
${keyHtml} [${value.length} items
+
${items}
]${comma}
`; +}; + +/** + * Render JSON object. + * @private + */ +AIChat.prototype._renderJsonObject = function (key, value, comma) { + 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)).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); + } 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); + } 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}
` : ''; + + return `
${langLabel}
${linesHtml}
`; +}; + /** * Scroll messages container to bottom. * @private @@ -422,6 +786,9 @@ AIChat.prototype._updateTokenCounter = async function () { if (usageInfo) { counter.textContent = `Tokens: ${usageInfo.inputUsage}/${usageInfo.inputQuota} (${usageInfo.percentUsed}%)`; counter.classList.toggle('warning', usageInfo.percentUsed >= 90); + + // Show usage warning when reaching 70% (only once per session) + this._checkTokenUsageWarning(usageInfo.percentUsed); } else { counter.textContent = ''; } @@ -430,6 +797,37 @@ AIChat.prototype._updateTokenCounter = async function () { } }; +/** + * 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} diff --git a/app/styles/less/modules/AIChat.less b/app/styles/less/modules/AIChat.less index 7adc242..aa52461 100644 --- a/app/styles/less/modules/AIChat.less +++ b/app/styles/less/modules/AIChat.less @@ -4,7 +4,7 @@ display: flex; flex-direction: column; height: 100%; - background-color: #f5f5f5; + background-color: @bg-white; } #ai-chat { @@ -17,8 +17,8 @@ justify-content: space-between; align-items: center; padding: 12px 16px; - border-bottom: 1px solid #ddd; - background-color: #fff; + border-bottom: 1px solid @border-gray-lighter; + background-color: @bg-white; .status-content { display: flex; @@ -30,27 +30,27 @@ width: 8px; height: 8px; border-radius: 50%; - background-color: #999; + background-color: @gray; } .status-text { font-size: 13px; - color: #333; + color: @text-color; } .download-button, .clear-history-button { padding: 6px 12px; - border: 1px solid #007bff; - background-color: #007bff; - color: #fff; + 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: #0056b3; + background-color: darken(@lighter-blue, 10%); } &:disabled { @@ -60,50 +60,50 @@ } .clear-history-button { - background-color: #6c757d; - border-color: #6c757d; + background-color: @gray; + border-color: @gray; &:hover:not(:disabled) { - background-color: #5a6268; + background-color: @gray-dark; } } // Status-specific styles &.status-ready { - background-color: #d4edda; - border-color: #c3e6cb; + background-color: fade(@lighter-blue, 20%); + border-color: @border-gray-lighter; .status-indicator { - background-color: #28a745; + background-color: @lighter-blue; } } &.status-needs-download { - background-color: #fff3cd; - border-color: #ffeaa7; + background-color: fade(@purple, 15%); + border-color: @border-gray-lighter; .status-indicator { - background-color: #ffc107; + background-color: @purple; } } &.status-downloading { - background-color: #d1ecf1; - border-color: #bee5eb; + background-color: fade(@lighter-blue, 15%); + border-color: @border-gray-lighter; .status-indicator { - background-color: #17a2b8; + background-color: @lighter-blue; animation: pulse 1.5s ease-in-out infinite; } } &.status-unavailable, &.status-error { - background-color: #f8d7da; - border-color: #f5c6cb; + background-color: fade(@red-saturated, 15%); + border-color: @border-gray-lighter; .status-indicator { - background-color: #dc3545; + background-color: @red-saturated; } .status-text { @@ -135,11 +135,11 @@ .ai-welcome-message { text-align: center; padding: 32px 16px; - color: #666; + color: @gray-dark; h3 { margin: 0 0 12px 0; - color: #333; + color: @text-color; font-size: 18px; } @@ -165,8 +165,8 @@ } .message-content { - background-color: #007bff; - color: #fff; + background-color: @lighter-blue; + color: @white; border-radius: 12px 12px 0 12px; } } @@ -175,9 +175,9 @@ align-self: flex-start; .message-content { - background-color: #fff; - color: #333; - border: 1px solid #ddd; + background-color: @bg-white; + color: @white; + border: 1px solid @border-gray-lighter; border-radius: 12px 12px 12px 0; } } @@ -187,8 +187,8 @@ max-width: 90%; .message-content { - background-color: #e9ecef; - color: #495057; + background-color: @bg-gray-lightest; + color: @text-color; border-radius: 8px; text-align: center; font-size: 12px; @@ -202,7 +202,7 @@ justify-content: space-between; margin-bottom: 4px; font-size: 11px; - color: #666; + color: @gray-dark; padding: 0 8px; .message-role { @@ -217,9 +217,51 @@ .message-content { padding: 10px 14px; word-wrap: break-word; - white-space: pre-wrap; + 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 { @@ -235,8 +277,8 @@ // Input Area .ai-input-area { - border-top: 1px solid #ddd; - background-color: #fff; + border-top: 1px solid @border-gray-lighter; + background-color: @bg-white; padding: 12px 16px; } @@ -246,10 +288,10 @@ gap: 6px; padding: 8px 12px; margin-bottom: 8px; - background-color: #e7f3ff; + background-color: fade(@lighter-blue, 15%); border-radius: 6px; font-size: 12px; - color: #004085; + color: @text-color; .context-icon { font-size: 14px; @@ -259,6 +301,22 @@ 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 { @@ -270,28 +328,29 @@ .ai-input { flex: 1; padding: 10px 12px; - border: 1px solid #ccc; + border: 1px solid @border-gray-lighter; border-radius: 6px; font-family: inherit; font-size: 13px; - resize: vertical; - max-height: 150px; + resize: none; + background-color: @bg-white; + color: @text-color; &:focus { outline: none; - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); + border-color: @lighter-blue; + box-shadow: 0 0 0 2px fade(@lighter-blue, 10%); } &::placeholder { - color: #999; + color: @gray; } } .ai-send-button { padding: 10px 20px; - background-color: #007bff; - color: #fff; + background-color: @lighter-blue; + color: @white; border: none; border-radius: 6px; cursor: pointer; @@ -301,7 +360,7 @@ transition: background-color 0.2s; &:hover:not(:disabled) { - background-color: #0056b3; + background-color: darken(@lighter-blue, 10%); } &:disabled { @@ -312,16 +371,23 @@ .input-footer { display: flex; - justify-content: flex-end; - margin-top: 6px; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding: 0 4px; } .token-counter { - font-size: 11px; - color: #666; + font-size: 12px; + color: @gray-dark; + font-weight: 500; + padding: 4px 8px; + background-color: fade(@gray, 10%); + border-radius: 4px; &.warning { - color: #dc3545; + color: @red-saturated; + background-color: fade(@red-saturated, 10%); font-weight: 600; } } @@ -333,15 +399,15 @@ } &::-webkit-scrollbar-track { - background: #f1f1f1; + background: @bg-gray-lightest; } &::-webkit-scrollbar-thumb { - background: #888; + background: @gray; border-radius: 4px; &:hover { - background: #555; + background: @gray-dark; } } } @@ -368,3 +434,243 @@ } } } + +// 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: 12px; + overflow-x: auto; + } + + .json-copy-btn { + position: absolute; + top: 8px; + right: 8px; + background: @lighter-blue; + color: @white; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; + z-index: 1; + transition: background-color 0.2s; + + &:hover { + background: darken(@lighter-blue, 10%); + } + } + + .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: 12px; + overflow-x: auto; + } + + .code-lang { + position: absolute; + top: 8px; + right: 8px; + 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; + } + + .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); + } +} diff --git a/app/styles/less/themes/dark/dark.less b/app/styles/less/themes/dark/dark.less index 2600865..59f4be6 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 7703013..ae8edd3 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/styles/themes/dark/dark.css b/tests/styles/themes/dark/dark.css index e1e4025..1a4531b 100644 --- a/tests/styles/themes/dark/dark.css +++ b/tests/styles/themes/dark/dark.css @@ -1254,7 +1254,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio display: flex; flex-direction: column; height: 100%; - background-color: #f5f5f5; + background-color: #252525; } #ai-chat { width: 100%; @@ -1264,8 +1264,8 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio justify-content: space-between; align-items: center; padding: 12px 16px; - border-bottom: 1px solid #ddd; - background-color: #fff; + border-bottom: 1px solid #d4d4d4; + background-color: #252525; } .ai-status-banner .status-content { display: flex; @@ -1276,17 +1276,17 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio width: 8px; height: 8px; border-radius: 50%; - background-color: #999; + background-color: #626262; } .ai-status-banner .status-text { font-size: 13px; - color: #333; + color: #949494; } .ai-status-banner .download-button, .ai-status-banner .clear-history-button { padding: 6px 12px; - border: 1px solid #007bff; - background-color: #007bff; + border: 1px solid #C98730; + background-color: #C98730; color: #fff; border-radius: 4px; cursor: pointer; @@ -1295,7 +1295,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio } .ai-status-banner .download-button:hover:not(:disabled), .ai-status-banner .clear-history-button:hover:not(:disabled) { - background-color: #0056b3; + background-color: #a06b26; } .ai-status-banner .download-button:disabled, .ai-status-banner .clear-history-button:disabled { @@ -1303,42 +1303,42 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio cursor: not-allowed; } .ai-status-banner .clear-history-button { - background-color: #6c757d; - border-color: #6c757d; + background-color: #626262; + border-color: #626262; } .ai-status-banner .clear-history-button:hover:not(:disabled) { - background-color: #5a6268; + background-color: #a3a3a3; } .ai-status-banner.status-ready { - background-color: #d4edda; - border-color: #c3e6cb; + background-color: rgba(201, 135, 48, 0.2); + border-color: #d4d4d4; } .ai-status-banner.status-ready .status-indicator { - background-color: #28a745; + background-color: #C98730; } .ai-status-banner.status-needs-download { - background-color: #fff3cd; - border-color: #ffeaa7; + background-color: rgba(89, 175, 214, 0.15); + border-color: #d4d4d4; } .ai-status-banner.status-needs-download .status-indicator { - background-color: #ffc107; + background-color: #59AFD6; } .ai-status-banner.status-downloading { - background-color: #d1ecf1; - border-color: #bee5eb; + background-color: rgba(201, 135, 48, 0.15); + border-color: #d4d4d4; } .ai-status-banner.status-downloading .status-indicator { - background-color: #17a2b8; + background-color: #C98730; animation: pulse 1.5s ease-in-out infinite; } .ai-status-banner.status-unavailable, .ai-status-banner.status-error { - background-color: #f8d7da; - border-color: #f5c6cb; + background-color: rgba(34, 212, 194, 0.15); + border-color: #d4d4d4; } .ai-status-banner.status-unavailable .status-indicator, .ai-status-banner.status-error .status-indicator { - background-color: #dc3545; + background-color: #22D4C2; } .ai-status-banner.status-unavailable .status-text, .ai-status-banner.status-error .status-text { @@ -1365,11 +1365,11 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .ai-welcome-message { text-align: center; padding: 32px 16px; - color: #666; + color: #a3a3a3; } .ai-welcome-message h3 { margin: 0 0 12px 0; - color: #333; + color: #949494; font-size: 18px; } .ai-welcome-message p { @@ -1390,7 +1390,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio justify-content: flex-end; } .ai-message.message-user .message-content { - background-color: #007bff; + background-color: #C98730; color: #fff; border-radius: 12px 12px 0 12px; } @@ -1398,9 +1398,9 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio align-self: flex-start; } .ai-message.message-assistant .message-content { - background-color: #fff; - color: #333; - border: 1px solid #ddd; + background-color: #252525; + color: #fff; + border: 1px solid #d4d4d4; border-radius: 12px 12px 12px 0; } .ai-message.message-system { @@ -1408,8 +1408,8 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio max-width: 90%; } .ai-message.message-system .message-content { - background-color: #e9ecef; - color: #495057; + background-color: #000; + color: #949494; border-radius: 8px; text-align: center; font-size: 12px; @@ -1420,7 +1420,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio justify-content: space-between; margin-bottom: 4px; font-size: 11px; - color: #666; + color: #a3a3a3; padding: 0 8px; } .message-header .message-role { @@ -1432,10 +1432,44 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .message-content { padding: 10px 14px; word-wrap: break-word; - white-space: pre-wrap; + white-space: normal; font-size: 13px; line-height: 1.5; } +.message-content code { + background-color: rgba(98, 98, 98, 0.2); + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; +} +.message-content pre { + background-color: rgba(98, 98, 98, 0.15); + border: 1px solid #d4d4d4; + border-radius: 6px; + padding: 12px; + overflow-x: auto; + margin: 8px 0; +} +.message-content pre code { + background: none; + padding: 0; + font-size: 12px; + line-height: 1.4; +} +.message-content strong { + font-weight: 600; +} +.message-content em { + font-style: italic; +} +.message-content a { + color: #C98730; + text-decoration: underline; +} +.message-content a:hover { + opacity: 0.8; +} @keyframes fadeIn { from { opacity: 0; @@ -1447,8 +1481,8 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio } } .ai-input-area { - border-top: 1px solid #ddd; - background-color: #fff; + border-top: 1px solid #d4d4d4; + background-color: #252525; padding: 12px 16px; } .context-info { @@ -1457,10 +1491,10 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio gap: 6px; padding: 8px 12px; margin-bottom: 8px; - background-color: #e7f3ff; + background-color: rgba(201, 135, 48, 0.15); border-radius: 6px; font-size: 12px; - color: #004085; + color: #949494; } .context-info .context-icon { font-size: 14px; @@ -1469,6 +1503,20 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio flex: 1; font-weight: 500; } +.context-info .context-clear-button { + background: none; + border: none; + color: #949494; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + opacity: 0.6; + transition: opacity 0.2s; +} +.context-info .context-clear-button:hover { + opacity: 1; +} .input-wrapper { display: flex; gap: 8px; @@ -1477,24 +1525,25 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .ai-input { flex: 1; padding: 10px 12px; - border: 1px solid #ccc; + border: 1px solid #d4d4d4; border-radius: 6px; font-family: inherit; font-size: 13px; - resize: vertical; - max-height: 150px; + resize: none; + background-color: #252525; + color: #949494; } .ai-input:focus { outline: none; - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); + border-color: #C98730; + box-shadow: 0 0 0 2px rgba(201, 135, 48, 0.1); } .ai-input::placeholder { - color: #999; + color: #626262; } .ai-send-button { padding: 10px 20px; - background-color: #007bff; + background-color: #C98730; color: #fff; border: none; border-radius: 6px; @@ -1505,7 +1554,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio transition: background-color 0.2s; } .ai-send-button:hover:not(:disabled) { - background-color: #0056b3; + background-color: #a06b26; } .ai-send-button:disabled { opacity: 0.5; @@ -1513,29 +1562,36 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio } .input-footer { display: flex; - justify-content: flex-end; - margin-top: 6px; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding: 0 4px; } .token-counter { - font-size: 11px; - color: #666; + font-size: 12px; + color: #a3a3a3; + font-weight: 500; + padding: 4px 8px; + background-color: rgba(98, 98, 98, 0.1); + border-radius: 4px; } .token-counter.warning { - color: #dc3545; + color: #22D4C2; + background-color: rgba(34, 212, 194, 0.1); font-weight: 600; } .ai-messages-container::-webkit-scrollbar { width: 8px; } .ai-messages-container::-webkit-scrollbar-track { - background: #f1f1f1; + background: #000; } .ai-messages-container::-webkit-scrollbar-thumb { - background: #888; + background: #626262; border-radius: 4px; } .ai-messages-container::-webkit-scrollbar-thumb:hover { - background: #555; + background: #a3a3a3; } @media (max-width: 600px) { .ai-message { @@ -1555,3 +1611,227 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio margin-left: 0; } } +.json-viewer { + margin: 8px 0; +} +.json-viewer .json-wrapper { + position: relative; + background: #000; + border: 1px solid #d4d4d4; + border-radius: 4px; + padding: 12px; + overflow-x: auto; +} +.json-viewer .json-copy-btn { + position: absolute; + top: 8px; + right: 8px; + background: #C98730; + color: #fff; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; + z-index: 1; + transition: background-color 0.2s; +} +.json-viewer .json-copy-btn:hover { + background: #a06b26; +} +.json-viewer .json-tree { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 12px; + line-height: 1.6; + color: #949494; +} +.json-viewer .json-line { + padding-left: 16px; + position: relative; +} +.json-viewer .json-content { + padding-left: 16px; +} +.json-viewer .json-content > .json-line:last-child { + padding-left: 0; +} +.json-viewer .json-toggle { + cursor: pointer; + user-select: none; + display: inline-block; + width: 14px; + color: #a3a3a3; + font-size: 10px; +} +.json-viewer .json-toggle:hover { + color: #C98730; +} +.json-viewer .json-count { + color: #a3a3a3; + font-size: 11px; + margin-left: 6px; + font-style: italic; +} +.json-viewer .json-key { + color: #0451a5; + font-weight: 600; +} +.json-viewer .json-string { + color: #a31515; +} +.json-viewer .json-number { + color: #098658; +} +.json-viewer .json-boolean { + color: #0000ff; + font-weight: 600; +} +.json-viewer .json-null { + color: #808080; + font-style: italic; +} +.json-viewer .json-bracket { + color: #949494; + font-weight: bold; +} +.code-viewer { + margin: 8px 0; +} +.code-viewer .code-wrapper { + position: relative; + background: #000; + border: 1px solid #d4d4d4; + border-radius: 4px; + padding: 12px; + overflow-x: auto; +} +.code-viewer .code-lang { + position: absolute; + top: 8px; + right: 8px; + background: #626262; + color: #fff; + border-radius: 4px; + padding: 2px 8px; + font-size: 10px; + text-transform: uppercase; + font-weight: 600; + opacity: 0.7; +} +.code-viewer .code-content { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 12px; + line-height: 1.6; + color: #949494; +} +.code-viewer .code-line { + white-space: pre-wrap; + word-wrap: break-word; +} +.code-viewer .code-line:empty::before { + content: ' '; + display: inline-block; +} +.ai-confirm-dialog { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +.ai-confirm-dialog .confirm-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); +} +.ai-confirm-dialog .confirm-content { + position: relative; + background: #252525; + 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; +} +.ai-confirm-dialog .confirm-title { + font-size: 16px; + font-weight: 600; + color: #949494; + margin-bottom: 12px; +} +.ai-confirm-dialog .confirm-message { + font-size: 13px; + color: #a3a3a3; + line-height: 1.5; + margin-bottom: 20px; +} +.ai-confirm-dialog .confirm-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; +} +.ai-confirm-dialog .confirm-button { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: background-color 0.2s; +} +.ai-confirm-dialog .confirm-button.confirm-cancel { + background-color: #000; + color: #949494; +} +.ai-confirm-dialog .confirm-button.confirm-cancel:hover { + background-color: #000000; +} +.ai-confirm-dialog .confirm-button.confirm-ok { + background-color: #22D4C2; + color: #fff; +} +.ai-confirm-dialog .confirm-button.confirm-ok:hover { + background-color: #1ba89a; +} +@keyframes confirmSlideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.ai-message.message-assistant .message-content { + color: #fff; +} +.json-viewer .json-content { + background: #1e1e1e; + border-color: #3c3c3c; +} +.json-viewer .json-key { + color: #9cdcfe; +} +.json-viewer .json-string { + color: #ce9178; +} +.json-viewer .json-number { + color: #b5cea8; +} +.json-viewer .json-boolean { + color: #569cd6; +} +.json-viewer .json-null { + color: #808080; +} diff --git a/tests/styles/themes/light/light.css b/tests/styles/themes/light/light.css index a4bb6ea..ac1219a 100644 --- a/tests/styles/themes/light/light.css +++ b/tests/styles/themes/light/light.css @@ -1254,7 +1254,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio display: flex; flex-direction: column; height: 100%; - background-color: #f5f5f5; + background-color: #fff; } #ai-chat { width: 100%; @@ -1264,7 +1264,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio justify-content: space-between; align-items: center; padding: 12px 16px; - border-bottom: 1px solid #ddd; + border-bottom: 1px solid #d4d4d4; background-color: #fff; } .ai-status-banner .status-content { @@ -1276,17 +1276,17 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio width: 8px; height: 8px; border-radius: 50%; - background-color: #999; + background-color: #bbb; } .ai-status-banner .status-text { font-size: 13px; - color: #333; + color: #222; } .ai-status-banner .download-button, .ai-status-banner .clear-history-button { padding: 6px 12px; - border: 1px solid #007bff; - background-color: #007bff; + border: 1px solid #3879D9; + background-color: #3879D9; color: #fff; border-radius: 4px; cursor: pointer; @@ -1295,7 +1295,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio } .ai-status-banner .download-button:hover:not(:disabled), .ai-status-banner .clear-history-button:hover:not(:disabled) { - background-color: #0056b3; + background-color: #2460ba; } .ai-status-banner .download-button:disabled, .ai-status-banner .clear-history-button:disabled { @@ -1303,42 +1303,42 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio cursor: not-allowed; } .ai-status-banner .clear-history-button { - background-color: #6c757d; - border-color: #6c757d; + background-color: #bbb; + border-color: #bbb; } .ai-status-banner .clear-history-button:hover:not(:disabled) { - background-color: #5a6268; + background-color: #a3a3a3; } .ai-status-banner.status-ready { - background-color: #d4edda; - border-color: #c3e6cb; + background-color: rgba(56, 121, 217, 0.2); + border-color: #d4d4d4; } .ai-status-banner.status-ready .status-indicator { - background-color: #28a745; + background-color: #3879D9; } .ai-status-banner.status-needs-download { - background-color: #fff3cd; - border-color: #ffeaa7; + background-color: rgba(136, 18, 128, 0.15); + border-color: #d4d4d4; } .ai-status-banner.status-needs-download .status-indicator { - background-color: #ffc107; + background-color: #881280; } .ai-status-banner.status-downloading { - background-color: #d1ecf1; - border-color: #bee5eb; + background-color: rgba(56, 121, 217, 0.15); + border-color: #d4d4d4; } .ai-status-banner.status-downloading .status-indicator { - background-color: #17a2b8; + background-color: #3879D9; animation: pulse 1.5s ease-in-out infinite; } .ai-status-banner.status-unavailable, .ai-status-banner.status-error { - background-color: #f8d7da; - border-color: #f5c6cb; + background-color: rgba(200, 0, 0, 0.15); + border-color: #d4d4d4; } .ai-status-banner.status-unavailable .status-indicator, .ai-status-banner.status-error .status-indicator { - background-color: #dc3545; + background-color: #C80000; } .ai-status-banner.status-unavailable .status-text, .ai-status-banner.status-error .status-text { @@ -1365,11 +1365,11 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .ai-welcome-message { text-align: center; padding: 32px 16px; - color: #666; + color: #a3a3a3; } .ai-welcome-message h3 { margin: 0 0 12px 0; - color: #333; + color: #222; font-size: 18px; } .ai-welcome-message p { @@ -1390,7 +1390,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio justify-content: flex-end; } .ai-message.message-user .message-content { - background-color: #007bff; + background-color: #3879D9; color: #fff; border-radius: 12px 12px 0 12px; } @@ -1399,8 +1399,8 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio } .ai-message.message-assistant .message-content { background-color: #fff; - color: #333; - border: 1px solid #ddd; + color: #fff; + border: 1px solid #d4d4d4; border-radius: 12px 12px 12px 0; } .ai-message.message-system { @@ -1408,8 +1408,8 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio max-width: 90%; } .ai-message.message-system .message-content { - background-color: #e9ecef; - color: #495057; + background-color: #eee; + color: #222; border-radius: 8px; text-align: center; font-size: 12px; @@ -1420,7 +1420,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio justify-content: space-between; margin-bottom: 4px; font-size: 11px; - color: #666; + color: #a3a3a3; padding: 0 8px; } .message-header .message-role { @@ -1432,10 +1432,44 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .message-content { padding: 10px 14px; word-wrap: break-word; - white-space: pre-wrap; + white-space: normal; font-size: 13px; line-height: 1.5; } +.message-content code { + background-color: rgba(187, 187, 187, 0.2); + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; +} +.message-content pre { + background-color: rgba(187, 187, 187, 0.15); + border: 1px solid #d4d4d4; + border-radius: 6px; + padding: 12px; + overflow-x: auto; + margin: 8px 0; +} +.message-content pre code { + background: none; + padding: 0; + font-size: 12px; + line-height: 1.4; +} +.message-content strong { + font-weight: 600; +} +.message-content em { + font-style: italic; +} +.message-content a { + color: #3879D9; + text-decoration: underline; +} +.message-content a:hover { + opacity: 0.8; +} @keyframes fadeIn { from { opacity: 0; @@ -1447,7 +1481,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio } } .ai-input-area { - border-top: 1px solid #ddd; + border-top: 1px solid #d4d4d4; background-color: #fff; padding: 12px 16px; } @@ -1457,10 +1491,10 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio gap: 6px; padding: 8px 12px; margin-bottom: 8px; - background-color: #e7f3ff; + background-color: rgba(56, 121, 217, 0.15); border-radius: 6px; font-size: 12px; - color: #004085; + color: #222; } .context-info .context-icon { font-size: 14px; @@ -1469,6 +1503,20 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio flex: 1; font-weight: 500; } +.context-info .context-clear-button { + background: none; + border: none; + color: #222; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + opacity: 0.6; + transition: opacity 0.2s; +} +.context-info .context-clear-button:hover { + opacity: 1; +} .input-wrapper { display: flex; gap: 8px; @@ -1477,24 +1525,25 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .ai-input { flex: 1; padding: 10px 12px; - border: 1px solid #ccc; + border: 1px solid #d4d4d4; border-radius: 6px; font-family: inherit; font-size: 13px; - resize: vertical; - max-height: 150px; + resize: none; + background-color: #fff; + color: #222; } .ai-input:focus { outline: none; - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); + border-color: #3879D9; + box-shadow: 0 0 0 2px rgba(56, 121, 217, 0.1); } .ai-input::placeholder { - color: #999; + color: #bbb; } .ai-send-button { padding: 10px 20px; - background-color: #007bff; + background-color: #3879D9; color: #fff; border: none; border-radius: 6px; @@ -1505,7 +1554,7 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio transition: background-color 0.2s; } .ai-send-button:hover:not(:disabled) { - background-color: #0056b3; + background-color: #2460ba; } .ai-send-button:disabled { opacity: 0.5; @@ -1513,29 +1562,36 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio } .input-footer { display: flex; - justify-content: flex-end; - margin-top: 6px; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding: 0 4px; } .token-counter { - font-size: 11px; - color: #666; + font-size: 12px; + color: #a3a3a3; + font-weight: 500; + padding: 4px 8px; + background-color: rgba(187, 187, 187, 0.1); + border-radius: 4px; } .token-counter.warning { - color: #dc3545; + color: #C80000; + background-color: rgba(200, 0, 0, 0.1); font-weight: 600; } .ai-messages-container::-webkit-scrollbar { width: 8px; } .ai-messages-container::-webkit-scrollbar-track { - background: #f1f1f1; + background: #eee; } .ai-messages-container::-webkit-scrollbar-thumb { - background: #888; + background: #bbb; border-radius: 4px; } .ai-messages-container::-webkit-scrollbar-thumb:hover { - background: #555; + background: #a3a3a3; } @media (max-width: 600px) { .ai-message { @@ -1555,3 +1611,208 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio margin-left: 0; } } +.json-viewer { + margin: 8px 0; +} +.json-viewer .json-wrapper { + position: relative; + background: #eee; + border: 1px solid #d4d4d4; + border-radius: 4px; + padding: 12px; + overflow-x: auto; +} +.json-viewer .json-copy-btn { + position: absolute; + top: 8px; + right: 8px; + background: #3879D9; + color: #fff; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; + z-index: 1; + transition: background-color 0.2s; +} +.json-viewer .json-copy-btn:hover { + background: #2460ba; +} +.json-viewer .json-tree { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 12px; + line-height: 1.6; + color: #222; +} +.json-viewer .json-line { + padding-left: 16px; + position: relative; +} +.json-viewer .json-content { + padding-left: 16px; +} +.json-viewer .json-content > .json-line:last-child { + padding-left: 0; +} +.json-viewer .json-toggle { + cursor: pointer; + user-select: none; + display: inline-block; + width: 14px; + color: #a3a3a3; + font-size: 10px; +} +.json-viewer .json-toggle:hover { + color: #3879D9; +} +.json-viewer .json-count { + color: #a3a3a3; + font-size: 11px; + margin-left: 6px; + font-style: italic; +} +.json-viewer .json-key { + color: #0451a5; + font-weight: 600; +} +.json-viewer .json-string { + color: #a31515; +} +.json-viewer .json-number { + color: #098658; +} +.json-viewer .json-boolean { + color: #0000ff; + font-weight: 600; +} +.json-viewer .json-null { + color: #808080; + font-style: italic; +} +.json-viewer .json-bracket { + color: #222; + font-weight: bold; +} +.code-viewer { + margin: 8px 0; +} +.code-viewer .code-wrapper { + position: relative; + background: #eee; + border: 1px solid #d4d4d4; + border-radius: 4px; + padding: 12px; + overflow-x: auto; +} +.code-viewer .code-lang { + position: absolute; + top: 8px; + right: 8px; + background: #bbb; + color: #fff; + border-radius: 4px; + padding: 2px 8px; + font-size: 10px; + text-transform: uppercase; + font-weight: 600; + opacity: 0.7; +} +.code-viewer .code-content { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 12px; + line-height: 1.6; + color: #222; +} +.code-viewer .code-line { + white-space: pre-wrap; + word-wrap: break-word; +} +.code-viewer .code-line:empty::before { + content: ' '; + display: inline-block; +} +.ai-confirm-dialog { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +.ai-confirm-dialog .confirm-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); +} +.ai-confirm-dialog .confirm-content { + position: relative; + background: #fff; + 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; +} +.ai-confirm-dialog .confirm-title { + font-size: 16px; + font-weight: 600; + color: #222; + margin-bottom: 12px; +} +.ai-confirm-dialog .confirm-message { + font-size: 13px; + color: #a3a3a3; + line-height: 1.5; + margin-bottom: 20px; +} +.ai-confirm-dialog .confirm-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; +} +.ai-confirm-dialog .confirm-button { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: background-color 0.2s; +} +.ai-confirm-dialog .confirm-button.confirm-cancel { + background-color: #eee; + color: #222; +} +.ai-confirm-dialog .confirm-button.confirm-cancel:hover { + background-color: #d5d5d5; +} +.ai-confirm-dialog .confirm-button.confirm-ok { + background-color: #C80000; + color: #fff; +} +.ai-confirm-dialog .confirm-button.confirm-ok:hover { + background-color: #950000; +} +@keyframes confirmSlideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.ai-message.message-assistant .message-content { + color: #222 !important; +} From a58c1d239dc004e669ec855d382d7314b99286ae Mon Sep 17 00:00:00 2001 From: Nikola Anachkov Date: Tue, 17 Feb 2026 14:18:37 +0200 Subject: [PATCH 4/4] fix: address comments --- app/html/panel/ui5/index.html | 2 +- app/scripts/background/main.js | 9 + app/scripts/devtools/panel/ui5/main.js | 2 - app/scripts/modules/ai/AISessionManager.js | 109 +++++-- app/scripts/modules/ui/AIChat.js | 303 +++++++++++++++--- app/scripts/modules/ui/ControlTree.js | 5 + app/styles/less/modules/AIChat.less | 145 +++++++-- tests/modules/ai/AISessionManager.spec.js | 240 ++++++++++++++ tests/modules/ai/ChatStorageManager.spec.js | 53 ++++ tests/modules/ui/AIChat.spec.js | 329 ++++++++++++++++++++ tests/styles/themes/dark/dark.css | 129 +++++++- tests/styles/themes/light/light.css | 129 +++++++- 12 files changed, 1331 insertions(+), 124 deletions(-) create mode 100644 tests/modules/ai/AISessionManager.spec.js create mode 100644 tests/modules/ai/ChatStorageManager.spec.js create mode 100644 tests/modules/ui/AIChat.spec.js diff --git a/app/html/panel/ui5/index.html b/app/html/panel/ui5/index.html index b7cc52f..3a37798 100644 --- a/app/html/panel/ui5/index.html +++ b/app/html/panel/ui5/index.html @@ -17,7 +17,7 @@ Application Information OData Requests Elements Registry - AI + AI Assistant diff --git a/app/scripts/background/main.js b/app/scripts/background/main.js index 86c32d5..6366d18 100644 --- a/app/scripts/background/main.js +++ b/app/scripts/background/main.js @@ -328,6 +328,15 @@ */ 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', diff --git a/app/scripts/devtools/panel/ui5/main.js b/app/scripts/devtools/panel/ui5/main.js index 7357fe2..fb05d73 100644 --- a/app/scripts/devtools/panel/ui5/main.js +++ b/app/scripts/devtools/panel/ui5/main.js @@ -432,8 +432,6 @@ var aiChat = new AIChat('ai-chat', { getAppInfo: function () { var currentFrameId = framesSelect.getSelectedId(); - console.log('[main.js] getAppInfo called. currentFrameId:', currentFrameId); - console.log('[main.js] frameData:', frameData); return frameData[currentFrameId] ? frameData[currentFrameId].applicationInformation : null; } }); diff --git a/app/scripts/modules/ai/AISessionManager.js b/app/scripts/modules/ai/AISessionManager.js index 1a29241..8d08f1a 100644 --- a/app/scripts/modules/ai/AISessionManager.js +++ b/app/scripts/modules/ai/AISessionManager.js @@ -198,6 +198,75 @@ AISessionManager.prototype.createSession = function () { }); }; +/** + * 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 @@ -206,42 +275,20 @@ AISessionManager.prototype.createSession = function () { * @returns {string} */ AISessionManager.prototype._formatPrompt = function (prompt, context) { + var MAX_SECTION_LENGTH = 2000; + if (!context || !context.control) { return prompt; } - let contextString = 'Current UI5 Control Context:\n'; - contextString += `- Type: ${context.control.type || 'Unknown'}\n`; - contextString += `- ID: ${context.control.id || 'None'}\n`; - - // Properties - if (context.control.properties && context.control.properties.own && context.control.properties.own.data && Object.keys(context.control.properties.own.data).length > 0) { - const propsPreview = context.control.properties.own.data; - contextString += `- Properties: ${JSON.stringify(propsPreview)}\n`; - } + var control = context.control; + var contextString = 'Current UI5 Control Context:\n'; + contextString += '- Type: ' + (control.type || 'Unknown') + '\n'; + contextString += '- ID: ' + (control.id || 'None') + '\n'; - // Bindings (detailed) - if (context.control.bindings && Object.keys(context.control.bindings).length > 0) { - contextString += `- Bindings (${Object.keys(context.control.bindings).length}):\n`; - try { - contextString += JSON.stringify(context.control.bindings, null, 2) + '\n'; - } catch (e) { - contextString += ' (Bindings data available but cannot serialize)\n'; - } - } - - // Aggregations (detailed) - if (context.control.aggregations) { - const ownAggr = context.control.aggregations.own; - if (ownAggr && ownAggr.data && Object.keys(ownAggr.data).length > 0) { - contextString += `- Aggregations (${Object.keys(ownAggr.data).length}):\n`; - try { - contextString += JSON.stringify(ownAggr.data, null, 2) + '\n'; - } catch (e) { - contextString += ' (Aggregations data available but cannot serialize)\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; }; diff --git a/app/scripts/modules/ui/AIChat.js b/app/scripts/modules/ui/AIChat.js index 9371910..b03d341 100644 --- a/app/scripts/modules/ui/AIChat.js +++ b/app/scripts/modules/ui/AIChat.js @@ -21,8 +21,12 @@ function AIChat(containerId, options) { 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(); } @@ -42,21 +46,21 @@ AIChat.prototype.init = function () { */ AIChat.prototype._render = function () { this._container.innerHTML = ` -
-
+
+
Checking model status...
- -
-
+

UI5 AI Assistant

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

@@ -65,10 +69,10 @@ AIChat.prototype._render = function () {
- -