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 0000000..ce62a5a
Binary files /dev/null and b/openwebui_integration/static/description/icon.png differ
diff --git a/openwebui_integration/static/src/css/openwebui.css b/openwebui_integration/static/src/css/openwebui.css
new file mode 100644
index 0000000..fdddc22
--- /dev/null
+++ b/openwebui_integration/static/src/css/openwebui.css
@@ -0,0 +1,12 @@
+.o_openwebui_container {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.o_openwebui_content {
+ flex: 1;
+ padding: 16px;
+ overflow: auto;
+}
diff --git a/openwebui_integration/static/src/js/openwebui.js b/openwebui_integration/static/src/js/openwebui.js
new file mode 100644
index 0000000..c8c9852
--- /dev/null
+++ b/openwebui_integration/static/src/js/openwebui.js
@@ -0,0 +1,30 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { session } from "@web/session";
+import { Component } from "@odoo/owl";
+
+export class OpenWebUIAction extends Component {
+ static template = "openwebui_integration.ClientAction";
+
+ setup() {
+ super.setup();
+ this.state = {
+ openwebui_enabled: false,
+ openwebui_model: '',
+ openwebui_api_url: ''
+ };
+ this.loadCompanyConfig();
+ }
+
+ async loadCompanyConfig() {
+ const company = await this.env.services.orm.read(
+ 'res.company',
+ [session.company_id],
+ ['openwebui_enabled', 'openwebui_api_url', 'openwebui_model']
+ );
+ this.state = company[0];
+ }
+}
+
+registry.category("actions").add("openwebui_action", OpenWebUIAction);
diff --git a/openwebui_integration/static/src/scss/openwebui.scss b/openwebui_integration/static/src/scss/openwebui.scss
new file mode 100644
index 0000000..377aa89
--- /dev/null
+++ b/openwebui_integration/static/src/scss/openwebui.scss
@@ -0,0 +1,26 @@
+.o_mail_message_typing {
+ opacity: 0.7;
+
+ .typing-indicator {
+ display: inline-block;
+ margin-left: 8px;
+
+ span {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ margin: 0 2px;
+ background-color: currentColor;
+ border-radius: 50%;
+ animation: typing 1s infinite;
+
+ &:nth-child(2) { animation-delay: 0.2s; }
+ &:nth-child(3) { animation-delay: 0.4s; }
+ }
+ }
+}
+
+@keyframes typing {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-4px); }
+}
diff --git a/openwebui_integration/static/src/xml/templates.xml b/openwebui_integration/static/src/xml/templates.xml
new file mode 100644
index 0000000..330e595
--- /dev/null
+++ b/openwebui_integration/static/src/xml/templates.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+