diff --git a/HardwareReport/Chat.js b/HardwareReport/Chat.js new file mode 100644 index 00000000..7f67d1a6 --- /dev/null +++ b/HardwareReport/Chat.js @@ -0,0 +1,459 @@ + +//constants +const TARGET_ASSISTANT_NAME = "ArduPilot WebTool"; +const TARGET_ASSISTANT_MODEL = "gpt-4o"; + + +// global variables +let Openai = null; +let openai = null; +let assistantId = null; +let currentThreadId = null; +let fileId; +let documentTitle = document.title; //title change => signals new file upload +let apiKey = null; // Store the API key in memory + +//checks if user is connected to the internet, avoids cached imports +async function isActuallyOnlineViaCDN() { + try { + const res = await fetch('https://cdn.jsdelivr.net/npm/openai@4.85.4/package.json', { + method: 'HEAD', + cache: 'no-store', + }); + return res.ok; + } catch (e) { + return false; + } +} +//tries to load package from cdn, if it fails and there is no connection, hide chat widget +async function verifyCDNStatus() { + const isOnline = await isActuallyOnlineViaCDN(); + if (!isOnline) { + document.getElementById("ai-chat-widget-container").style.display = 'none' + return; + } + try { + Openai = (await import('https://cdn.jsdelivr.net/npm/openai@4.85.4/+esm')).OpenAI; + console.log(Openai); + + document.getElementById("ai-chat-widget-container").style.display = 'block' + } catch (e) { + document.getElementById("ai-chat-widget-container").style.display = 'none' + } +} + + +//makes mutiple checks crucial for the assistant to work +async function connectIfNeeded(){ + if (!apiKey) + throw new Error('openai API key not configured.'); + else + document.getElementById('api-error-message').style.display='none' + if (!openai){ + // instantiate openai object + openai = new Openai({apiKey, dangerouslyAllowBrowser: true}); + if (!openai) { + throw new Error('Could not connect to open AI'); + } + } + if (!assistantId){ + //create or find existing assistant + assistantId = await findAssistantIdByName(TARGET_ASSISTANT_NAME); + } + if (!currentThreadId){ + //create a new thread + currentThreadId = await createThread(); + } + + //if document title changes => signals that a new logs file was uploaded => upload the new file to the assitant + if (document.title !== documentTitle) { + fileId = await uploadLogs(); + documentTitle = document.title; + } + +} + +//stores the processed data from the logs file in a json file and uploads it to the assistant +async function uploadLogs() { + //store real time values of global variables in the logs array + //these global variables are declared in HardwareReport.js and are visible to this file + const logs = [ + { name: "version", value: version}, + { name: "Temperature", value: Temperature }, + { name: "Board_Voltage", value: Board_Voltage }, + { name: "power_flags", value: power_flags }, + { name: "performance_load", value: performance_load }, + { name: "performance_mem", value: performance_mem }, + { name: "performance_time", value: performance_time }, + { name: "stack_mem", value: stack_mem }, + { name: "stack_pct", value: stack_pct }, + { name: "log_dropped", value: log_dropped }, + { name: "log_buffer", value: log_buffer }, + { name: "log_stats", value: log_stats }, + { name: "clock_drift", value: clock_drift }, + { name: "ins", value: ins }, + { name: "compass", value: compass }, + { name: "baro", value: baro }, + { name: "airspeed", value: airspeed }, + { name: "gps", value: gps }, + { name: "rangefinder", value: rangefinder }, + { name: "flow", value: flow }, + { name: "viso", value: viso }, + { name: "can", value: can } + ]; + //create logs.json file from the array above + const jsonString = JSON.stringify(logs); + const blob = new Blob([jsonString], {type:"application/json"}) + const file = new File([blob], "logs.json", { type: "application/json" }); + + //delete the previously uploaded logs.json file before uploading the new one + const filesList = await openai.files.list(); + if (!filesList) + throw new Error("error fetching files list"); + filesList.data.forEach( file => file.filename === 'logs.json' && openai.files.del(file.id)); + + //upload new logs.json file + const uploadRes = await openai.files.create({ + file, + purpose: "assistants" + }); + if (!uploadRes) + throw new Error("error creating logs file"); + const fileId = uploadRes.id; + return fileId; + +} + +//handles vector store retrieval for use by assistant +async function getOrCreateVectorStore(name = "schema-store") { + //check if vectore store already exists + const list = await openai.beta.vectorStores.list(); + if (!list) + throw new Error("error fetching vector stores list"); + const existing = list.data.find(vs => vs.name === name); + if (existing) + return existing; + //create new vector store in case one doesn't already exist + const vectorStore = await openai.beta.vectorStores.create({ name }); + return vectorStore; +} + +//deleted old schema file, used primarily as part of the version update pipeline +async function purgeOldSchemaFile(vectorStoreId, targetFilename = "logs_schema_and_notes.txt") { + const refs = await openai.beta.vectorStores.files.list(vectorStoreId); + if (!refs) + throw new Error("error retrieving vector store files list"); + for (const ref of refs.data) { + const file = await openai.files.retrieve(ref.id); + if (!file) + throw new Error("error retrieving file"); + if (file.filename === targetFilename) { + //detach from vector store + await openai.beta.vectorStores.files.del(vectorStoreId, ref.id); + //delete the file itself + await openai.files.del(ref.id); + } + } +} + + +async function uploadNewSchemaFile(vectorStoreId, file) { + //uploads new schema file + const newFile = await openai.files.create({ + file, + purpose: "assistants", + }); + if (!newFile) + throw new Error("could not create new schema file"); + + // add and wait until embeddings are ready + await openai.beta.vectorStores.fileBatches.createAndPoll(vectorStoreId, { + file_ids: [newFile.id], + }); + + return newFile.id; +} + +//handles schema loading, vector store creation and file updating as part of versioning +async function uploadSchema(){ + const file = await loadSchema(); + const vectorStore = await getOrCreateVectorStore(); + await purgeOldSchemaFile(vectorStore.id); + await uploadNewSchemaFile(vectorStore.id, file); + return vectorStore.id; +} + +//creates a new thread for use by the assistant +async function createThread(){ + if (!assistantId) + throw new Error("cannot create thread before initializing assistant"); + const newThread = await openai.beta.threads.create(); + if (!newThread) + throw new Error("something went wrong while creating thread"); + return newThread.id; +} + +async function createAssistant(name, instructions, model, tools){ + //get vectore store id needed for assistant creation + const vectorStoreId = await uploadSchema(); + const assistant = await openai.beta.assistants.create({ + instructions, + name, + model, + tools, + tool_resources: {file_search:{vector_store_ids: [vectorStoreId]}} + }); + if (!assistant) + throw new Error("error creating new assistant"); + return assistant; +} + +async function findAssistantIdByName(name) { + //if we have assitant id, terminate function + if (assistantId) return assistantId; + //retrive all listed assistants and look for the one with the specified name + const assistantsList = await openai.beta.assistants.list({order: "desc", limit: 20}); + if (!assistantsList) + throw new Error("could not retrieve the list of assistants"); + let assistant = assistantsList.data.find(a => a.name === name); + //if assistant doesn't exist, create it. + if (!assistant){ + const assistantInstructions = await loadInstructions(); + const assistantTools = await loadTools(); + assistant = await createAssistant(TARGET_ASSISTANT_NAME, assistantInstructions, TARGET_ASSISTANT_MODEL, assistantTools); + } + return assistant.id; +} + +//handles sending a message to the assistant and managing the streamed response +async function sendQueryToAssistant(query){ + //check if a connection is established + await connectIfNeeded(); + //create a new message with the user query, if user uploaded a logs file, it will be attached to the message + const message = await openai.beta.threads.messages.create(currentThreadId, { + role: "user", + content: query, + attachments: fileId && [{ + file_id: fileId, + tools: [{ type: "code_interpreter" }] + }] }); + if (!message) + throw new Error("Could not send message to assistant"); + + //create a new run with the added message and stream the response + const run = openai.beta.threads.runs.stream(currentThreadId, {assistant_id: assistantId}); + if (!run) + throw new Error("Could not establish run streaming"); + //UI update for the user + const messagesContainer = document.querySelector('#ai-chat-window .ai-chat-messages'); + document.getElementById('thinking-message').style.display= 'block'; + messagesContainer.scrollTop = messagesContainer.scrollHeight; + + handleRunStream(run); + +} + +//handling the streamed response from the assitant +async function handleRunStream(runStream){ + if (!runStream) + throw new Error("run stream not defined"); + //run stream is in the form of an async iterable + for await (const event of runStream) { + //handling based on event type + switch (event.event) { + case 'thread.message.delta': + //message stream + document.getElementById('thinking-message').style.display= 'none'; + addChatMessage(event.data.delta.content[0].text.value, "assistant"); + break; + case 'thread.run.requires_action': + //assistant would like to call a tool + document.getElementById('thinking-message').style.display= 'none'; + handleToolCall(event); + break; + } + } +} + +//calls the tool requested by the assistant, submits the tool output, and handles the run +async function handleToolCall(event) { + //sanity check + if (!event.data.required_action.submit_tool_outputs || !event.data.required_action.submit_tool_outputs.tool_calls) + throw new Error ("passed event does not require action"); + + const toolCalls = event.data.required_action.submit_tool_outputs.tool_calls; + const toolOutputs = []; + + for (const toolCall of toolCalls){ + const toolCallId = toolCall.id; + const toolName = toolCall.function.name; + //supported function names so far, these functions are defined in HardwareReport.js and are visible within this file + const supportedTools = new Set(["save_all_parameters","save_changed_parameters","save_minimal_parameters"]); + let toolOutput = {tool_call_id: toolCallId}; + //check if the tool requested is part of the supported tools + if (supportedTools.has(toolName)){ + //call the tool + window[toolName](); + toolOutput.output = "success"; + } + else { + //failure message for the assistant + toolOutput.output = "failure, the function that was called is not supported"; + } + toolOutputs.push(toolOutput); + } + + //submit all the called tools outputs together + const run = await openai.beta.threads.runs.submitToolOutputs( + currentThreadId, + event.data.id, + { + tool_outputs: toolOutputs, + stream: true + } + ); + + if (!run) + throw new Error ("error occurred while submitting tool outputs"); + const messagesContainer = document.querySelector('#ai-chat-window .ai-chat-messages'); + document.getElementById('thinking-message').style.display= 'block'; + messagesContainer.scrollTop = messagesContainer.scrollHeight; + //handle the run again + handleRunStream(run); + +} + +//load the system instructions file for use by the assitant +async function loadInstructions() { + const response = await fetch('instructions.txt'); + if (!response.ok) + throw new Error('error fetching file'); + const data = await response.text(); + if (!data) + throw new Error("could not load instructions for new assistant"); + return data; +} + +//load the schema and notes file for the assistant, used to detail to the assitant how to process logs.json +async function loadSchema() { + const response = await fetch('logs_schema_and_notes.txt'); + if (!response.ok) + throw new Error('error fetching file'); + const blob = await response.blob(); + if (!blob) + throw new Error("could not load instructions for new assistant"); + return new File([blob], 'logs_schema_and_notes.txt',{ type:blob.type}); +} + +//load the assistant tools file, defining all the tools accessible to the assistant +async function loadTools(){ + const response = await fetch("assistantTools.json"); + if (!response.ok) + throw new Error("error fetching file"); + const data = await response.json(); + if (!data) + throw new Error("could not load assistant tools for new assistant"); + return data; +} + +//upgrade assistant version, deletes the old assistant and creates a completely new one +async function upgradeAssistant() { + //UI feedback + const upgradeButton = document.getElementById('upgrade-assistant'); + upgradeButton.title = 'Upgrade in progress...'; + upgradeButton.textContent = "Upgrading..."; + //connection check + await connectIfNeeded(); + //delete assistant + const response = await openai.beta.assistants.del(assistantId); + if (!response) + throw new Error("error deleting assitant"); + //signal that the assitant was deleted + assistantId=null; + + //connecting again would automatically recreate a new assistant with no additional overhead + await connectIfNeeded(); + + //check that a new assistant was created + if (assistantId){ + upgradeButton.title = 'Upgraded successfully to the newest Assistant version'; + upgradeButton.textContent = 'Upgraded'; + } + +} + +//save the api key in memory only, user will have to re-enter it when page is refreshed +function saveAPIKey(event){ + //prevent default form behavior of page refresh upon submission + event.preventDefault(); + apiKey = document.getElementById('openai-api-key').value.trim(); +} + +//toggle chat window +function toggleChat(show) { + //retrieve DOM elements + const chatWindow = document.getElementById('ai-chat-window'); + const chatBubble = document.getElementById('ai-chat-bubble'); + if (show){ + //show window, hide bubble + chatWindow.style.display = 'flex'; + chatBubble.style.display = 'none'; + } + else{ + //show bubble, hide window + chatWindow.style.display = 'none'; + chatBubble.style.display = 'flex'; + } +} + +//triggered by user's enter key press or send button click +function sendMessage(event) { + //prevent default form behavior of page refresh upon submission + event.preventDefault(); + const messageInput = document.getElementById('ai-chat-input'); + const messageText = messageInput.value.trim(); + //add to chat and send to assitant + if (messageText) { + addChatMessage(messageText, 'user'); + messageInput.value = ''; + sendQueryToAssistant(messageText); + } +} + +function addChatMessage(text, sender) { + //assistant might add non native formatting or file annotations to text messages, these regex replacements remove them. + text=text.replace(/【[\d:]+†[^】]*】/g, ''); + text = text.replace(/\*/g, ''); + + const messagesContainer = document.querySelector('#ai-chat-window .ai-chat-messages'); + //stream the assitant response + if (sender === "assistant") { + let last_message = messagesContainer.querySelector(`.assistant:last-of-type`); + if (last_message) { + last_message.textContent += text; + messagesContainer.scrollTop = messagesContainer.scrollHeight; + return; + } + } + //add user message + const messageElement = document.createElement('li'); + messageElement.classList.add('ai-chat-message', sender); + messageElement.textContent = text; + messagesContainer.appendChild(messageElement); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + + +async function init(){ + //define event listeners + document.getElementById("ai-chat-bubble").addEventListener('click', ()=>toggleChat(true)); + document.getElementById("ai-chat-close-button").addEventListener('click', ()=>toggleChat(false)); + document.getElementById("ai-chat-input-area").addEventListener('submit', sendMessage); + document.getElementById('save-api-key').addEventListener('submit', saveAPIKey); + document.getElementById('upgrade-assistant').addEventListener('click',upgradeAssistant); + //attempt to load package from cdn, if it fails => user is offline => hide chat widget, webtool would still work offline + verifyCDNStatus(); + +} + +init(); \ No newline at end of file diff --git a/HardwareReport/HardwareReport.js b/HardwareReport/HardwareReport.js index a4ea7460..b067e964 100644 --- a/HardwareReport/HardwareReport.js +++ b/HardwareReport/HardwareReport.js @@ -2524,6 +2524,7 @@ function TimeUS_to_seconds(TimeUS) { let params = {} let defaults = {} +let version = {} async function load_log(log_file) { // Make sure imports are fully loaded before starting @@ -2583,7 +2584,7 @@ async function load_log(log_file) { load_params(log) - const version = get_version_and_board(log) + version = get_version_and_board(log) if (version.fw_string != null) { let section = document.getElementById("VER") diff --git a/HardwareReport/assistantTools.json b/HardwareReport/assistantTools.json new file mode 100644 index 00000000..319a99fb --- /dev/null +++ b/HardwareReport/assistantTools.json @@ -0,0 +1,46 @@ +[ + { "type": "file_search" }, + { "type": "code_interpreter" }, + { + "type": "function", + "function": { + "name": "save_all_parameters", + "description": "Saves all parameters", + "strict": true, + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "save_changed_parameters", + "description": "Saves changed parameters", + "strict": true, + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "save_minimal_parameters", + "description": "Saves minimal parameters", + "strict": true, + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + } + } + } +] diff --git a/HardwareReport/index.html b/HardwareReport/index.html index 45ac8c0e..3d051fc2 100644 --- a/HardwareReport/index.html +++ b/HardwareReport/index.html @@ -31,6 +31,176 @@ div.plotly-notifier { visibility: hidden; } + + #ai-chat-widget-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + } + + #ai-chat-bubble { + width: 56px; + height: 56px; + background-color: #007AFF; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); + transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out; + } + + #ai-chat-bubble:hover { + background-color: #005ecb; + transform: scale(1.05); + } + + #ai-chat-bubble svg { + width: 26px; + height: 26px; + fill: white; + } + + #ai-chat-window { + width: 460px; + height: 550px; + background-color: #ffffff; + border-radius: 12px; + box-shadow: 0 5px 25px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + overflow: hidden; + } + + .ai-chat-header { + background-color: #f8f8f8; + color: #333333; + padding: 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #e0e0e0; + } + + .ai-chat-header h3 { + margin: 0; + font-size: 1em; + font-weight: 600; + } + + .ai-chat-close-button { + background: none; + border: none; + color: #8e8e93; + font-size: 22px; + cursor: pointer; + padding: 5px; + line-height: 1; + } + + .ai-chat-close-button svg { + width: 16px; + height: 16px; + fill: #8e8e93; + } + .ai-chat-close-button:hover svg { + fill: #333333; + } + + .ai-chat-messages { + flex-grow: 1; + padding: 16px; + overflow-y: auto; + background-color: #ffffff; + display: flex; + flex-direction: column; + gap: 12px; + } + + .ai-chat-message { + max-width: 85%; + padding: 10px 14px; + border-radius: 18px; + line-height: 1.45; + font-size: 0.9em; + word-wrap: break-word; + order: 0; + white-space: pre-wrap; + } + + #thinking-message { + order: 1; + display: none; + } + + #api-error-message { + order: 1; + display: none; + color: red; + } + + .ai-chat-message.user { + background-color: #007AFF; + color: white; + align-self: flex-end; + border-bottom-right-radius: 6px; + } + + .ai-chat-message.assistant { + background-color: #f0f0f0; + color: #2c2c2e; + align-self: flex-start; + border-bottom-left-radius: 6px; + } + + .ai-chat-input-area { + display: flex; + padding: 12px; + border-top: 1px solid #e0e0e0; + background-color: #f8f8f8; + align-items: center; + } + + .ai-chat-input-area input { + flex-grow: 1; + border: 1px solid #d1d1d6; + border-radius: 20px; + padding: 10px 16px; + font-size: 0.9em; + margin-right: 8px; + background-color: #ffffff; + } + + .ai-chat-input-area input:focus { + outline: none; + border-color: #007AFF; + box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2); + } + + .ai-chat-input-area button { + background-color: #007bff; + border: none; + color: white; + padding: 10px 15px; + border-radius: 20px; + cursor: pointer; + font-size: 0.9em; + transition: background-color 0.3s ease; + } + + .ai-chat-input-area button:hover { + background-color: #0056b3; + } + #upgrade-assistant { + background-color: #28a745; + margin-left: 8px; + } + #upgrade-assistant:hover { + background-color: #228d3b; + }