openwebui_integration
This commit is contained in:
parent
064983d18a
commit
77c5b8ee82
22 changed files with 1150 additions and 0 deletions
2
openwebui_integration/__init__.py
Normal file
2
openwebui_integration/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
33
openwebui_integration/__manifest__.py
Normal file
33
openwebui_integration/__manifest__.py
Normal file
|
|
@ -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',
|
||||
}
|
||||
1
openwebui_integration/controllers/__init__.py
Normal file
1
openwebui_integration/controllers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import main
|
||||
22
openwebui_integration/controllers/main.py
Normal file
22
openwebui_integration/controllers/main.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
9
openwebui_integration/models/__init__.py
Normal file
9
openwebui_integration/models/__init__.py
Normal file
|
|
@ -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
|
||||
46
openwebui_integration/models/discuss_channel.py
Normal file
46
openwebui_integration/models/discuss_channel.py
Normal file
|
|
@ -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
|
||||
318
openwebui_integration/models/openwebui_bot.py
Normal file
318
openwebui_integration/models/openwebui_bot.py
Normal file
|
|
@ -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)
|
||||
71
openwebui_integration/models/openwebui_bot_mixin.py
Normal file
71
openwebui_integration/models/openwebui_bot_mixin.py
Normal file
|
|
@ -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
|
||||
243
openwebui_integration/models/openwebui_model.py
Normal file
243
openwebui_integration/models/openwebui_model.py
Normal file
|
|
@ -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)}"
|
||||
101
openwebui_integration/models/res_company.py
Normal file
101
openwebui_integration/models/res_company.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
17
openwebui_integration/models/res_users.py
Normal file
17
openwebui_integration/models/res_users.py
Normal file
|
|
@ -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)
|
||||
5
openwebui_integration/security/ir.model.access.csv
Normal file
5
openwebui_integration/security/ir.model.access.csv
Normal file
|
|
@ -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
|
||||
|
7
openwebui_integration/security/openwebui_security.xml
Normal file
7
openwebui_integration/security/openwebui_security.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Règle de sécurité pour le multi-compagnie -->
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
41
openwebui_integration/security/security.xml
Normal file
41
openwebui_integration/security/security.xml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- Catégorie de sécurité pour OpenWebUI -->
|
||||
<record id="module_category_openwebui" model="ir.module.category">
|
||||
<field name="name">OpenWebUI</field>
|
||||
<field name="description">Gère les accès aux fonctionnalités OpenWebUI</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<!-- Groupe pour les utilisateurs OpenWebUI -->
|
||||
<record id="group_openwebui_user" model="res.groups">
|
||||
<field name="name">Utilisateur</field>
|
||||
<field name="category_id" ref="module_category_openwebui"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Groupe pour les administrateurs OpenWebUI -->
|
||||
<record id="group_openwebui_admin" model="res.groups">
|
||||
<field name="name">Administrateur</field>
|
||||
<field name="category_id" ref="module_category_openwebui"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_openwebui_user'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Règles de sécurité par société -->
|
||||
<record id="openwebui_model_company_rule" model="ir.rule">
|
||||
<field name="name">Modèles OpenWebUI: règle multi-société</field>
|
||||
<field name="model_id" ref="model_openwebui_model"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="openwebui_bot_company_rule" model="ir.rule">
|
||||
<field name="name">Bots OpenWebUI: règle multi-société</field>
|
||||
<field name="model_id" ref="model_openwebui_bot"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
BIN
openwebui_integration/static/description/icon.png
Normal file
BIN
openwebui_integration/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
12
openwebui_integration/static/src/css/openwebui.css
Normal file
12
openwebui_integration/static/src/css/openwebui.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
30
openwebui_integration/static/src/js/openwebui.js
Normal file
30
openwebui_integration/static/src/js/openwebui.js
Normal file
|
|
@ -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);
|
||||
26
openwebui_integration/static/src/scss/openwebui.scss
Normal file
26
openwebui_integration/static/src/scss/openwebui.scss
Normal file
|
|
@ -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); }
|
||||
}
|
||||
23
openwebui_integration/static/src/xml/templates.xml
Normal file
23
openwebui_integration/static/src/xml/templates.xml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="openwebui_integration.ChatMessage" owl="1">
|
||||
<div class="o_mail_message">
|
||||
<div class="o_mail_message_content">
|
||||
<t t-esc="props.message"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="openwebui_integration.BotTyping" owl="1">
|
||||
<div class="o_mail_message o_mail_message_typing">
|
||||
<div class="o_mail_message_content">
|
||||
<span>Le bot est en train d'écrire...</span>
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
61
openwebui_integration/views/openwebui_bot_views.xml
Normal file
61
openwebui_integration/views/openwebui_bot_views.xml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_openwebui_bot_config_list" model="ir.ui.view">
|
||||
<field name="name">openwebui.bot.config.list</field>
|
||||
<field name="model">openwebui.bot</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
<field name="is_active"/>
|
||||
<field name="max_tokens"/>
|
||||
<field name="temperature"/>
|
||||
<field name="company_id" optional="hide" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_openwebui_bot_config_form" model="ir.ui.view">
|
||||
<field name="name">openwebui.bot.config.form</field>
|
||||
<field name="model">openwebui.bot</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
<field name="is_active"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="max_tokens"/>
|
||||
<field name="temperature"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Instructions" name="instructions">
|
||||
<field name="instructions" placeholder="Specific instructions to guide the bot's behavior..."/>
|
||||
</page>
|
||||
<page string="Context" name="context">
|
||||
<field name="context" placeholder="Additional context for conversations..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_openwebui_bot_config" model="ir.actions.act_window">
|
||||
<field name="name">OpenWebUI Bots Configuration</field>
|
||||
<field name="res_model">openwebui.bot</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_openwebui_bot"
|
||||
name="Bots Configuration"
|
||||
parent="mail.menu_configuration"
|
||||
action="action_openwebui_bot_config"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
27
openwebui_integration/views/openwebui_model_views.xml
Normal file
27
openwebui_integration/views/openwebui_model_views.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_openwebui_model_form" model="ir.ui.view">
|
||||
<field name="name">openwebui.model.form</field>
|
||||
<field name="model">openwebui.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form create="false" edit="false" delete="false">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="identifier"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="is_active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
55
openwebui_integration/views/res_company_views.xml
Normal file
55
openwebui_integration/views/res_company_views.xml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_company_form_inherit_openwebui" model="ir.ui.view">
|
||||
<field name="name">res.company.form.inherit.openwebui</field>
|
||||
<field name="model">res.company</field>
|
||||
<field name="inherit_id" ref="base.view_company_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="OpenWebUI" name="openwebui">
|
||||
<group>
|
||||
<group>
|
||||
<field name="openwebui_enabled"/>
|
||||
<field name="openwebui_api_url"
|
||||
invisible="not openwebui_enabled"
|
||||
required="openwebui_enabled"
|
||||
placeholder="https://api.openwebui.com"/>
|
||||
<field name="openwebui_api_key"
|
||||
invisible="not openwebui_enabled"
|
||||
required="openwebui_enabled"
|
||||
password="True"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="openwebui_verify_ssl"
|
||||
invisible="not openwebui_enabled"/>
|
||||
<field name="openwebui_timeout"
|
||||
invisible="not openwebui_enabled"/>
|
||||
<button name="test_openwebui_connection"
|
||||
string="Test Connection"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
invisible="not openwebui_enabled"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Available Models" invisible="not openwebui_enabled">
|
||||
<field name="openwebui_models_ids" nolabel="1">
|
||||
<list create="false" delete="false" edit="true">
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="description"/>
|
||||
<field name="is_active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
<group>
|
||||
<button name="refresh_model_list"
|
||||
string="Refresh List"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-refresh"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue