545 lines
20 KiB
Python
545 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import models, fields, api, _, modules
|
|
from odoo.exceptions import ValidationError
|
|
import logging
|
|
from markupsafe import Markup
|
|
import base64
|
|
|
|
_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."""
|
|
# Get OdooBot's image
|
|
image_path = modules.get_module_resource('mail', 'static/src/img', 'odoobot.png')
|
|
image_base64 = False
|
|
if image_path:
|
|
with open(image_path, 'rb') as f:
|
|
image_base64 = base64.b64encode(f.read())
|
|
|
|
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,
|
|
'image_1920': image_base64,
|
|
'active': False, # Archivé comme OdooBot
|
|
})
|
|
|
|
@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
|
|
|
|
if vals.get('instructions'):
|
|
vals['instructions'] = self._convert_markdown_to_html(vals['instructions'])
|
|
|
|
# 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 name changes and activation/deactivation."""
|
|
# Store old name for channel updates
|
|
old_names = {bot.id: bot.name for bot in self} if 'name' in vals else {}
|
|
|
|
if vals.get('instructions'):
|
|
vals['instructions'] = self._convert_markdown_to_html(vals['instructions'])
|
|
|
|
res = super().write(vals)
|
|
|
|
# Handle name change
|
|
if 'name' in vals:
|
|
for bot in self:
|
|
# Update partner name
|
|
if bot.partner_id:
|
|
# Get OdooBot's image if partner doesn't have one
|
|
if not bot.partner_id.image_1920:
|
|
odoobot = self.env.ref('base.partner_root', raise_if_not_found=False)
|
|
image_1920 = odoobot.image_1920 if odoobot else None
|
|
else:
|
|
image_1920 = bot.partner_id.image_1920
|
|
|
|
bot.partner_id.sudo().write({
|
|
'name': vals['name'],
|
|
'email': f"{vals['name'].lower().replace(' ', '.')}@bot.internal",
|
|
'image_1920': image_1920,
|
|
})
|
|
|
|
# Update channel names
|
|
old_name = old_names.get(bot.id)
|
|
if old_name:
|
|
channels = self.env['discuss.channel'].sudo().search([
|
|
('bot_id', '=', bot.id),
|
|
('channel_type', '=', 'chat')
|
|
])
|
|
for channel in channels:
|
|
# Only update if the channel name contains the old bot name
|
|
if old_name in channel.name:
|
|
new_name = channel.name.replace(old_name, vals['name'])
|
|
channel.write({'name': new_name})
|
|
_logger.info(
|
|
"Updated channel name from '%s' to '%s' for bot %s",
|
|
channel.name, new_name, bot.name
|
|
)
|
|
|
|
# Handle activation
|
|
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)
|
|
)
|
|
|
|
# Get conversation history from the channel
|
|
domain = [
|
|
('model', '=', 'discuss.channel'),
|
|
('res_id', '=', channel.id),
|
|
('message_type', '=', 'comment'),
|
|
('id', '<=', message.id) # Only get messages up to current message
|
|
]
|
|
|
|
# Limit to last 10 messages for context
|
|
history_messages = self.env['mail.message'].search(domain, limit=10, order='id desc')
|
|
|
|
# Build message history in the format expected by the API
|
|
message_history = []
|
|
for msg in reversed(history_messages[:-1]): # Exclude current message
|
|
role = 'assistant' if msg.author_id == self.partner_id else 'user'
|
|
message_history.append({
|
|
'role': role,
|
|
'content': msg.body
|
|
})
|
|
|
|
# Process message with OpenWebUI
|
|
response = self.model_id.send_message(
|
|
message=message.body,
|
|
message_history=message_history,
|
|
context=self.context,
|
|
instructions=self.instructions
|
|
)
|
|
|
|
_logger.info("Got response from OpenWebUI: %s", bool(response))
|
|
|
|
if response:
|
|
# Convert markdown response to HTML
|
|
html_response = self._convert_markdown_to_odoo_html(response)
|
|
|
|
# Post the response
|
|
_logger.info("Posting response to channel %s", channel.name)
|
|
channel.sudo().message_post(
|
|
body=html_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)
|
|
|
|
def _convert_markdown_to_odoo_html(self, text):
|
|
"""Convert markdown response to Odoo-compatible HTML."""
|
|
if not text:
|
|
return ""
|
|
|
|
lines = text.split('\n')
|
|
html = []
|
|
in_list = False
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# Section separator
|
|
if line.startswith('---'):
|
|
html.append(Markup('<hr/>'))
|
|
continue
|
|
|
|
# Headers
|
|
if line.startswith('###'):
|
|
line = line.replace('###', '').strip()
|
|
line = line.replace('**', '') # Remove bold from headers
|
|
html.append(Markup('<h3>{}</h3>').format(line))
|
|
continue
|
|
|
|
# Lists
|
|
if line.startswith('- '):
|
|
if not in_list:
|
|
html.append(Markup('<ul>'))
|
|
in_list = True
|
|
line = line[2:] # Remove the '- '
|
|
line = line.replace('**', '<strong>', 1).replace('**', '</strong>', 1)
|
|
html.append(Markup('<li>{}</li>').format(Markup(line)))
|
|
continue
|
|
|
|
# Numbered lists
|
|
if line[0].isdigit() and line[1:].startswith('. '):
|
|
if not in_list:
|
|
html.append(Markup('<ol>'))
|
|
in_list = True
|
|
line = line[line.find(' ')+1:] # Remove the number and dot
|
|
line = line.replace('**', '<strong>', 1).replace('**', '</strong>', 1)
|
|
html.append(Markup('<li>{}</li>').format(Markup(line)))
|
|
continue
|
|
|
|
# End list if line doesn't match list format
|
|
if in_list and not (line.startswith('- ') or (line[0].isdigit() and line[1:].startswith('. '))):
|
|
html.append(Markup('</ul>') if str(html[-2]).startswith('<ul') else Markup('</ol>'))
|
|
in_list = False
|
|
|
|
# Regular text with bold
|
|
if not in_list:
|
|
line = line.replace('**', '<strong>', 1).replace('**', '</strong>', 1)
|
|
html.append(Markup('<p>{}</p>').format(Markup(line)))
|
|
|
|
# Close any open list
|
|
if in_list:
|
|
html.append(Markup('</ul>') if str(html[-2]).startswith('<ul') else Markup('</ol>'))
|
|
|
|
return Markup('\n').join(html)
|
|
|
|
def _convert_markdown_to_html(self, text):
|
|
"""Convert markdown-like text to Odoo-compatible HTML."""
|
|
if not text:
|
|
return ""
|
|
|
|
html_parts = []
|
|
current_section = []
|
|
in_list = False
|
|
|
|
for line in text.split('\n'):
|
|
line = line.strip()
|
|
|
|
# Skip empty lines
|
|
if not line:
|
|
if current_section:
|
|
html_parts.append('<div class="section">' + '\n'.join(current_section) + '</div>')
|
|
current_section = []
|
|
continue
|
|
|
|
# Section separator
|
|
if line.startswith('---'):
|
|
if current_section:
|
|
html_parts.append('<div class="section">' + '\n'.join(current_section) + '</div>')
|
|
current_section = []
|
|
continue
|
|
|
|
# Headers
|
|
if line.startswith('###'):
|
|
if current_section:
|
|
html_parts.append('<div class="section">' + '\n'.join(current_section) + '</div>')
|
|
current_section = []
|
|
line = line.replace('###', '').strip()
|
|
if '**' in line:
|
|
line = line.replace('**', '')
|
|
html_parts.append(f'<h3 class="o_heading">{line}</h3>')
|
|
continue
|
|
|
|
# Lists
|
|
if line.startswith('- '):
|
|
if not in_list:
|
|
if current_section:
|
|
html_parts.append('<div class="section">' + '\n'.join(current_section) + '</div>')
|
|
current_section = []
|
|
current_section.append('<ul class="o_list">')
|
|
in_list = True
|
|
line = line[2:] # Remove the '- '
|
|
if '**' in line:
|
|
line = line.replace('**', '<strong>', 1).replace('**', '</strong>', 1)
|
|
current_section.append(f'<li>{line}</li>')
|
|
continue
|
|
|
|
# Numbered lists
|
|
if line[0].isdigit() and line[1:].startswith('. '):
|
|
if not in_list:
|
|
if current_section:
|
|
html_parts.append('<div class="section">' + '\n'.join(current_section) + '</div>')
|
|
current_section = []
|
|
current_section.append('<ol class="o_list">')
|
|
in_list = True
|
|
line = line[line.find(' ')+1:] # Remove the number and dot
|
|
if '**' in line:
|
|
line = line.replace('**', '<strong>', 1).replace('**', '</strong>', 1)
|
|
current_section.append(f'<li>{line}</li>')
|
|
continue
|
|
|
|
# End list if line doesn't match list format
|
|
if in_list:
|
|
current_section.append('</ul>' if current_section[0].startswith('<ul') else '</ol>')
|
|
in_list = False
|
|
|
|
# Regular text with bold
|
|
if '**' in line:
|
|
line = line.replace('**', '<strong>', 1).replace('**', '</strong>', 1)
|
|
current_section.append(f'<p>{line}</p>')
|
|
|
|
# Add any remaining section
|
|
if current_section:
|
|
if in_list:
|
|
current_section.append('</ul>' if current_section[0].startswith('<ul') else '</ol>')
|
|
html_parts.append('<div class="section">' + '\n'.join(current_section) + '</div>')
|
|
|
|
return '\n'.join(html_parts)
|