From 77c5b8ee820d4c69723a718de2a8216579966618 Mon Sep 17 00:00:00 2001 From: xtremxpert Date: Thu, 16 Jan 2025 18:42:16 -0500 Subject: [PATCH] openwebui_integration --- openwebui_integration/__init__.py | 2 + openwebui_integration/__manifest__.py | 33 ++ openwebui_integration/controllers/__init__.py | 1 + openwebui_integration/controllers/main.py | 22 ++ openwebui_integration/models/__init__.py | 9 + .../models/discuss_channel.py | 46 +++ openwebui_integration/models/openwebui_bot.py | 318 ++++++++++++++++++ .../models/openwebui_bot_mixin.py | 71 ++++ .../models/openwebui_model.py | 243 +++++++++++++ openwebui_integration/models/res_company.py | 101 ++++++ openwebui_integration/models/res_users.py | 17 + .../security/ir.model.access.csv | 5 + .../security/openwebui_security.xml | 7 + openwebui_integration/security/security.xml | 41 +++ .../static/description/icon.png | Bin 0 -> 10741 bytes .../static/src/css/openwebui.css | 12 + .../static/src/js/openwebui.js | 30 ++ .../static/src/scss/openwebui.scss | 26 ++ .../static/src/xml/templates.xml | 23 ++ .../views/openwebui_bot_views.xml | 61 ++++ .../views/openwebui_model_views.xml | 27 ++ .../views/res_company_views.xml | 55 +++ 22 files changed, 1150 insertions(+) create mode 100644 openwebui_integration/__init__.py create mode 100644 openwebui_integration/__manifest__.py create mode 100644 openwebui_integration/controllers/__init__.py create mode 100644 openwebui_integration/controllers/main.py create mode 100644 openwebui_integration/models/__init__.py create mode 100644 openwebui_integration/models/discuss_channel.py create mode 100644 openwebui_integration/models/openwebui_bot.py create mode 100644 openwebui_integration/models/openwebui_bot_mixin.py create mode 100644 openwebui_integration/models/openwebui_model.py create mode 100644 openwebui_integration/models/res_company.py create mode 100644 openwebui_integration/models/res_users.py create mode 100644 openwebui_integration/security/ir.model.access.csv create mode 100644 openwebui_integration/security/openwebui_security.xml create mode 100644 openwebui_integration/security/security.xml create mode 100644 openwebui_integration/static/description/icon.png create mode 100644 openwebui_integration/static/src/css/openwebui.css create mode 100644 openwebui_integration/static/src/js/openwebui.js create mode 100644 openwebui_integration/static/src/scss/openwebui.scss create mode 100644 openwebui_integration/static/src/xml/templates.xml create mode 100644 openwebui_integration/views/openwebui_bot_views.xml create mode 100644 openwebui_integration/views/openwebui_model_views.xml create mode 100644 openwebui_integration/views/res_company_views.xml diff --git a/openwebui_integration/__init__.py b/openwebui_integration/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/openwebui_integration/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/openwebui_integration/__manifest__.py b/openwebui_integration/__manifest__.py new file mode 100644 index 0000000..a25db3f --- /dev/null +++ b/openwebui_integration/__manifest__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'OpenWebUI Integration', + 'version': '1.0', + 'summary': 'Integration with OpenWebUI for chatbots', + 'sequence': 10, + 'description': """ +OpenWebUI Integration +===================== +This module provides integration with OpenWebUI to create and manage chatbots. + +Features: +--------- +* Connect to OpenWebUI API +* Manage models and configurations +* Create chatbots with specific behaviors +* Control access to bots by channels and users +""", + 'category': 'Discuss', + 'website': 'https://www.odoo.com/app/discuss', + 'depends': ['mail'], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'views/res_company_views.xml', + 'views/openwebui_bot_views.xml', + ], + 'installable': True, + 'application': False, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/openwebui_integration/controllers/__init__.py b/openwebui_integration/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/openwebui_integration/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/openwebui_integration/controllers/main.py b/openwebui_integration/controllers/main.py new file mode 100644 index 0000000..89753a1 --- /dev/null +++ b/openwebui_integration/controllers/main.py @@ -0,0 +1,22 @@ +from odoo import http, fields +from odoo.http import request + +class OpenWebUIController(http.Controller): + @http.route('/openwebui/config', type='json', auth='user') + def get_config(self): + company = request.env.company + user_companies = request.env.user.company_ids.ids + + return { + 'version': '1.0', + 'enabled': company.openwebui_enabled, + 'company_id': company.id, + 'allowed_company_ids': user_companies, + 'company_name': company.name, + 'model': company.openwebui_model, + 'api_url': company.openwebui_api_url, + 'max_tokens': company.openwebui_max_tokens, + 'temperature': company.openwebui_temperature, + 'timeout': company.openwebui_timeout, + 'use_ssl': company.openwebui_use_ssl, + } diff --git a/openwebui_integration/models/__init__.py b/openwebui_integration/models/__init__.py new file mode 100644 index 0000000..8c7db0c --- /dev/null +++ b/openwebui_integration/models/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import openwebui_bot_mixin +from . import openwebui_model +from . import openwebui_bot +from . import discuss_channel +from . import res_company +from . import res_users diff --git a/openwebui_integration/models/discuss_channel.py b/openwebui_integration/models/discuss_channel.py new file mode 100644 index 0000000..3dbf791 --- /dev/null +++ b/openwebui_integration/models/discuss_channel.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ + +class DiscussChannel(models.Model): + _inherit = 'discuss.channel' + + bot_id = fields.Many2one( + comodel_name='openwebui.bot', + string='AI Bot', + help="AI Bot associated with this channel" + ) + + def _get_bot_domain(self, company_id): + """Get domain for available bots.""" + return [ + ('company_id', '=', company_id), + ('is_active', '=', True), + ] + + @api.model + def channel_get(self, partners_to): + """Override channel_get to handle bot channels.""" + channel = super().channel_get(partners_to) + + # Check if any of the partners is a bot + if len(partners_to) == 2: # One-to-one chat + bot_partner_ids = self.env['openwebui.bot'].search([('partner_id', 'in', partners_to)]).mapped('partner_id').ids + if bot_partner_ids: + bot = self.env['openwebui.bot'].search([('partner_id', 'in', bot_partner_ids)], limit=1) + if bot: + channel.write({'bot_id': bot.id}) + + return channel + + @api.returns('mail.message', lambda value: value.id) + def message_post(self, **kwargs): + """Override message_post to handle bot messages.""" + message = super().message_post(**kwargs) + + # If this is a bot channel, let the bot handle the message + if self.bot_id and message.author_id != self.bot_id.partner_id: + self.bot_id._handle_message(message) + + return message diff --git a/openwebui_integration/models/openwebui_bot.py b/openwebui_integration/models/openwebui_bot.py new file mode 100644 index 0000000..9b65571 --- /dev/null +++ b/openwebui_integration/models/openwebui_bot.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError +import logging + +_logger = logging.getLogger(__name__) + +class OpenWebUIBot(models.Model): + """OpenWebUI Bot that can be configured and used in channels. + + This class allows to: + - Configure a bot with a name, model and parameters + - Manage permissions (channels and users) + - Send messages to the model with the configured parameters + """ + _name = 'openwebui.bot' + _description = 'OpenWebUI Bot' + _inherit = ['mail.thread', 'mail.activity.mixin', 'openwebui.bot.mixin'] + + name = fields.Char( + string='Name', + required=True, + tracking=True + ) + + description = fields.Text( + string='Description', + tracking=True + ) + + model_id = fields.Many2one( + comodel_name='openwebui.model', + string='Model', + required=True, + tracking=True, + domain="[('company_id', '=', company_id), ('is_active', '=', True), ('is_temp', '=', False)]" + ) + + company_id = fields.Many2one( + comodel_name='res.company', + string='Company', + required=True, + default=lambda self: self.env.company + ) + + partner_id = fields.Many2one( + comodel_name='res.partner', + string='Partner', + readonly=True, + required=True + ) + + is_active = fields.Boolean( + string='Active', + default=True, + tracking=True + ) + + max_tokens = fields.Integer( + string='Max Tokens', + default=2048, + tracking=True, + help="Maximum number of tokens to generate in the response" + ) + + temperature = fields.Float( + string='Temperature', + default=0.7, + tracking=True, + help="Controls the creativity of the responses. Higher values give more creative but potentially less accurate responses." + ) + + channel_ids = fields.One2many( + comodel_name='discuss.channel', + inverse_name='bot_id', + string='Channels' + ) + + user_ids = fields.Many2many( + comodel_name='res.users', + string='Authorized Users', + help="Users authorized to interact with the bot" + ) + + instructions = fields.Text( + string='Instructions', + tracking=True, + help="Specific instructions to guide the bot's behavior" + ) + + context = fields.Text( + string='Context', + tracking=True, + help="Additional context for the bot" + ) + + _sql_constraints = [ + ('name_company_uniq', 'unique(name, company_id)', 'Bot name must be unique per company!') + ] + + @api.constrains('max_tokens') + def _check_max_tokens(self): + """Checks that the number of tokens is valid""" + for bot in self: + if bot.max_tokens < 1: + raise ValidationError("The maximum number of tokens must be greater than 0") + + @api.constrains('temperature') + def _check_temperature(self): + """Checks that the temperature is valid""" + for bot in self: + if not (0 <= bot.temperature <= 2): + raise ValidationError("The temperature must be between 0 and 2") + + def _create_bot_partner(self, name=None, company_id=None): + """Create a partner for the bot.""" + return self.env['res.partner'].sudo().create({ + 'name': name or 'New Bot', + 'email': f"{(name or 'new.bot').lower().replace(' ', '.')}@bot.internal", + 'type': 'contact', + 'company_id': company_id or self.env.company.id, + 'is_company': False, + }) + + @api.model_create_multi + def create(self, vals_list): + """Override create to ensure bot partner is created and discussions initialized with internal users.""" + for vals in vals_list: + if not vals.get('partner_id'): + partner = self._create_bot_partner( + name=vals.get('name'), + company_id=vals.get('company_id', self.env.company.id) + ) + vals['partner_id'] = partner.id + + # Create bots + bots = super().create(vals_list) + + # Initialize discussions for each bot with internal users + for bot in bots: + bot._init_bot_for_users() + + return bots + + def write(self, vals): + """Override write to handle activation/deactivation.""" + res = super().write(vals) + if 'is_active' in vals and vals['is_active']: + for bot in self: + _logger.info("Bot %s (ID: %s) activated, initializing discussions with internal users", bot.name, bot.id) + bot._init_bot_for_users() + return res + + def _init_bot_for_users(self): + """Initialize discussions with all internal users (non-portal, non-public users).""" + self.ensure_one() + if not self.is_active: + return + + # Get all internal users (employees, not portal or public users) + internal_users = self.env['res.users'].sudo().search([ + ('share', '=', False), # Excludes portal and public users + ('active', '=', True), # Only active users + ('partner_id', '!=', False) # Must have a partner + ]) + + _logger.info( + "Initializing discussions for bot %s (ID: %s) with %s internal users", + self.name, self.id, len(internal_users) + ) + + for user in internal_users: + try: + # Create discussion only for internal users + self.init_bot_discussion(user.id) + _logger.debug( + "Created discussion between bot %s and internal user %s (ID: %s)", + self.name, user.name, user.id + ) + except Exception as e: + _logger.error( + "Failed to create discussion between bot %s and user %s (ID: %s): %s", + self.name, user.name, user.id, str(e) + ) + + def init_bot_discussion(self, user_id): + """Initialize one-to-one discussion between the bot and a user.""" + self.ensure_one() + user = self.env['res.users'].browse(user_id) + + # Create or get channel + channel = self.env['discuss.channel'].channel_get([self.partner_id.id, user.partner_id.id]) + + # Send welcome message + welcome_msg = _( + "Hello! I'm %(bot_name)s, your AI assistant. " + "I'm here to help you with any questions or tasks you might have. " + "Feel free to start our conversation!" + ) % {'bot_name': self.name} + + channel.sudo().message_post( + body=welcome_msg, + author_id=self.partner_id.id, + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + + return channel + + def _handle_message(self, message): + """Handle incoming messages in bot channels.""" + self.ensure_one() + + # Get the channel from message's model and res_id + channel = self.env[message.model].browse(message.res_id) if message.model == 'discuss.channel' else None + + _logger.info( + "Received message for bot %s (ID: %s). Message ID: %s, Author: %s, Channel: %s", + self.name, self.id, message.id, + message.author_id.name if message.author_id else 'No Author', + channel.name if channel else 'No Channel' + ) + + # Skip if message is from the bot itself + if message.author_id == self.partner_id: + _logger.info("Skipping message as it's from the bot itself") + return + + # Verify channel + if not channel or message.model != 'discuss.channel': + _logger.warning("Message %s is not from a discuss channel", message.id) + return + + if self.partner_id not in channel.channel_member_ids.partner_id: + _logger.warning( + "Bot %s (ID: %s) is not a member of channel %s", + self.name, self.id, channel.name + ) + return + + try: + _logger.info( + "Processing message with OpenWebUI. Model: %s, Context: %s, Instructions: %s", + self.model_id.identifier, bool(self.context), bool(self.instructions) + ) + + # Process message with OpenWebUI + response = self.model_id.send_message( + message=message.body, + context=self.context, + instructions=self.instructions + ) + + _logger.info("Got response from OpenWebUI: %s", bool(response)) + + if response: + # Post the response + _logger.info("Posting response to channel %s", channel.name) + channel.sudo().message_post( + body=response, + author_id=self.partner_id.id, + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + else: + _logger.warning("Empty response from OpenWebUI") + error_msg = _("I apologize, but I couldn't generate a response at this time.") + channel.sudo().message_post( + body=error_msg, + author_id=self.partner_id.id, + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + + except Exception as e: + _logger.error( + "Error processing message with OpenWebUI: %s\nBot: %s (ID: %s)\nModel: %s", + str(e), self.name, self.id, self.model_id.identifier, + exc_info=True + ) + error_msg = _("I apologize, but I encountered an error processing your message. Please try again later.") + channel.sudo().message_post( + body=error_msg, + author_id=self.partner_id.id, + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + + def send_message(self, message, context=None): + """Sends a message to the bot and returns its response. + + Args: + message: The message to send + context: Additional context for the conversation + + Returns: + tuple: (success, result) + """ + self.ensure_one() + + if not self.is_active: + return False, "This bot is not active" + + if not self.model_id: + return False, "No model is configured for this bot" + + # Prepare the conversation context + conversation_context = { + 'max_tokens': self.max_tokens, + 'temperature': self.temperature, + } + if context: + conversation_context.update(context) + + # Send the message to the model + return self.model_id.send_message(message, conversation_context) diff --git a/openwebui_integration/models/openwebui_bot_mixin.py b/openwebui_integration/models/openwebui_bot_mixin.py new file mode 100644 index 0000000..8721724 --- /dev/null +++ b/openwebui_integration/models/openwebui_bot_mixin.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ + +class OpenWebUIBotMixin(models.AbstractModel): + """Mixin to add OpenWebUI bot functionality to any model. + + This mixin provides methods to: + - Get bot parameters and context + - Send messages to models + - Handle responses + """ + _name = 'openwebui.bot.mixin' + _description = 'OpenWebUI Bot Mixin' + + def _get_bot_context(self, bot): + """Gets the bot context with its parameters""" + return { + 'instructions': bot.instructions, + 'model': bot.model_id, + 'context': bot.context or '', + 'max_tokens': bot.max_tokens, + 'temperature': bot.temperature, + } + + def _apply_logic(self, record, values, command=None): + """Applies the bot logic to a record. + + Args: + record: The record to apply the logic to + values: The values to use for message generation + command: Optional command for the bot + + Returns: + dict: The updated values + """ + bot = values.get('bot') + if not bot or not bot.model_id: + return values + + # Prepare the bot context + bot_context = self._get_bot_context(bot) + + # Generate the message for the bot + message = self._generate_bot_message(record, values, command) + + # Send the message to the model + response = bot_context['model'].send_message( + message=message, + context=bot_context['context'], + max_tokens=bot_context['max_tokens'], + temperature=bot_context['temperature'], + instructions=bot_context['instructions'] + ) + + if response: + # Update the values with the bot response + values = self._process_bot_response(values, response) + + return values + + def _generate_bot_message(self, record, values, command=None): + """Generates the message to send to the bot. + To be overridden in child classes.""" + return '' + + def _process_bot_response(self, values, response): + """Handles the bot response. + To be overridden in child classes.""" + return values diff --git a/openwebui_integration/models/openwebui_model.py b/openwebui_integration/models/openwebui_model.py new file mode 100644 index 0000000..d18539d --- /dev/null +++ b/openwebui_integration/models/openwebui_model.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import requests +import logging +import json +from requests.exceptions import RequestException +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +DEFAULT_MODELS = [ + ('gpt-3.5-turbo', 'GPT-3.5 Turbo'), + ('gpt-4', 'GPT-4'), + ('claude-2', 'Claude 2'), +] + +class OpenWebUIModel(models.Model): + _name = 'openwebui.model' + _description = 'OpenWebUI Model' + + name = fields.Char( + string='Name', + required=True, + readonly=True + ) + + identifier = fields.Char( + string='Identifier', + required=True, + readonly=True + ) + + description = fields.Text( + string='Description', + readonly=True + ) + + is_active = fields.Boolean( + string='Active', + default=True, + help="If disabled, this model will not be available for bots" + ) + + company_id = fields.Many2one( + comodel_name='res.company', + string='Company', + required=True, + readonly=True, + default=lambda self: self.env.company + ) + + is_temp = fields.Boolean( + string='Temporary', + default=False, + readonly=True, + help="Indicates if this model is temporary (used for testing)" + ) + + _sql_constraints = [ + ('unique_identifier', 'unique(identifier, company_id)', 'The identifier must be unique per company!') + ] + + @api.model_create_multi + def create(self, vals_list): + """Prevents manual creation of models except for temporary models""" + for vals in vals_list: + # Mark model as temporary if it starts with test_ or refresh_ + if vals.get('identifier', '').startswith(('test_', 'refresh_')): + vals['is_temp'] = True + + # Allow creation if it's a temporary model or in installation mode + if not (vals.get('is_temp') or self.env.context.get('install_mode')): + raise UserError(_("OpenWebUI models cannot be created manually. Use the 'Refresh List' button to synchronize models from OpenWebUI.")) + + return super().create(vals_list) + + def write(self, vals): + """Prevents manual modification of models""" + if self.env.context.get('install_mode') or self.env.context.get('sync_models'): + return super().write(vals) + if 'is_active' in vals: + return super().write(vals) + raise UserError(_("OpenWebUI models cannot be modified manually. Use the 'Refresh List' button to synchronize models from OpenWebUI.")) + + def unlink(self): + """Prevents manual deletion of models""" + if self.env.context.get('install_mode') or self.env.context.get('sync_models') or all(model.is_temp for model in self): + return super().unlink() + raise UserError(_("OpenWebUI models cannot be deleted manually. They are managed automatically during synchronization with OpenWebUI.")) + + def cleanup_temp_models(self): + """Cleans up temporary models""" + temp_models = self.search([ + ('is_temp', '=', True), + '|', + ('create_date', '<', fields.Datetime.now()), + '|', + ('identifier', '=like', 'test_%'), + ('identifier', '=like', 'refresh_%') + ]) + if temp_models: + temp_models.with_context(sync_models=True).unlink() + + def _get_company_config(self): + """Gets the configuration of the current company""" + company = self.env.company + return { + 'enabled': company.openwebui_enabled, + 'api_url': company.openwebui_api_url, + 'api_key': company.openwebui_api_key, + 'verify_ssl': company.openwebui_verify_ssl, + 'timeout': company.openwebui_timeout, + } + + def _make_request(self, endpoint, method='GET', data=None, files=None, timeout=None): + """Makes a request to the OpenWebUI API""" + config = self._get_company_config() + + if not config['enabled']: + return False, "OpenWebUI is not enabled" + + try: + url = f"{config['api_url'].rstrip('/')}/api/{endpoint.lstrip('/')}" + headers = { + 'Accept': 'application/json' + } + if config['api_key']: + headers['Authorization'] = f'Bearer {config["api_key"]}' + + if method in ['POST', 'PUT', 'PATCH'] and not files: + headers['Content-Type'] = 'application/json' + + timeout = timeout or config['timeout'] + + kwargs = { + 'headers': headers, + 'verify': config['verify_ssl'], + 'timeout': timeout + } + + if files: + kwargs['files'] = files + elif method in ['POST', 'PUT', 'PATCH']: + kwargs['json'] = data + + response = requests.request(method=method, url=url, **kwargs) + response.raise_for_status() + + return True, response.json() + + except requests.exceptions.SSLError: + return False, "SSL/TLS verification failed" + except requests.exceptions.ConnectionError: + return False, "Could not connect to server" + except requests.exceptions.Timeout: + return False, "Connection timed out" + except requests.exceptions.RequestException as e: + return False, f"Connection error: {str(e)}" + except (json.JSONDecodeError, ValueError) as e: + return False, f"Invalid response from server: {str(e)}" + except Exception as e: + return False, f"Unexpected error: {str(e)}" + + def test_connection(self): + """Tests the connection to OpenWebUI""" + return self._make_request('models', timeout=5) + + @api.model + def get_available_models(self): + """Gets the list of available models from the API""" + success, result = self._make_request('models') + + if not success: + _logger.error(f"Error fetching models: {result}") + return DEFAULT_MODELS + + try: + if isinstance(result, list): + return [(model['id'], model.get('name', model['id'])) for model in result] + elif isinstance(result, dict) and 'data' in result: + return [(model['id'], model.get('name', model['id'])) for model in result['data']] + else: + _logger.warning("Unexpected response format from OpenWebUI API") + return DEFAULT_MODELS + except Exception as e: + _logger.error(f"Error processing models response: {str(e)}") + return DEFAULT_MODELS + + def sync_models(self): + """Synchronizes models from OpenWebUI""" + self.ensure_one() + success, result = self._sync_models() + return success, result + + @api.model + def _sync_models(self): + """Synchronizes models from OpenWebUI""" + # First clean up temporary models + self.cleanup_temp_models() + + success, result = self._make_request('models') + if not success: + return False, result + + try: + models_data = result if isinstance(result, list) else result.get('data', []) + for model_data in models_data: + existing = self.search([('identifier', '=', model_data['id'])]) + if not existing: + self.with_context(sync_models=True).create({ + 'name': model_data.get('name', model_data['id']), + 'identifier': model_data['id'], + 'description': model_data.get('description', ''), + }) + return True, None + except Exception as e: + _logger.error(f"Error synchronizing models: {str(e)}") + return False, str(e) + + def send_message(self, message, context=None, instructions=None): + """Sends a message to the model and returns its response""" + self.ensure_one() + + data = { + 'model': self.identifier, + 'messages': [{'role': 'user', 'content': message}], + } + + if context: + data['context'] = context + if instructions: + data['system'] = instructions + + success, result = self._make_request('chat/completions', method='POST', data=data) + if not success: + return f"Error: {result}" + + try: + return result['choices'][0]['message']['content'] + except (KeyError, IndexError) as e: + return f"Error processing response: {str(e)}" diff --git a/openwebui_integration/models/res_company.py b/openwebui_integration/models/res_company.py new file mode 100644 index 0000000..cd7f5c9 --- /dev/null +++ b/openwebui_integration/models/res_company.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +class ResCompany(models.Model): + _inherit = 'res.company' + + openwebui_enabled = fields.Boolean( + string='Enable OpenWebUI', + default=False, + help="Enable integration with OpenWebUI" + ) + + openwebui_api_url = fields.Char( + string='OpenWebUI API URL', + help="Base URL of the OpenWebUI API (e.g. http://localhost:8080)" + ) + + openwebui_api_key = fields.Char( + string='OpenWebUI API Key', + help="API key for authentication with OpenWebUI" + ) + + openwebui_verify_ssl = fields.Boolean( + string='Verify SSL Certificate', + default=True, + help="Verify the SSL certificate when making API calls" + ) + + openwebui_timeout = fields.Integer( + string='Timeout (seconds)', + default=30, + help="Maximum wait time for API calls" + ) + + openwebui_models_ids = fields.One2many( + comodel_name='openwebui.model', + inverse_name='company_id', + string='OpenWebUI Models', + ) + + def test_openwebui_connection(self): + """Tests the connection to OpenWebUI""" + self.ensure_one() + + if not self.openwebui_enabled: + raise UserError(_("OpenWebUI is not enabled for this company.")) + + model = self.env['openwebui.model'].create({ + 'name': 'test_connection', + 'identifier': 'test_connection', + 'company_id': self.id, + 'is_temp': True, + }) + + success, message = model.test_connection() + if not success: + raise UserError(message) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Connection to OpenWebUI was successful.'), + 'type': 'success', + 'sticky': False, + } + } + + def refresh_model_list(self): + """Refreshes the list of models from OpenWebUI""" + self.ensure_one() + + if not self.openwebui_enabled: + raise UserError(_("OpenWebUI is not enabled for this company.")) + + # Create a temporary model to perform synchronization + model = self.env['openwebui.model'].create({ + 'name': 'refresh_models', + 'identifier': 'refresh_models', + 'company_id': self.id, + 'is_temp': True, + }) + + success, message = model.sync_models() + if not success: + raise UserError(message) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Model list has been successfully updated.'), + 'type': 'success', + 'sticky': False, + } + } diff --git a/openwebui_integration/models/res_users.py b/openwebui_integration/models/res_users.py new file mode 100644 index 0000000..a8bc39d --- /dev/null +++ b/openwebui_integration/models/res_users.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, Command, _ + +class ResUsers(models.Model): + _inherit = 'res.users' + + @api.model + def _init_messaging(self, store): + """Initialize messaging for the user, including OpenWebUI bot.""" + super()._init_messaging(store) + if not self.env.user._is_public(): + # Initialize chat with active bots + bots = self.env['openwebui.bot'].sudo().search([('is_active', '=', True)]) + for bot in bots: + bot.init_bot_discussion(self.env.user.id) diff --git a/openwebui_integration/security/ir.model.access.csv b/openwebui_integration/security/ir.model.access.csv new file mode 100644 index 0000000..a5108db --- /dev/null +++ b/openwebui_integration/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_openwebui_model_user,access_openwebui_model_user,model_openwebui_model,base.group_user,1,0,0,0 +access_openwebui_model_admin,access_openwebui_model_admin,model_openwebui_model,base.group_system,1,1,1,1 +access_openwebui_bot_user,access_openwebui_bot_user,model_openwebui_bot,base.group_user,1,0,0,0 +access_openwebui_bot_admin,access_openwebui_bot_admin,model_openwebui_bot,base.group_system,1,1,1,1 diff --git a/openwebui_integration/security/openwebui_security.xml b/openwebui_integration/security/openwebui_security.xml new file mode 100644 index 0000000..4f5b5c9 --- /dev/null +++ b/openwebui_integration/security/openwebui_security.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/openwebui_integration/security/security.xml b/openwebui_integration/security/security.xml new file mode 100644 index 0000000..7680c40 --- /dev/null +++ b/openwebui_integration/security/security.xml @@ -0,0 +1,41 @@ + + + + + + OpenWebUI + Gère les accès aux fonctionnalités OpenWebUI + 20 + + + + + Utilisateur + + + + + + + Administrateur + + + + + + + + Modèles OpenWebUI: règle multi-société + + [('company_id', 'in', company_ids)] + + + + + Bots OpenWebUI: règle multi-société + + [('company_id', 'in', company_ids)] + + + + diff --git a/openwebui_integration/static/description/icon.png b/openwebui_integration/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ce62a5a91d8e1d8c45db26b382b5f9001a46b12a GIT binary patch literal 10741 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 + + +
+
+ +
+
+
+ + +
+
+ Le bot est en train d'écrire... +
+ + + +
+
+
+
+
diff --git a/openwebui_integration/views/openwebui_bot_views.xml b/openwebui_integration/views/openwebui_bot_views.xml new file mode 100644 index 0000000..a37cfdd --- /dev/null +++ b/openwebui_integration/views/openwebui_bot_views.xml @@ -0,0 +1,61 @@ + + + + openwebui.bot.config.list + openwebui.bot + primary + + + + + + + + + + + + + + openwebui.bot.config.form + openwebui.bot + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + OpenWebUI Bots Configuration + openwebui.bot + list,form + + + +
diff --git a/openwebui_integration/views/openwebui_model_views.xml b/openwebui_integration/views/openwebui_model_views.xml new file mode 100644 index 0000000..54cbf1e --- /dev/null +++ b/openwebui_integration/views/openwebui_model_views.xml @@ -0,0 +1,27 @@ + + + + openwebui.model.form + openwebui.model + +
+ +
+
+ + + + + + + + + + +
+
+
+
+
diff --git a/openwebui_integration/views/res_company_views.xml b/openwebui_integration/views/res_company_views.xml new file mode 100644 index 0000000..cdbd6b2 --- /dev/null +++ b/openwebui_integration/views/res_company_views.xml @@ -0,0 +1,55 @@ + + + + res.company.form.inherit.openwebui + res.company + + + + + + + + + + + + + +