UNPKG

iobroker.ai-assistant

Version:

AI Assistant adapter allows you to control your ioBroker trought artifical intelligence based on LLMs

1,250 lines (1,131 loc) 61.4 kB
"use strict"; /* * Created with @iobroker/create-adapter v2.6.5 */ const utils = require("@iobroker/adapter-core"); const I18n = require("@iobroker/i18n"); const { jsonrepair } = require("jsonrepair"); // Providers const AnthropicAiProvider = require("./lib/providers/anthropic-ai-provider"); const OpenAiProvider = require("./lib/providers/openai-ai-provider"); const PerplexityAiProvider = require("./lib/providers/perplexity-ai-provider"); const OpenRouterAiProvider = require("./lib/providers/openrouter-ai-provider"); const DeepseekAiProvider = require("./lib/providers/deepseek-ai-provider"); const CustomAiProvider = require("./lib/providers/custom-ai-provider"); // Tools const StatesTool = require("./lib/tools/states-tool"); const SchedulerTool = require("./lib/tools/scheduler-tool"); const TriggerTool = require("./lib/tools/trigger-tool"); class AiAssistant extends utils.Adapter { /** * @param [options] - The options for the adapter instance. */ constructor(options) { super({ ...options, name: "ai-assistant", }); this.on("ready", this.onReady.bind(this)); this.on("stateChange", this.onStateChange.bind(this)); this.on("message", this.onMessage.bind(this)); this.on("unload", this.onUnload.bind(this)); this.provider = null; this.timeouts = []; } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { if (!this.config.assistant_language) { this.config.assistant_language = "en"; } await I18n.init(__dirname, this.config.assistant_language); this.I18n = I18n; this.jsonRepair = jsonrepair; this.scheduler = new SchedulerTool(this); this.trigger = new TriggerTool(this); this.log.debug(await this.getAvailableEndpointsStructure()); const models = await this.getAvailableModels(); if (models.length == 0) { this.log.error("No Models set, cant start adapter!"); return; } if (models.filter(model => model.value == this.config.assistant_model).length == 0) { this.log.error(`Model ${this.config.assistant_model} not found in available models, cant start adapter!`); return; } if (!this.config.assistant_model || this.config.assistant_model == "") { this.log.error("No model set for assistant, cant start adapter!"); return; } this.provider = await this.getModelProvider(this.config.assistant_model); if (!this.provider) { this.log.error(`No provider set for model ${this.config.assistant_model}, cant start adapter!`); return; } this.log.info( `Starting adapter with provider: ${this.provider.name} and model: ${this.config.assistant_model}`, ); // Create Models and Assistant objects await this.setObjectAsync("Models", { type: "folder", common: { name: "AI Models", desc: "Statistics and Data for used AI Models", }, native: {}, }); await this.setObjectAsync("Assistant", { type: "folder", common: { name: "Assistant", desc: "Interact with your Assistant", }, native: {}, }); await this.setObjectAsync("Cronjobs", { type: "folder", common: { name: "Cronjobs", desc: "Cronjobs created by Assistant", }, native: {}, }); await this.setObjectAsync("Triggers", { type: "folder", common: { name: "Triggers", desc: "Triggers created by Assistant", }, native: {}, }); // Create objects for each model for (let model of models) { const modelName = model.value; model = this.stringToAlphaNumeric(model.value); this.log.debug(`Initializing objects for model: ${model}`); await this.setObjectAsync(`Models.${model}`, { type: "folder", common: { name: model, desc: `Model ${modelName} for the AI Assistant`, }, native: {}, }); await this.setObjectAsync(`Models.${model}.statistics`, { type: "folder", common: { name: "Statistics", desc: `Statistics for the model ${modelName} like requests count, tokens used, etc.`, }, native: {}, }); await this.setObjectAsync(`Models.${model}.response`, { type: "folder", common: { name: "Response data", desc: `Response data for the model ${modelName} like raw response, error response, etc.`, }, native: {}, }); await this.setObjectAsync(`Models.${model}.request`, { type: "folder", common: { name: "Request data", desc: `Request data for the model ${modelName} like request body, state, etc.`, }, native: {}, }); await this.setObjectNotExistsAsync(`Models.${model}.request.state`, { type: "state", common: { name: "Request state", desc: "State for the running inference request", type: "string", role: "text", read: true, write: false, def: "", }, native: {}, }); await this.setObjectNotExistsAsync(`Models.${model}.request.body`, { type: "state", common: { name: "Request body", desc: "Sent body for the running inference request", type: "string", role: "json", read: true, write: false, def: "", }, native: {}, }); await this.setObjectNotExistsAsync(`Models.${model}.response.raw`, { type: "state", common: { name: "Raw response", desc: `Raw response for model${modelName}`, type: "string", role: "json", read: true, write: false, def: "", }, native: {}, }); await this.setObjectNotExistsAsync(`Models.${model}.response.error`, { type: "state", common: { name: "Error response", desc: `Error response for model${modelName}`, type: "string", role: "text", read: true, write: false, def: "", }, native: {}, }); await this.setObjectNotExistsAsync(`Models.${model}.statistics.tokens_input`, { type: "state", common: { name: "Input tokens", desc: `Used input tokens for model${modelName}`, type: "number", role: "state", read: true, write: false, def: 0, }, native: {}, }); await this.setObjectNotExistsAsync(`Models.${model}.statistics.tokens_output`, { type: "state", common: { name: "Output tokens", desc: `Used output tokens for model${modelName}`, type: "number", role: "state", read: true, write: false, def: 0, }, native: {}, }); await this.setObjectNotExistsAsync(`Models.${model}.statistics.requests_count`, { type: "state", common: { name: "Count requests", desc: `Count of requests for model${modelName}`, type: "number", role: "state", read: true, write: false, def: 0, }, native: {}, }); await this.setObjectNotExistsAsync(`Models.${model}.statistics.last_request`, { type: "state", common: { name: "Last request", desc: `Last request for model${modelName}`, type: "string", role: "date", read: true, write: false, def: "", }, native: {}, }); } // Create objects for assistant this.log.debug("Initializing objects for Assistant: "); await this.setObjectAsync("Assistant.text_request", { type: "state", common: { name: "Start request", desc: "Text to send to the Assistant", type: "string", role: "text", read: true, write: true, def: "", }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.text_response", { type: "state", common: { name: "Text response", desc: "Text response from the Assistant", type: "string", role: "text", read: true, write: false, def: "", }, native: {}, }); await this.setObjectAsync("Assistant.statistics", { type: "folder", common: { name: "Statistics", desc: "Statistics for the Assistant like requests count, tokens used, etc.", }, native: {}, }); await this.setObjectAsync("Assistant.response", { type: "folder", common: { name: "Response data", desc: "Response data for the Assistant like raw response, error response, etc.", }, native: {}, }); await this.setObjectAsync("Assistant.request", { type: "folder", common: { name: "Request data", desc: "Request data for the Assistant like request body, state, etc.", }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.statistics.messages", { type: "state", common: { name: "Previous messages", desc: "Previous messages for the Assistant", type: "string", role: "json", read: true, write: false, def: '{"messages": []}', }, native: {}, }); await this.setObjectAsync("Assistant.statistics.clear_messages", { type: "state", common: { name: "Clear messages", desc: "Clear previous message history for the Assistant", type: "boolean", role: "button", read: false, write: true, def: true, }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.request.state", { type: "state", common: { name: "State", desc: "State for the running inference request", type: "string", role: "text", read: true, write: false, def: "", }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.request.body", { type: "state", common: { name: "Request body", desc: "Sent body for the running inference request", type: "string", role: "json", read: true, write: false, def: "", }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.response.raw", { type: "state", common: { name: "Response Raw", desc: "Raw response from Assistant", type: "string", role: "json", read: true, write: false, def: "", }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.response.error", { type: "state", common: { name: "Error response", desc: "Error response from Assistant", type: "string", role: "text", read: true, write: false, def: "", }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.statistics.tokens_input", { type: "state", common: { name: "Input tokens", desc: "Used input tokens for Assistant", type: "number", role: "state", read: true, write: false, def: 0, }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.statistics.tokens_output", { type: "state", common: { name: "Output tokens", desc: "Used output tokens for Assistant", type: "number", role: "state", read: true, write: false, def: 0, }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.statistics.requests_count", { type: "state", common: { name: "Requests count", desc: "Count of requests for Assistant", type: "number", role: "state", read: true, write: false, def: 0, }, native: {}, }); await this.setObjectNotExistsAsync("Assistant.statistics.last_request", { type: "state", common: { name: "Last request", desc: "Last request for Assistant", type: "string", role: "date", read: true, write: false, def: "", }, native: {}, }); this.subscribeStates("*"); this.log.info("Adapter ready"); } /** * Is called when adapter shuts down - callback has to be called under any circumstances! * * @param callback - The callback function. */ onUnload(callback) { try { for (const timeout of this.timeouts) { this.clearTimeout(timeout); } callback(); } catch (e) { this.log.warn(`Error on unload: ${e}`); callback(); } } /** * Is called if a subscribed state changes * * @param id - The state ID that changed. * @param state - The new state. */ async onStateChange(id, state) { if (state) { // The state was changed if (id.includes(".clear_messages") && state.val && state.ack == false) { await this.clearHistory(); return; } if (id.includes("Assistant.") && id.includes(".text_request") && state.val && state.ack == false) { this.startAssistantRequest(state.val); return; } this.log.debug(`state ${id} changed: ${state.val} (ack = ${state.ack})`); this.trigger.triggerCheck(id, state.val); } } /** * Clear the chat history for the Assistant. */ async clearHistory() { this.log.info("Clearing message history"); await this.setStateAsync("Assistant.statistics.messages", { val: '{"messages": []}', ack: true }); await this.setStateAsync("Assistant.response.raw", { val: null, ack: true }); await this.setStateAsync("Assistant.text_response", { val: null, ack: true }); await this.setStateAsync("Assistant.response.error", { val: null, ack: true }); await this.setStateAsync("Assistant.request.body", { val: null, ack: true }); await this.setStateAsync("Assistant.request.state", { val: null, ack: true }); } /** * Start a request for the Assistant with the specified text. * * @param text - The text to send to the Assistant. * @param tries - The number of tries for the request. * @param try_only_once - If true, the request will only be tried once. * @param functionResponse - If true, the request will be started as function response. */ async startAssistantRequest(text, tries = 1, try_only_once = false, functionResponse = false) { this.log.info(`Starting request for Assistant with text: ${text}`); if (tries == 1) { await this.setStateAsync("Assistant.request.state", { val: "start", ack: true }); } await this.setStateAsync("Assistant.response.error", { val: "", ack: true }); if (this.provider) { if (!this.provider.apiTokenCheck()) { this.log.warn(`No API token set for provider ${this.provider.name}, cant start request!`); return false; } const messages = []; let messagePairs = { messages: [] }; if (this.config.chat_history > 0) { this.log.debug("Chat history is enabled for Assistant"); messagePairs = await this.getValidatedMessageHistory(); this.log.debug("Adding previous message pairs for request"); } this.log.debug("Converting message pairs to chat format for request to model"); for (const message of messagePairs.messages) { this.log.debug(`Adding message pair to request array: ${message.user} - ${message.assistant}`); messages.push({ role: "user", content: message.user }); messages.push({ role: "assistant", content: message.assistant }); } let textMessage = text; if (!functionResponse) { this.log.debug(`Adding user message to request array: ${textMessage}`); textMessage = ` ${I18n.translate("assistant_current_time")} ${new Date().toLocaleString()} ${I18n.translate("assistant_output_language")} ${this.config.assistant_language} ${I18n.translate("assistant_message_from_user")} ${text} `; } else { this.log.debug(`Adding function response to request array: ${textMessage}`); } this.log.debug(`Adding user message to request array: ${textMessage}`); messages.push({ role: "user", content: textMessage }); const modelResponse = await this.startModelRequest( messages, await this.buildSystemPrompt(), this.config.max_tokens, this.config.temperature, ); let requestCompleted = true; if (modelResponse.error) { await this.setStateAsync("Assistant.request.state", { val: "error", ack: true }); await this.setStateAsync("Assistant.request.body", { val: JSON.stringify(modelResponse.requestData), ack: true, }); await this.setStateAsync("Assistant.response.error", { val: modelResponse.error, ack: true }); await this.setStateAsync("Assistant.response.raw", { val: JSON.stringify(modelResponse.responseData), ack: true, }); this.log.warn(`Request for Assistant failed Text: ${text} Error: ${modelResponse.error}`); requestCompleted = false; } else { await this.setStateAsync("Assistant.request.state", { val: "success", ack: true }); await this.setStateAsync("Assistant.request.body", { val: JSON.stringify(modelResponse.requestData), ack: true, }); await this.setStateAsync("Assistant.response.error", { val: "", ack: true }); await this.setStateAsync("Assistant.response.raw", { val: JSON.stringify(modelResponse.responseData), ack: true, }); if (modelResponse.text && modelResponse.text.trim() != "") { //modelResponse.text = modelResponse.text.replace(/(\r\n|\n|\r|\t)/gm, ""); this.log.debug(`Assistant response: ${modelResponse.text}`); try { this.handleAssistantResponse(modelResponse.text); } catch (error) { this.log.error(`Error handling Assistant response: ${error}`); await this.setStateAsync("Assistant.request.state", { val: "error", ack: true }); await this.setStateAsync("Assistant.request.body", { val: JSON.stringify(modelResponse.requestData), ack: true, }); await this.setStateAsync("Assistant.response.error", { val: error, ack: true }); await this.setStateAsync("Assistant.response.raw", { val: JSON.stringify(modelResponse.responseData), ack: true, }); requestCompleted = false; } } else { this.log.warn("Assistant response text is empty, cant handle response!"); await this.setStateAsync("Assistant.request.state", { val: "error", ack: true }); await this.setStateAsync("Assistant.request.body", { val: JSON.stringify(modelResponse.requestData), ack: true, }); await this.setStateAsync("Assistant.response.error", { val: "Malformed model answer or missing text response", ack: true, }); await this.setStateAsync("Assistant.response.raw", { val: JSON.stringify(modelResponse.responseData), ack: true, }); requestCompleted = false; } } if (!requestCompleted) { if (typeof this.config.retry_delay == "undefined" || this.config.retry_delay == null) { this.config.retry_delay = 15; } if (typeof this.config.max_retries == "undefined" || this.config.max_retries == null) { this.config.max_retries = 3; } await this.setStateAsync("Assistant.request.state", { val: "retry", ack: true }); if (tries < this.config.max_retries && !try_only_once) { let retry_delay = this.config.retry_delay * 1000; if (tries == this.config.max_retries) { retry_delay = 0; } this.log.debug( `Try ${tries}/${this.config.max_retries} of request for Assistant failed Text: ${text}`, ); tries = tries + 1; this.log.debug(`Retry request for Assistant in ${this.config.retry_delay} seconds Text: ${text}`); const timeoutConfig = { text: text, tries: tries, try_only_once: try_only_once, functionResponse: functionResponse, }; this.timeouts.push( this.setTimeout( timeoutConfig => { this.startAssistantRequest( timeoutConfig.text, timeoutConfig.tries, timeoutConfig.try_only_once, timeoutConfig.functionResponse, ); }, retry_delay, timeoutConfig, ), ); } else { this.log.error(`Request for Assistant failed after ${tries} tries Text: ${text}`); await this.setStateAsync("Assistant.request.state", { val: "failed", ack: true }); return false; } } else { this.log.info(`Request for Assistant successful Text: ${text} Response: ${modelResponse.text}`); await this.addMessagePairToHistory( text, modelResponse.text, modelResponse.tokens_input, modelResponse.tokens_output, modelResponse.model, ); return modelResponse; } } } /** * Build the system prompt for the assistant. * * @returns - The system prompt for the assistant. */ async buildSystemPrompt() { let systemPrompt = ` ${I18n.translate("assistant_system_prompt_1", this.config.assistant_name, this.config.assistant_personality)} ${I18n.translate("assistant_system_prompt_2")} `; systemPrompt = systemPrompt + (await this.buildFunctionPrompt()); //return systemPrompt.replace(/(\r\n|\n|\r)/gm, ""); return systemPrompt; } /** * Build the function prompt for the assistant. * * @returns - The function prompt for the assistant. */ async buildFunctionPrompt() { const functionPrompt = ` ${I18n.translate("assistant_function_prompt_1")} [ {"name": "states", "description": "${I18n.translate("assistant_function_states_tool")}"}, {"name": "scheduler", "description": "${I18n.translate("assistant_function_scheduler_tool")}"}, {"name": "trigger", "description": "${I18n.translate("assistant_function_trigger_tool")}"}, {"name": "deleteHistory", "description": "${I18n.translate("assistant_function_delete_history")} ${await this.getCustomFunctionsPrompt()} ]; ${I18n.translate("assistant_function_prompt_2")} { "reasoning": "${I18n.translate("assistant_function_reasoning")}", "functionCall": "${I18n.translate("assistant_function_call")}", "functionTextInstructionString": "${I18n.translate("assistant_function_instruction")}", "userResponse": "${I18n.translate("assistant_function_user_response")}" } ${I18n.translate("assistant_function_prompt_3")} ${I18n.translate("assistant_function_prompt_4")} `; //return functionPrompt.replace(/(\r\n|\n|\r)/gm, ""); return functionPrompt; } /** * Build the custom functions prompt for the assistant. * * @returns - The custom functions prompt for the assistant. */ async getCustomFunctionsPrompt() { let customFunctionPrompt = ""; if (this.config.available_functions && this.config.available_functions.length > 0) { for (const customFunction of this.config.available_functions) { customFunctionPrompt = `${customFunctionPrompt}{"name":"${customFunction.name}", "description":"${ customFunction.description }"},\n`; } } return customFunctionPrompt; } /** * Starts a request for the specified model with the specified messages. * Validates the request and returns the response data if the request was successful. * Updates the statistics for the model with the response data. * Logs the request and response data. * * @param messages - The messages to send to the model. * @param system_prompt - The system prompt for the model. * @param max_tokens - The maximum number of tokens to generate. * @param temperature - The temperature for the model. * @returns - Returns the response data if the request was successful, otherwise false. */ async startModelRequest(messages, system_prompt = null, max_tokens = 2000, temperature = 0.6) { const modelDatapointName = this.stringToAlphaNumeric(this.config.assistant_model); this.log.info(`Starting request for model: ${this.config.assistant_model}`); if (this.provider) { if (!this.provider.apiTokenCheck()) { this.log.warn(`No API token set for provider ${this.provider.name}, cant start request!`); return false; } await this.setStateAsync(`Models.${modelDatapointName}.request.state`, { val: "start", ack: true }); await this.setStateAsync(`Models.${modelDatapointName}.response.error`, { val: "", ack: true }); const request = { model: this.config.assistant_model, messages: messages, max_tokens: max_tokens, temperature: temperature, system_prompt: system_prompt, feedback_device: `Model.${modelDatapointName}`, }; if (!this.validateRequest(request)) { await this.setStateAsync(`Models.${modelDatapointName}.request.state`, { val: "error", ack: true, }); await this.setStateAsync(`Models.${modelDatapointName}.response.error`, { val: "Request Validation failed", ack: true, }); this.log.warn(`Request for Model ${this.config.assistant_model} failed validation, stopping request`); return; } const modelResponse = await this.provider.request(request); modelResponse.requestData = this.provider.requestData; modelResponse.responseData = this.provider.responseData; if (modelResponse.error) { await this.setStateAsync(`Models.${modelDatapointName}.request.state`, { val: "error", ack: true, }); await this.setStateAsync(`Models.${modelDatapointName}.response.error`, { val: modelResponse.error, ack: true, }); await this.setStateAsync(`Models.${modelDatapointName}.request.body`, { val: JSON.stringify(modelResponse.requestData), ack: true, }); await this.setStateAsync(`Models.${modelDatapointName}.response.raw`, { val: JSON.stringify(modelResponse.responseData), ack: true, }); await this.setStateAsync("Assistant.text_response", { val: `Error: ${modelResponse.error}`, ack: true, }); } else { await this.setStateAsync(`Models.${modelDatapointName}.request.state`, { val: "success", ack: true, }); await this.setStateAsync(`Models.${modelDatapointName}.response.error`, { val: "", ack: true }); await this.setStateAsync(`Models.${modelDatapointName}.request.body`, { val: JSON.stringify(modelResponse.requestData), ack: true, }); await this.setStateAsync(`Models.${modelDatapointName}.response.raw`, { val: JSON.stringify(modelResponse.responseData), ack: true, }); this.updateModelStatistics(this.config.assistant_model, modelResponse); this.updateAssistantStatistics(modelResponse); modelResponse.text = this.extractJsonString(modelResponse.text); } return modelResponse; } } /** * Extracts the JSON string from the specified input. * Returns the JSON string or null if no JSON string was found. * * @param input - The input to extract the JSON string from. * @returns - The JSON string or null if no JSON string was found. */ extractJsonString(input) { const jsonStart = input.indexOf("{"); const jsonEnd = input.lastIndexOf("}"); if (jsonStart !== -1 && jsonEnd !== -1) { return input.substring(jsonStart, jsonEnd + 1); } return null; } /** * Validates the request object and sets default values if necessary. * Logs a warning if the request is invalid. * Returns the validated request object or false if the request is invalid. * * @param requestObj - The request object. * @param requestObj.model - The model name. * @param requestObj.messages - The messages to send to the model. * @param requestObj.feedback_device - The feedback device for the model. * @param requestObj.max_tokens - The maximum number of tokens to generate. * @param requestObj.temperature - The temperature for the model. * @param requestObj.system_prompt - The system prompt for the model. * @returns - The validated request object or false if the request is invalid. */ validateRequest(requestObj) { if (!requestObj.model || requestObj.model == "") { this.log.warn(`No model provided in request, validation failed`); return false; } if (!requestObj.messages || requestObj.messages.length == 0) { this.log.warn(`No messages provided in request, validation failed`); return false; } if (!requestObj.feedback_device || requestObj.feedback_device == "") { this.log.debug(`No path for feedback objects provided in request, using Model default`); requestObj.feedback_device = `Models.${this.stringToAlphaNumeric(requestObj.model)}`; } if (!requestObj.max_tokens || requestObj.max_tokens == "") { this.log.debug(`No max_tokens provided in request, using default value: 2000`); requestObj.max_tokens = 2000; } if (!requestObj.temperature || requestObj.temperature == "") { this.log.debug(`No temperature provided in request, using default value: 0.6`); requestObj.temperature = 0.6; } if (!requestObj.system_prompt || requestObj.system_prompt.trim() == "") { this.log.debug(`No system prompt provided in request`); requestObj.system_prompt = null; } return requestObj; } /** * Retrieves the message history for the specified bot. * Validates the message history and returns an array of messages. * * @returns - An array of validated messages. */ async getValidatedMessageHistory() { this.log.debug("Getting previous message pairs for request"); const validatedObject = { messages: [] }; const messageObject = await this.getStateAsync("Assistant.statistics.messages"); if (messageObject && messageObject.val != null && messageObject.val != "") { this.log.debug("Trying to decode history json data"); const messagesData = JSON.parse(jsonrepair(messageObject.val)); if (messagesData && messagesData.messages && messagesData.messages.length > 0) { for (const message of messagesData.messages) { validatedObject.messages.push(message); } } return validatedObject; } this.log.warn("Message history object for Assistant not found"); return validatedObject; } /** * Adds a message pair to the message history for the specified bot. * * @param user - The user message. * @param assistant - The assistant response. * @param tokens_input - The number of input tokens used in the request. * @param tokens_output - The number of output tokens used in the response. * @param model - The model name. * @returns - Returns true if the message pair was added successfully, otherwise false. */ async addMessagePairToHistory(user, assistant, tokens_input, tokens_output, model) { if (this.config.chat_history > 0) { const messagesData = await this.getValidatedMessageHistory(); this.log.debug("Adding message pair to history"); messagesData.messages.push({ user: user, assistant: assistant, timestamp: Date.now(), model: model, tokens_input: tokens_input, tokens_output: tokens_output, }); while (messagesData.messages.length > this.config.chat_history) { this.log.debug("Removing message entry because chat history too big"); messagesData.messages.shift(); } await this.setStateAsync("Assistant.statistics.messages", { val: JSON.stringify(messagesData), ack: true }); return true; } this.log.debug("Chat history disabled for Assistant "); return false; } /** * Handles the response data from the model request * and updates the statistics for the model. * * @param responseString - The response data from the model request. * @returns - Returns true if the statistics were updated successfully, otherwise false. */ async handleAssistantResponse(responseString) { this.log.debug(`Handling response string: ${responseString}`); try { const responseData = JSON.parse(jsonrepair(responseString)); if (!responseData.userResponse) { this.log.warn("No user reply provided in assistant response data"); return; } if (this.config.assistant_debug_output) { const debugOutput = ` Reasoning: ${responseData.reasoning} FunctionCall: ${responseData.functionCall} FunctionInstruction: ${responseData.functionTextInstructionString} `; await this.setStateAsync("Assistant.text_response", { val: debugOutput, ack: true }); } if (responseData.functionCall) { this.log.info(`Function call detected: ${responseData.functionCall}`); if (!responseData.functionTextInstructionString) { this.log.warn( `No function instruction provided in assistant response data. Called function: ${ responseData.functionCall }`, ); } await this.setStateAsync("Assistant.text_response", { val: responseData.userResponse, ack: true }); this.log.debug(`Function instruction: ${responseData.functionTextInstructionString}`); const functionResponse = await this.tryToExecuteFunctionCall( responseData.functionCall, responseData.functionTextInstructionString, ); if (functionResponse) { if (this.config.assistant_debug_output) { const debugOutput = ` Received Response from FunctionCall FunctionResponseFrom: ${functionResponse.tool} FunctionReasoning: ${functionResponse.reasoning} NoticeToAssistant: ${functionResponse.noticeToAssistant} FunctionResultData: ${JSON.stringify(functionResponse.result)} `; await this.setStateAsync("Assistant.text_response", { val: debugOutput, ack: true }); } delete functionResponse.reasoning; this.startAssistantRequest(JSON.stringify(functionResponse), 0, false, true); } } else { await this.setStateAsync("Assistant.text_response", { val: responseData.userResponse, ack: true }); } } catch (error) { this.log.debug(responseString); this.log.error(`Error parsing response data: ${error.message}`); } } /** * Tries to execute function call for the specified function name. * Returns the result of the function call. * * @param functionCall - The function name to call. * @param functionInstruction - The instruction for the function call. * @returns - Returns the result of the function call. */ async tryToExecuteFunctionCall(functionCall, functionInstruction) { if (functionCall === "states") { this.log.info(`States function call detected: ${functionInstruction}`); const toolFunction = new StatesTool(this); const toolResult = await toolFunction.request(functionInstruction); return toolResult; } if (functionCall === "scheduler") { this.log.info(`Scheduler function call detected: ${functionInstruction}`); const toolFunction = this.scheduler; const toolResult = await toolFunction.request(functionInstruction); return toolResult; } if (functionCall === "trigger") { this.log.info(`Trigger function call detected: ${functionInstruction}`); const toolFunction = this.trigger; const toolResult = await toolFunction.request(functionInstruction); return toolResult; } if (functionCall === "deleteHistory") { this.log.info(`Delete history call detected: ${functionInstruction}`); await this.setStateAsync("Assistant.text_response", { val: I18n.translate("assistant_function_delete_history_success"), ack: true, }); this.setTimeout(async () => { await this.clearHistory(); }, 3000); return null; } for (const customFunction of this.config.available_functions) { if (functionCall === customFunction.name) { this.log.info(`Custom function call detected: ${customFunction.name}`); const result = await this.tryToExecuteCustomFunctionCall(customFunction, functionInstruction); const toolResult = { type: "toolResponse", tool: customFunction.name, noticeToAssistant: I18n.translate("assistant_function_executed"), result: result, }; return toolResult; } } const toolResult = { tool: functionCall, prompt: functionInstruction, noticeToAssistant: I18n.translate("assistant_function_not_implemented"), result: null, }; return toolResult; } /** * Tries to execute custom function call for the specified custom function. * Returns the result of the custom function call. * * @param customFunction - The custom function to call. * @param functionInstruction - The instruction for the custom function call. * @returns - Returns the result of the custom function call. */ async tryToExecuteCustomFunctionCall(customFunction, functionInstruction) { this.log.info(`Trying to execute custom function: ${customFunction.name}`); const oldResultState = await this.getForeignStateAsync(customFunction.objId_result); const oldResultTimestamp = oldResultState.ts; let tries = 0; await this.setForeignStateAsync(customFunction.objId_request, { val: functionInstruction, ack: false }); this.log.debug(`Waiting for result from function: ${customFunction.name}`); return new Promise(resolve => { const checkInterval = setInterval( async () => { this.log.debug(`Checking for result from function: ${customFunction.name}`); const newResultState = await this.getForeignStateAsync(customFunction.objId_result); const newResultTimestamp = newResultState.ts; tries += 1; if (newResultTimestamp > oldResultTimestamp) { this.log.info( `Result received from function: ${customFunction.name} Result: ${newResultState.val}`, ); clearInterval(checkInterval); resolve(newResultState.val); } if (tries >= 60) { this.log.warn( `No result received from function: ${customFunction.name} on datapoint ${ customFunction.objId_result } after 60 seconds`, ); clearInterval(checkInterval); resolve(I18n.translate("assistant_function_timeout")); } }, 1000, this, ); }); } /** * Updates the statistics for the specified bot with the response data. * * @param response - The response from the assistant. * @param response.tokens_input - The number of input tokens used in the request. * @param response.tokens_output - The number of output tokens used in the response. */ async updateAssistantStatistics(response) { this.log.debug("Updating statistics for Assistant with response"); let input_tokens = await this.getStateAsync("Assistant.statistics.tokens_input"); let output_tokens = await this.getStateAsync("Assistant.statistics.tokens_output"); let requests_count = await this.getStateAsync("Assistant.statistics.requests_count"); if (!input_tokens || input_tokens.val == null || input_tokens.val == "") { input_tokens = 0 + response.tokens_input; } else { input_tokens = input_tokens.val + response.tokens_input; } if (!output_tokens || output_tokens.val == null || output_tokens.val == "") { output_tokens = 0 + response.tokens_output; } else { output_tokens = output_tokens.val + response.tokens_output; } if (!requests_count || requests_count.val == null || requests_count.val == "") { requests_count = 0 + 1; } else { requests_count = parseInt(requests_count.val) + 1; } this.setStateAsync("Assistant.statistics.tokens_input", { val: input_tokens, ack: true }); this.setStateAsync("Assistant.statistics.tokens_output", { val: output_tokens, ack: true }); this.setStateAsync("Assistant.statistics.requests_count", { val: requests_count, ack: true }); this.setStateAsync("Assistant.statistics.last_request", { val: new Date().toISOString(), ack: true }); } /** * Converts a string to alphanumeric characters only. * * @param str - The string to convert. * @returns - The converted string. */ stringToAlphaNumeric(str) { return str.replace(/[^a-zA-Z0-9-_]/g, ""); } /** * Updates the statistics for the specified model with the response data. * * @param model - The model name. * @param response - The response from the model. * @param response.tokens_input - The number of input tokens used in the request. * @param response.tokens_output - The number of output tokens used in the response. */ async updateModelStatistics(model, response) { this.log.debug(`Updating model statistics for model ${model} with response`); model = this.stringToAlphaNumeric(model); let input_tokens = await this.getStateAsync(`Models.${model}.statistics.tokens_input`); let output_tokens = await this.getStateAsync(`Models.${model}.statistics.tokens_output`); let requests_count = await this.getStateAsync(`Models.${model}.statistics.requests_count`); if (!input_tokens || input_tokens.val == null || input_tokens.val == "") { input_tokens = 0 + response.tokens_input; } else { input_tokens = input_tokens.val + response.tokens_input; } if (!output_tokens || output_tokens.val == null || output_tokens.val == "") { output_tokens = 0 + response.tokens_output; } else { output_tokens = output_tokens.val + response.tokens_output; } if (!req