This commit is contained in:
Benoît Vézina 2025-02-19 14:19:30 -05:00
parent 9a130bae6b
commit 0676cf1e7f
68 changed files with 0 additions and 5143 deletions

View file

@ -1 +0,0 @@
from . import models

View file

@ -1,22 +0,0 @@
{
'name': 'OpenAI Connector',
'version': '1.0',
'category': 'Tools',
'summary': 'Manage OpenAI connection settings',
'license': 'AGPL-3',
'description': '''
This module allows the configuration of OpenAI API connection
settings, including API key and Organization ID.
''',
'author': 'Bemade Inc.',
'depends': ['base_setup'],
'data': [
'views/res_config_settings_views.xml',
'security/ir.model.access.csv',
],
'external_dependencies': {
'python': ['openai'],
},
'installable': True,
'application': False,
}

View file

@ -1,2 +0,0 @@
from . import res_config_settings
from . import res_company

View file

@ -1,9 +0,0 @@
from odoo import models, fields
class ResCompany(models.Model):
_inherit = 'res.company'
api_key = fields.Char(string="API Key", help="API Key for OpenAI specific to this company. It should start with 'sk-'")
organization = fields.Char(string="Organization ID", help="Organization ID for OpenAI specific to this company. It should start with 'org-'")

View file

@ -1,96 +0,0 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import openai
import logging
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
api_key = fields.Char(
string="API Key",
config_parameter='openai_connector.api_key',
help="API Key for OpenAI, used as a default if the company-specific key is not set. Format should start with 'sk-'"
)
organization = fields.Char(
string="Organization ID",
config_parameter='openai_connector.organization',
help="Organization ID for OpenAI, used as a default if the company-specific ID is not set. Format should start with 'org-'"
)
connection_status = fields.Char(
string="Connection Status",
compute='_compute_connection_status',
help="Displays the current connection status with OpenAI."
)
@api.depends('api_key', 'organization')
def _compute_connection_status(self):
for record in self:
try:
if record.api_key and record.organization: # Vérifie que les champs ne sont pas vides
record._test_openai_connection()
record.connection_status = "Connected"
else:
record.connection_status = "Disconnected"
except Exception as e:
record.connection_status = "Disconnected"
_logger.error(f"OpenAI connection test failed: {str(e)}")
def set_values(self):
super(ResConfigSettings, self).set_values()
self.env['ir.config_parameter'].sudo().set_param(
'openai_connector.api_key', self.api_key)
self.env['ir.config_parameter'].sudo().set_param(
'openai_connector.organization', self.organization)
# Test de connexion automatique lors de l'enregistrement si les champs sont remplis
if self.api_key and self.organization:
self._test_openai_connection()
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
res.update(
api_key=self.env['ir.config_parameter'].sudo().get_param(
'openai_connector.api_key', default=''),
organization=self.env['ir.config_parameter'].sudo().get_param(
'openai_connector.organization', default='')
)
return res
def _test_openai_connection(self):
"""Method to test connection to OpenAI."""
if not self.api_key or not self.organization:
return # Ne fait rien si l'un des champs est vide
try:
client = openai.OpenAI(organization=self.organization, api_key=self.api_key)
client.models.list() # Test basique de la connexion
except Exception as e:
raise UserError(_("Failed to connect to OpenAI API. Please check the API Key and Organization ID.\nError: %s") % str(e))
def action_test_openai_connection(self):
"""Action to test connection manually and display the result."""
try:
self._test_openai_connection()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Connection Test Successful"),
'message': _("The connection to OpenAI was successful."),
'sticky': False,
},
}
except UserError as e:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Connection Test Failed"),
'message': str(e),
'sticky': True,
},
}

View file

@ -1,2 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_res_config_settings_openai,res.config.settings.openai,model_res_config_settings,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_res_config_settings_openai res.config.settings.openai model_res_config_settings base.group_system 1 1 1 1

View file

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_openai_connector_config_settings_form" model="ir.ui.view">
<field name="name">openai.connector.config.settings.form</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="30"/>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<!-- Insertion sous les paramètres généraux -->
<xpath expr="//block[@name='integration']" position="after">
<block title="OpenAI Integration" name="openai_connector_settings">
<!-- Champ pour l'API Key OpenAI -->
<setting id="openai_api_key_setting"
help="Enter the API Key for OpenAI. This will be used by default if the company-specific key is not set."
company_dependent="1">
<field name="api_key"/>
</setting>
<!-- Champ pour l'Organization ID OpenAI -->
<setting id="openai_organization_setting"
help="Enter the Organization ID for OpenAI. This will be used by default if the company-specific ID is not set."
company_dependent="1">
<field name="organization"/>
</setting>
<!-- Champ affichant le statut de connexion -->
<setting id="openai_connection_status_setting"
help="Displays the current connection status with OpenAI."
company_dependent="1">
<field name="connection_status" readonly="1"/>
</setting>
<!-- Bouton pour tester la connexion -->
<div class="oe_button_box" name="button_box">
<button name="action_test_openai_connection"
string="Tester la connexion"
type="object"
class="btn-primary oe_stat_button"
icon="fa-check-circle"/>
</div>
</block>
</xpath>
</field>
</record>
</odoo>

View file

@ -1,2 +0,0 @@
from . import models
from . import wizard

View file

@ -1,31 +0,0 @@
{
'name': 'Partner Purchase Analysis with Optional OpenAI and Queue Job',
'version': '1.0',
'category': 'Tools',
'summary': 'Manage OpenAI connection settings',
'license': 'AGPL-3',
'description': '''
This module allows the configuration of OpenAI API connection
settings, including API key and Organization ID.
''',
'author': 'Bemade Inc.',
'depends': [
'base',
'sale',
'product',
'openai_connector',
'sale_management',
],
'data': [
'security/ir.model.access.csv', # Fichier de sécurité mis à jour
'data/queue_job_group.xml',
'views/res_config_settings_view.xml',
'views/res_partner_view.xml',
'wizard/partner_purchase_analysis_wizard_view.xml',
],
'external_dependencies': {
'python': ['openai'],
},
'installable': True,
'application': False,
}

View file

@ -1,7 +0,0 @@
<odoo>
<record id="group_use_queue_job" model="res.groups">
<field name="name">Use Queue Job for Asynchronous Processing</field>
<field name="category_id" ref="base.module_category_tools"/>
</record>
</odoo>

View file

@ -1,4 +0,0 @@
from . import res_partner
# from . import partner_purchase_analysis_wizard
from . import res_config_settings
from . import res_company

View file

@ -1,93 +0,0 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import openai
try:
from odoo.addons.queue_job.job import job
except ImportError:
job = None # Si queue_job n'est pas disponible, job reste None
class PartnerPurchaseAnalysisWizard(models.TransientModel):
_name = 'partner.purchase.analysis.wizard'
_description = 'Wizard for Partner Purchase Analysis with OpenAI'
partner_id = fields.Many2one('res.partner', string="Customer", required=True, readonly=True)
date_start = fields.Date(string="Start Date")
date_end = fields.Date(string="End Date")
analysis_result = fields.Text(string="Analysis Result", readonly=True)
def start_analysis(self):
use_queue = self.env['ir.config_parameter'].sudo().get_param('my_module.use_queue_job')
if use_queue and job:
return self.with_delay().perform_analysis()
else:
return self.perform_analysis()
def perform_analysis(self):
"""Effectue l'analyse des ventes pour le partenaire sélectionné."""
selected_categories = self.env.company.product_categories_analyzed
if selected_categories:
category_ids = selected_categories.ids
domain = [
('order_id.partner_id', '=', self.partner_id.id),
('product_id.categ_id', 'child_of', category_ids),
]
else:
domain = [('order_id.partner_id', '=', self.partner_id.id)]
if self.date_start:
domain.append(('order_id.date_order', '>=', self.date_start))
if self.date_end:
domain.append(('order_id.date_order', '<=', self.date_end))
sale_order_lines = self.env['sale.order.line'].search(domain)
if not sale_order_lines:
raise UserError(_("No relevant purchase history found for this customer based on the selected filters."))
# Préparation des données pour l'API d'OpenAI
purchase_data = {}
for line in sale_order_lines:
category_name = line.product_id.categ_id.name or "Uncategorized"
if category_name not in purchase_data:
purchase_data[category_name] = {}
product_name = line.product_id.display_name
if product_name not in purchase_data[category_name]:
purchase_data[category_name][product_name] = []
purchase_data[category_name][product_name].append({
'date': line.order_id.date_order,
'quantity': line.product_uom_qty,
'unit_price': line.price_unit,
})
# Construire le prompt pour OpenAI
user_lang = self.env.user.lang or 'en_US'
user_lang_name = self.env['res.lang'].search([('code', '=', user_lang)], limit=1).name or "English"
purchase_details = f"Customer Purchase Analysis Grouped by Product Category and Product (Response in {user_lang_name}):\n\n"
for category, products in purchase_data.items():
purchase_details += f"Category: {category}\n"
for product, entries in products.items():
purchase_details += f" Product: {product}\n"
for entry in entries:
purchase_details += (
f" - Date: {entry['date']}, "
f"Qty: {entry['quantity']}, "
f"U.Price: {entry['unit_price']}\n"
)
purchase_details += "\n"
purchase_details += "\n"
try:
response = openai.ChatCompletion.create(
model="gpt-4",
messages=[
{"role": "system",
"content": f"Analyze the following customer purchase history. Identify trends, product category preferences, and any significant deviations. Respond in {user_lang_name}."},
{"role": "user", "content": purchase_details}
]
)
self.analysis_result = response.choices[0].message['content']
except Exception as e:
raise UserError(_("Failed to get response from OpenAI. Error: %s") % str(e))

View file

@ -1,11 +0,0 @@
from odoo import models, fields
class ResCompany(models.Model):
_inherit = 'res.company'
product_categories_analyzed = fields.Many2many(
'product.category',
string="Product Categories Analyzed",
help="Select product categories to include in the purchase analysis. Only products in these categories and their subcategories will be analyzed."
)

View file

@ -1,36 +0,0 @@
from odoo import models, fields, api
from odoo.modules.module import get_module_resource
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
use_queue_job = fields.Boolean(
string="Use Queue Job for Asynchronous Processing",
help="If enabled, the system will use queue jobs for background tasks. Requires the queue_job module.",
default=False
)
product_categories_analyzed = fields.Many2many(
related='company_id.product_categories_analyzed',
comodel_name='product.category',
string="Product Categories Analyzed",
help="Select product categories to include in the purchase analysis."
)
@api.model
def get_values(self):
res = super().get_values()
res.update(
use_queue_job=self.env['ir.config_parameter'].sudo().get_param('my_module.use_queue_job', default=False)
)
return res
def set_values(self):
super().set_values()
self.env['ir.config_parameter'].sudo().set_param('my_module.use_queue_job', self.use_queue_job)
@api.model
def enable_queue_job_group(self):
"""Enable the group if the queue_job module is installed"""
group = self.env.ref('partner_purchase_analysis_with_openai_filtered.group_use_queue_job', raise_if_not_found=False)
if group and get_module_resource('queue_job'):
group.sudo().write({'users': [(4, self.env.user.id)]})

View file

@ -1,20 +0,0 @@
from odoo import models, fields
class ResPartner(models.Model):
_inherit = 'res.partner'
sale_analysis = fields.Html("Sale Analysis", help="Analysis of the partner's sales.")
sale_analysis_date = fields.Date("Sale Analysis Date", help="Date of the latest sale analysis.")
def action_open_purchase_analysis(self):
"""Ouvre le wizard d'analyse des achats pour le client actuel."""
return {
'name': 'Purchase Analysis',
'type': 'ir.actions.act_window',
'res_model': 'partner.purchase.analysis.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_partner_id': self.id,
},
}

View file

@ -1 +0,0 @@
../.repos/bemade-addons/openai_connector

View file

@ -1,2 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_partner_purchase_analysis_wizard_sales,partner.purchase.analysis.wizard access,model_partner_purchase_analysis_wizard,sales_team.group_sale_salesman,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_partner_purchase_analysis_wizard_sales partner.purchase.analysis.wizard access model_partner_purchase_analysis_wizard sales_team.group_sale_salesman 1 1 1 1

View file

@ -1,19 +0,0 @@
<odoo>
<record id="res_config_settings_view_form_inherit_partner_analysis" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.partner.analysis</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="openai_connector.view_openai_connector_config_settings_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@name='openai_connector_settings']" position="inside">
<div class="col-12" groups="openai_partner_purchase_analysis.group_use_queue_job">
<label for="use_queue_job"/>
<field name="use_queue_job"/>
</div>
<div class="col-12 mt16">
<label for="product_categories_analyzed"/>
<field name="product_categories_analyzed" widget="many2many_tags"/>
</div>
</xpath>
</field>
</record>
</odoo>

View file

@ -1,17 +0,0 @@
<odoo>
<record id="view_partner_form_inherit_purchase_analysis" model="ir.ui.view">
<field name="name">res.partner.form.inherit.purchase.analysis</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<notebook position="inside">
<page string="Sales Analysis">
<group>
<field name="sale_analysis" widget="html" placeholder="No analysis available"/>
<field name="sale_analysis_date" placeholder="Date of analysis"/>
</group>
</page>
</notebook>
</field>
</record>
</odoo>

View file

@ -1 +0,0 @@
from . import partner_purchase_analysis_wizard

View file

@ -1,141 +0,0 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from openai import OpenAI
from datetime import datetime
from bs4 import BeautifulSoup
from markupsafe import escape
# Import conditionnel de queue_job pour gérer les files d'attente si elles sont disponibles
try:
from odoo.addons.queue_job.job import job
except ImportError:
job = None # Si queue_job n'est pas disponible, job reste None
class PartnerPurchaseAnalysisWizard(models.TransientModel):
_name = 'partner.purchase.analysis.wizard'
_description = 'Wizard for Partner Purchase Analysis with OpenAI'
partner_id = fields.Many2one('res.partner', string="Customer", required=True, readonly=True)
date_start = fields.Date(string="Start Date", required=True)
date_end = fields.Date(string="End Date", default=fields.Date.today, required=True)
def start_analysis(self):
"""Lance l'analyse en arrière-plan si `queue_job` est disponible, sinon exécute immédiatement."""
use_queue = self.env['ir.config_parameter'].sudo().get_param('my_module.use_queue_job')
if use_queue and job:
return self.with_delay().perform_analysis()
else:
return self.perform_analysis()
def perform_analysis(self):
"""Effectue l'analyse des ventes pour le partenaire sélectionné."""
# Récupération de l'organisation et de la clé API OpenAI dans les paramètres
organization = self.env['ir.config_parameter'].sudo().get_param('openai_connector.organization')
api_key = self.env['ir.config_parameter'].sudo().get_param('openai_connector.api_key')
if not api_key or not organization:
raise UserError(_("API Key or Organization ID for OpenAI is missing in settings."))
client = OpenAI(
api_key=api_key,
organization=organization
)
selected_categories = self.env.company.product_categories_analyzed
if selected_categories:
category_ids = selected_categories.ids
domain = [
('order_id.partner_id', '=', self.partner_id.id),
('product_id.categ_id', 'child_of', category_ids),
]
else:
domain = [('order_id.partner_id', '=', self.partner_id.id)]
if self.date_start:
domain.append(('order_id.date_order', '>=', self.date_start))
if self.date_end:
domain.append(('order_id.date_order', '<=', self.date_end))
sale_order_lines = self.env['sale.order.line'].search(domain)
if not sale_order_lines:
raise UserError(_("No relevant purchase history found for this customer based on the selected filters."))
# Préparation des données pour le prompt OpenAI
purchase_data = {}
for line in sale_order_lines:
category_name = line.product_id.categ_id.name or "Uncategorized"
if category_name not in purchase_data:
purchase_data[category_name] = {}
product_name = line.product_id.display_name
if product_name not in purchase_data[category_name]:
purchase_data[category_name][product_name] = []
purchase_data[category_name][product_name].append({
'date': line.order_id.date_order,
'quantity': line.product_uom_qty,
'unit_price': line.price_unit,
})
# Prompt défini en plusieurs lignes pour lisibilité
user_lang = self.env.user.lang or 'en_US'
user_lang_name = self.env['res.lang'].search([('code', '=', user_lang)], limit=1).name or "English"
prompt = (
"Analyze the following customer purchase history. Identify trends, product "
"category preferences, and any significant deviations. Respond in "
f"{user_lang_name}. Produce graph and table of the analysis. You output all "
"in html format."
)
# Construction de `purchase_details` pour le contenu du prompt
purchase_details = f"Customer Purchase Analysis Grouped by Product Category and Product:\n\n"
for category, products in purchase_data.items():
purchase_details += f"Category: {category}\n"
for product, entries in products.items():
purchase_details += f" Product: {product}\n"
for entry in entries:
purchase_details += (
f" - Date: {entry['date']}, "
f"Qty: {entry['quantity']}, "
f"U.Price: {entry['unit_price']}\n"
)
purchase_details += "\n"
purchase_details += "\n"
# Appel à l'API OpenAI
try:
response = client.chat.completions.create(
messages=[
{
"role": "system",
"content": prompt
},
{
"role": "user",
"content": purchase_details
}
],
model="gpt-4o",
)
# Supposons que `response` contient la réponse complète au format HTML
response_text = response.choices[0].message.content
# Parse avec BeautifulSoup pour extraire le contenu du <body>
soup = BeautifulSoup(response_text, "html.parser")
body_content = soup.body
# Si body est présent, on utilise son contenu ; sinon, on utilise tout le texte
cleaned_text = escape(body_content.get_text()) if body_content else escape(response_text)
# Ajouter des balises de base pour structurer le texte en HTML simple
html_content = f"<div>{cleaned_text.replace('\n', '<br/>')}</div>"
self.partner_id.sale_analysis = body_content
self.partner_id.sale_analysis_date = fields.Datetime.today()
# self.sale_analysis = response.choices[0].message.content
# self.sale_analysis_date = fields.Datetime.today()
print(response.choices[0].message.content)
except Exception as e:
raise UserError(_("Failed to get response from OpenAI. Error: %s") % str(e))

View file

@ -1,45 +0,0 @@
<odoo>
<record id="view_partner_purchase_analysis_wizard_form" model="ir.ui.view">
<field name="name">partner.purchase.analysis.wizard.form</field>
<field name="model">partner.purchase.analysis.wizard</field>
<field name="arch" type="xml">
<form string="Analyze Purchases with OpenAI">
<group>
<field name="partner_id" readonly="1"/>
<field name="date_start"/>
<field name="date_end"/>
<button name="start_analysis" string="Run Analysis" type="object" class="btn-primary"/>
</group>
</form>
</field>
</record>
<!-- Action serveur pour ouvrir le wizard d'analyse des achats -->
<record id="action_partner_purchase_analysis" model="ir.actions.server">
<field name="name">Analyze Purchases</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="state">code</field>
<field name="binding_type">action</field>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="code">
action = {
'name': 'Purchase Analysis',
'type': 'ir.actions.act_window',
'res_model': 'partner.purchase.analysis.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_partner_id': env.context.get('active_id'),
}
}
</field>
</record>
<!-- Ajouter l'action dans le menu "Actions" de res.partner -->
<!-- <record id="action_partner_purchase_analysis_act_window" model="ir.actions.servers">-->
<!-- <field name="name">Analyze Purchases</field>-->
<!-- <field name="model">res.partner</field>-->
<!-- <field name="key2">client_action_multi</field>-->
<!-- <field name="value" eval="'ir.actions.server,' + str(ref('openai_partner_purchase_analysis.action_partner_purchase_analysis'))"/>-->
<!-- </record>-->
</odoo>

File diff suppressed because it is too large Load diff

View file

@ -1,110 +0,0 @@
# Spécifications du module OpenWebUI Integration
## Objectif
Ce module fournit une intégration complète entre Odoo et OpenWebUI, permettant l'utilisation de modèles d'IA avancés dans diverses fonctionnalités d'Odoo. Il sert de base pour tous les modules qui souhaitent utiliser les capacités d'IA d'OpenWebUI.
## Architecture du Module
### Structure des Répertoires
```
openwebui_integration/
├── controllers/ # Contrôleurs pour les endpoints web
├── models/ # Modèles de données
├── security/ # Fichiers de sécurité et accès
├── static/ # Ressources statiques
└── views/ # Vues XML Odoo
```
## Composants Principaux
### 1. OpenWebUI Bot Mixin (`openwebui.bot.mixin`)
Mixin permettant d'ajouter des fonctionnalités de bot à n'importe quel modèle Odoo.
#### Fonctionnalités clés:
- Gestion du contexte du bot
- Génération et traitement des messages
- Gestion des réponses
- Points de personnalisation spécifiques au modèle
#### Méthodes principales:
- `_get_bot_context()`: Récupère le contexte et les paramètres du bot
- `_apply_logic()`: Applique la logique du bot à un enregistrement
- `_generate_bot_message()`: Génère le message à envoyer (à surcharger)
- `_process_bot_response()`: Traite la réponse du bot (à surcharger)
### 2. Modèle OpenWebUI (`openwebui.model`)
Gère les modèles d'IA disponibles via OpenWebUI.
#### Caractéristiques:
- Modèles par défaut: GPT-3.5 Turbo, GPT-4, Claude 2
- Identifiant unique par entreprise
- Gestion de l'état actif/inactif
- Support des modèles temporaires pour les tests
#### Contraintes de sécurité:
- Création manuelle interdite (sauf modèles temporaires)
- Modifications limitées (activation/désactivation uniquement)
- Suppression contrôlée
### 3. Configuration par Entreprise
Extension du modèle `res.company` pour la configuration OpenWebUI.
#### Paramètres configurables:
- `openwebui_enabled`: Activation de l'intégration
- `openwebui_api_url`: URL de l'API
- `openwebui_api_key`: Clé d'API
- `openwebui_verify_ssl`: Vérification du certificat SSL
- `openwebui_timeout`: Délai d'attente des requêtes
- `openwebui_default_model_id`: Modèle d'IA par défaut à utiliser
- Sélectionnable uniquement parmi les modèles actifs
- Utilisé comme modèle par défaut pour toutes les requêtes IA
- Peut être surchargé au niveau des modules spécifiques
#### Fonctionnalités de gestion:
- Test de connexion à l'API
- Synchronisation des modèles disponibles
- Gestion des modèles par entreprise
## Sécurité et Gestion des Erreurs
### Sécurité
- Authentification API via clé
- Vérification SSL configurable
- Contrôle d'accès par entreprise
- Protection contre la création/modification non autorisée
### Gestion des Erreurs
- Validation des paramètres de connexion
- Gestion des timeouts
- Traitement des erreurs API
- Validation des réponses
## Intégration et Utilisation
### Étapes d'installation
1. Installation du module via Odoo
2. Configuration des paramètres OpenWebUI dans la configuration de l'entreprise
3. Test de la connexion API
4. Synchronisation initiale des modèles
### Développement d'Extensions
1. Hériter du mixin `openwebui.bot.mixin`
2. Implémenter les méthodes de génération et traitement
3. Configurer les paramètres spécifiques au modèle
4. Gérer les réponses selon les besoins
### Maintenance
- Nettoyage périodique des modèles temporaires
- Surveillance des timeouts et erreurs
- Mise à jour des modèles disponibles
## Dépendances
- Module `mail` d'Odoo
- Accès à une instance OpenWebUI
- Python 3.x avec support SSL
## Notes Techniques
- Utilisation de requêtes HTTP asynchrones
- Cache des réponses API pour optimisation
- Support multi-entreprises
- Extensible pour différents cas d'usage

View file

@ -1,2 +0,0 @@
from . import models
from . import controllers

View file

@ -1,34 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'OpenWebUI Integration',
'version': '18.0.1.1.0',
'summary': 'Core integration with OpenWebUI',
'sequence': 10,
'description': """
OpenWebUI Integration
=====================
This module provides core integration with OpenWebUI.
Features:
---------
* Connect to OpenWebUI API
* Manage models and configurations
* Base bot functionality and configuration
* Authentication and access control
""",
'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',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
'i18n': True,
'post_init_hook': 'post_init_hook',
}

View file

@ -1 +0,0 @@
from . import main

View file

@ -1,22 +0,0 @@
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,
}

View file

@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
def post_init_hook(cr, registry):
"""Post-init hook for initializing new columns."""
# Initialize ollama_port with default value
cr.execute("""
ALTER TABLE res_company
ADD COLUMN IF NOT EXISTS ollama_port integer DEFAULT 11434;
""")

View file

@ -1,6 +0,0 @@
# -*- 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 res_company

View file

@ -1,223 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
"""OpenWebUI Bot Mixin Module
This module provides a mixin class that can be used to add OpenWebUI bot
functionality to any Odoo model. It handles the integration between Odoo
models and OpenWebUI's AI models, providing:
- Bot context management
- Message generation and processing
- Response handling
- Model-specific customization points
Example usage:
class MyModel(models.Model, OpenWebUIBotMixin):
_name = 'my.model'
def _generate_bot_message(self, record, values, command=None):
return f"Process this: {record.name}"
def _process_bot_response(self, values, response):
values['processed_text'] = response
return values
"""
import json
import logging
import re
import time
import requests
from odoo import models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
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_model(self, model):
"""Gets the model to use"""
return model
def _apply_logic(self, record, values, command=None):
"""Applies the model logic to a record."""
model = values.get('bot')
if not model:
return values
# Get the model
model = self._get_model(model)
# Generate the message for the model
message = self._generate_bot_message(record, values, command)
# Paramètres de retry
max_retries = 3
base_delay = 2 # délai initial en secondes
# Obtenir le timeout de la configuration de la compagnie
company = self.env.company
timeout = company.openwebui_timeout
last_error = None
for attempt in range(max_retries):
try:
# Send the message to the model
_logger.info('Attempt %d/%d: Sending message to model (timeout=%ds)',
attempt + 1, max_retries, timeout)
_logger.info('Sending message to model: %r', message)
response = model.send_message(message=message, timeout=timeout)
_logger.info('Raw response from model: %r', response)
# Si la réponse contient une erreur de connexion, on la traite comme une exception
if isinstance(response, str) and "Connection error" in response:
_logger.error('Connection error in response: %r', response)
raise ConnectionError(response)
_logger.info('Processed response from model: %r', response)
if not response:
raise ValueError("Empty response from model")
if not isinstance(response, str):
raise ValueError(f"Invalid response type: {type(response)}")
# Debug de la réponse brute
_logger.info('Raw response from model: %r', response)
# Clean up the response
# Supprimer les blocs de code markdown et autres caractères problématiques
response = re.sub(r'```json\n|\n```', '', response.strip())
response = re.sub(r'\\([^\\])', r'\1', response) # Supprimer les backslashes simples
response = re.sub(r'">\\n', '",', response) # Corriger le format des fins de lignes
response = re.sub(r'\\n\s*]\\n"}$', ']}', response) # Corriger la fin du JSON
_logger.info('Response after cleanup: %r', response)
# Debug des lignes individuelles
lines = response.strip().split('\n')
_logger.info('Number of response lines: %d', len(lines))
for i, line in enumerate(lines):
_logger.info('Line %d: %r', i + 1, line)
# Paramètres de retry pour le parsing JSON
max_json_retries = 3
json_retry_delay = 2 # secondes
last_json_error = None
for json_attempt in range(max_json_retries):
try:
# Pour Ollama, la réponse peut être une série de JSON séparés par des newlines
# On prend le dernier JSON qui devrait être la réponse finale
json_responses = [json.loads(line) for line in response.strip().split('\n') if line.strip()]
if json_responses:
parsed_response = json_responses[-1] # Prendre le dernier JSON
else:
parsed_response = json.loads(response)
# Si on arrive ici, le parsing a réussi
break
except json.JSONDecodeError as e:
last_json_error = e
if json_attempt < max_json_retries - 1:
_logger.warning('JSON parsing attempt %d/%d failed: %s. Retrying in %d seconds...',
json_attempt + 1, max_json_retries, str(e), json_retry_delay)
time.sleep(json_retry_delay)
else:
_logger.error('All %d JSON parsing attempts failed. Last error: %s',
max_json_retries, str(e))
raise ValueError(f"Failed to parse JSON after {max_json_retries} attempts: {str(e)}")
# Si c'est un dictionnaire avec une clé 'response', extraire la valeur
if isinstance(parsed_response, dict):
if 'response' in parsed_response:
# Pour Ollama, la réponse est directement dans la clé 'response'
clean_response = parsed_response['response']
return self._process_bot_response(values, clean_response)
clean_response = response
# Si le parsing initial a échoué, essayer d'extraire une liste JSON
if last_json_error is not None:
_logger.warning('Failed to parse response directly, trying to extract JSON list')
start = response.find('[')
end = response.rfind(']')
if start == -1 or end == -1:
_logger.error('No JSON list markers found in response: %r', response)
raise ValueError("No JSON list found in response")
clean_response = response[start:end + 1]
_logger.debug('Extracted JSON list: %r', clean_response)
# Nouveau cycle de retry pour le JSON extrait
for json_attempt in range(max_json_retries):
try:
parsed_response = json.loads(clean_response)
# Si on arrive ici, le parsing a réussi
break
except json.JSONDecodeError as e:
last_json_error = e
if json_attempt < max_json_retries - 1:
_logger.warning('Extracted JSON parsing attempt %d/%d failed: %s. Retrying in %d seconds...',
json_attempt + 1, max_json_retries, str(e), json_retry_delay)
time.sleep(json_retry_delay)
else:
_logger.error('All %d extracted JSON parsing attempts failed. Last error: %s',
max_json_retries, str(e))
raise ValueError("Invalid JSON list in response")
# Extraire la liste de produits si présente
if isinstance(parsed_response, dict):
if 'products' in parsed_response:
parsed_response = parsed_response['products']
elif 'response' in parsed_response:
# Cas précédent où la réponse est dans la clé 'response'
parsed_response = json.loads(parsed_response['response'])
if not isinstance(parsed_response, list):
_logger.error('Response is not a list: %r', parsed_response)
raise ValueError("Response must be a JSON list")
_logger.info('Successfully parsed response as list with %d items', len(parsed_response))
# Si on arrive ici, tout s'est bien passé
# Update the values with the cleaned response
values = self._process_bot_response(values, clean_response)
return values
except (ConnectionError, TimeoutError) as e:
last_error = e
if attempt < max_retries - 1: # Ne pas attendre après la dernière tentative
delay = base_delay * (2 ** attempt) # Délai exponentiel: 2s, 4s, 8s
_logger.warning('Connection error on attempt %d: %s. Retrying in %d seconds...',
attempt + 1, str(e), delay)
time.sleep(delay)
else:
_logger.error('All %d connection attempts failed. Last error: %s', max_retries, str(e))
except Exception as e:
# Pour les autres erreurs, on ne réessaie pas
_logger.error('Non-connection error occurred: %s', str(e))
raise e
# Si on arrive ici, toutes les tentatives ont échoué
raise last_error
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

View file

@ -1,535 +0,0 @@
# -*-
"""
OpenWebUI Model Module
This module defines the models needed for OpenWebUI integration in Odoo.
It includes user management, roles, and customizable interfaces.
"""
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import requests
from odoo import models, fields, api, _
from odoo.exceptions import UserError, AccessError
_logger = logging.getLogger(__name__)
_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, in installation mode, or during sync
if not (vals.get('is_temp') or self.env.context.get('install_mode') or self.env.context.get('sync_models')):
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 send_message(self, message, message_history=None, context=None, instructions=None, timeout=None):
"""Sends a message to the model and returns its response
Args:
message (str): The message to send
message_history (list): Optional list of previous messages in the format
[{'role': 'user'|'assistant', 'content': 'message'}, ...]
context (dict): Optional context to pass to the model
instructions (str): Optional system instructions
timeout (int): Optional timeout in seconds for this request
Returns:
str: The model's response or error message
"""
self.ensure_one()
company = self.env.company
# Base data structure
if company.ai_provider == 'ollama':
# Format for Ollama API
data = {
'model': self.identifier,
'stream': False, # We want a single response
'options': {
'num_ctx': company.openwebui_context_size,
'temperature': 0.7 # Default temperature
}
}
# Handle message history and current message
if message_history:
messages = []
for msg in message_history:
messages.append({
'role': msg['role'],
'content': msg['content']
})
messages.append({'role': 'user', 'content': message})
data['messages'] = messages
else:
# For single message, use simple prompt
if instructions:
# If system instructions are provided, use chat format
data['messages'] = [
{'role': 'system', 'content': instructions},
{'role': 'user', 'content': message}
]
else:
# For simple queries, use generate endpoint
data = {
'model': self.identifier,
'prompt': message,
'stream': False,
'options': data['options']
}
endpoint = 'api/generate'
if not 'endpoint' in locals():
endpoint = 'api/chat'
else: # openwebui
# Format for OpenWebUI API
data = {
'model': self.identifier,
'messages': message_history or []
}
data['messages'].append({'role': 'user', 'content': message})
if instructions:
data['system'] = instructions
if context:
data['context'] = context
data['options'] = {
'num_ctx': company.openwebui_context_size
}
endpoint = 'chat/completions'
_logger.info('Sending request to %s with data: %r', endpoint, data)
success, result = self._make_request(endpoint, method='POST', data=data, timeout=timeout)
if not success:
_logger.error('Request failed: %s', result)
return f"Error: {result}"
_logger.info('Raw API response: %r', result)
try:
# Handle Ollama response format
if isinstance(result, dict):
if 'response' in result: # Ollama chat/generate response
return result['response']
elif 'message' in result: # Ollama chat response alternative format
return result['message']['content']
elif 'choices' in result: # OpenWebUI format
return result['choices'][0]['message']['content']
else:
_logger.error('Unexpected API response format: %r', result)
return f"Error: Unexpected response format from API"
# Handle streaming response (should not happen with stream=False)
elif isinstance(result, str):
try:
# Parse the last line of a streaming response
lines = [line.strip() for line in result.split('\n') if line.strip()]
if lines:
last_response = json.loads(lines[-1])
if 'response' in last_response:
return last_response['response']
except json.JSONDecodeError as e:
_logger.error('Failed to parse streaming response: %s', e)
return result # Return raw string if parsing fails
return f"Error: Unexpected response type: {type(result)}"
except Exception as e:
_logger.error('Error processing API response: %s', str(e))
return f"Error processing response: {str(e)}"
def cleanup_temp_models(self):
"""Clean up temporary models from the database.
This method automatically removes models marked as temporary,
including:
- Models with identifier starting with 'test_'
- Models with identifier starting with 'refresh_'
- Temporary models created before the current date
Note:
Deletion is performed with 'sync_models' context to bypass
standard deletion restrictions.
"""
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):
"""Get the OpenWebUI configuration for the current company.
Returns:
dict: A dictionary containing the OpenWebUI configuration:
{
'enabled': bool, # Whether integration is enabled
'api_url': str, # OpenWebUI API URL
'api_key': str, # API key for authentication
'verify_ssl': bool, # SSL certificate verification
'timeout': int # Request timeout in seconds
}
"""
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):
"""Make a request to the OpenWebUI API.
Args:
endpoint (str): API endpoint (without /api/ prefix)
method (str, optional): HTTP method to use. Defaults to 'GET'.
data (dict, optional): Data to send for POST/PUT/PATCH methods.
files (dict, optional): Files to send as multipart/form-data.
timeout (int, optional): Custom request timeout in seconds.
Returns:
tuple: A tuple (success, result) where:
- success (bool): True if request succeeded, False otherwise
- result (dict|str): JSON response if success, error message if failure
Note:
The method automatically handles:
- API key authentication
- SSL verification
- Content-Type headers
- Timeouts and connection errors
"""
config = self._get_company_config()
if not config['enabled']:
return False, "OpenWebUI is not enabled"
try:
api_url = config['api_url']
if not api_url:
return False, "OpenWebUI API URL is not configured"
# Add scheme if missing
if not api_url.startswith(('http://', 'https://')):
api_url = f'http://{api_url}'
# Check if port is included in URL
from urllib.parse import urlparse
parsed_url = urlparse(api_url)
if not parsed_url.port:
# Use Ollama port from company configuration
company = self.env.company
netloc = parsed_url.netloc
if ':' in netloc:
host = netloc.split(':')[0]
else:
host = netloc
api_url = f"{parsed_url.scheme}://{host}:{company.ollama_port}"
# Pour Ollama, les endpoints incluent déjà /api/
if endpoint.startswith('api/'):
url = f"{api_url.rstrip('/')}/{endpoint.lstrip('/')}"
else:
url = f"{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
# Send the request
response = requests.request(method=method, url=url, **kwargs)
response.raise_for_status()
# Log raw response
response_text = response.text.strip()
_logger.info('Raw response text: %r', response_text)
# Split response into lines (Ollama may send multiple JSON objects)
response_lines = [line.strip() for line in response_text.split('\n') if line.strip()]
# Try to parse each line as JSON
parsed_responses = []
for line in response_lines:
try:
parsed_json = json.loads(line)
if isinstance(parsed_json, dict):
# Handle Ollama message format
if 'message' in parsed_json and 'content' in parsed_json['message']:
content = parsed_json['message']['content']
if content:
try:
# Try to parse content as JSON
content_json = json.loads(content)
parsed_responses.append(content_json)
except json.JSONDecodeError:
# Content is not JSON
parsed_responses.append(content)
else:
parsed_responses.append(parsed_json)
except json.JSONDecodeError:
# Skip invalid JSON lines
continue
# Return the last valid parsed response
if parsed_responses:
return True, parsed_responses[-1]
# If no valid JSON found, return the raw text
return True, response_text
except requests.exceptions.SSLError:
return False, "SSL/TLS verification failed"
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 (TypeError, AttributeError) as e:
return False, f"Invalid request parameters: {str(e)}"
except KeyError as e:
return False, f"Missing required configuration: {str(e)}"
def test_connection(self):
"""Test the connection to the OpenWebUI API.
This method performs a simple request to the /api/chat endpoint
to verify that:
1. The API URL is accessible
2. The credentials are valid
3. The SSL configuration is correct
Returns:
tuple: A tuple (success, result) where:
- success (bool): True if test succeeded
- result (dict|str): Models list if success, error message if failure
Note:
Uses a reduced timeout of 5 seconds to avoid long waits.
"""
return self._make_request('api/chat', timeout=5)
@api.model
def get_available_models(self):
"""Get the list of available models from the OpenWebUI/Ollama API.
This method attempts to retrieve the list of models from the API.
For Ollama, it uses the /api/tags endpoint.
If it fails, it returns a default list of common models.
Returns:
list: A list of tuples (id, name) of available models.
Example: [('llama2', 'Llama 2'), ('mistral', 'Mistral')]
Note:
- Handles both Ollama and OpenWebUI API response formats
- Returns DEFAULT_MODELS in case of error or unexpected format
- Errors are logged but don't interrupt execution
"""
# Try Ollama endpoint first
success, result = self._make_request('api/tags')
if not success:
# Try OpenWebUI endpoint as fallback
success, result = self._make_request('models')
if not success:
_logger.error(f"Error fetching models: {result}")
return DEFAULT_MODELS
try:
# Handle Ollama response format
if isinstance(result, dict) and 'models' in result:
return [(model['name'], model.get('name', model['name'])) for model in result['models']]
# Handle OpenWebUI response format
elif 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 API")
return DEFAULT_MODELS
except (KeyError, TypeError, AttributeError) as e:
_logger.error(f"Error processing models response: {str(e)}")
return DEFAULT_MODELS
def sync_models(self):
"""Synchronize models from OpenWebUI.
This method ensures that local models match those available in OpenWebUI.
It will create new models and update existing ones as needed.
Returns:
tuple: A tuple (success, result) where:
- success (bool): True if synchronization succeeded
- result (str|None): Error message if failed, None if succeeded
"""
self.ensure_one()
success, result = self._sync_models()
return success, result
@api.model
def _sync_models(self):
"""Internal method to synchronize models from OpenWebUI/Ollama.
This method performs the actual synchronization work:
1. Cleans up temporary models
2. Fetches current models from the API
3. Creates new models that don't exist locally
Returns:
tuple: A tuple (success, result) where:
- success (bool): True if synchronization succeeded
- result (str|None): Error message if failed, None if succeeded
Note:
This is an internal method called by sync_models().
It should not be called directly unless you need fine-grained control.
Supports both Ollama and OpenWebUI API formats.
"""
# First clean up temporary models
self.cleanup_temp_models()
# Try Ollama endpoint first
success, result = self._make_request('api/tags')
if not success:
# Try OpenWebUI endpoint as fallback
success, result = self._make_request('models')
if not success:
return False, result
try:
# Handle Ollama response format
if isinstance(result, dict) and 'models' in result:
models_data = result['models']
for model_data in models_data:
existing = self.search([('identifier', '=', model_data['name'])])
if not existing:
self.with_context(sync_models=True).create({
'name': model_data['name'],
'identifier': model_data['name'],
'description': model_data.get('description', ''),
})
# Handle OpenWebUI response format
else:
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 (KeyError, TypeError) as e:
_logger.error(f"Error processing model data: {str(e)}")
return False, f"Error processing model data: {str(e)}"
return False, f"Invalid model data format: {str(e)}"
except odoo.exceptions.AccessError as e:
_logger.error(f"Access error while creating model: {str(e)}")
return False, f"Permission denied: {str(e)}"

View file

@ -1,300 +0,0 @@
# -*- coding: utf-8 -*-
"""
This module extends the res.company model to add OpenWebUI configuration.
It manages OpenWebUI integration settings at the company level,
including service activation, API keys, and other configuration parameters.
"""
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api, tools, _
from odoo.exceptions import UserError
class ResCompany(models.Model):
_inherit = 'res.company'
ai_provider = fields.Selection([
('openwebui', 'OpenWebUI'),
('ollama', 'Ollama')
],
string='AI Provider',
default='openwebui',
required=True,
help="Select the AI provider to use"
)
# Ollama Configuration
ollama_port = fields.Integer(
string='Ollama Port',
default=11434,
required=True,
help="Port number for the Ollama server (default: 11434)"
)
# OpenWebUI Configuration
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=60,
help="Maximum wait time for API calls"
)
openwebui_context_size = fields.Integer(
string='Context Window Size',
default=4096,
help="Maximum number of tokens in the context window (default: 4096)"
)
openwebui_max_products = fields.Integer(
string='Maximum Products per Batch',
default=800,
help="Maximum number of products that can be processed at once (default: 800)"
)
def test_ollama_connection(self):
"""Test the connection to Ollama server.
Returns:
dict: A notification action with the test result
"""
self.ensure_one()
if self.ai_provider != 'ollama':
raise UserError(_("Please select Ollama as the AI provider first."))
try:
import requests
# Test connection to Ollama server
url = f'http://localhost:{self.ollama_port}/api/tags'
response = requests.get(url, timeout=5)
response.raise_for_status()
# If we get here, the connection was successful
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Successfully connected to Ollama server on port %s', self.ollama_port),
'sticky': False,
'type': 'success',
}
}
except requests.exceptions.ConnectionError:
raise UserError(_("Could not connect to Ollama server. Please check if Ollama is running and the port number is correct."))
except requests.exceptions.Timeout:
raise UserError(_("Connection to Ollama server timed out. Please check your network settings."))
except requests.exceptions.RequestException as e:
raise UserError(_("Error connecting to Ollama server: %s", str(e)))
openwebui_products_per_request = fields.Integer(
string='Products per Request',
default=10,
help="Number of products to process in a single API request"
)
openwebui_models_ids = fields.One2many(
comodel_name='openwebui.model',
inverse_name='company_id',
string='OpenWebUI Models',
)
openwebui_default_model_id = fields.Many2one(
comodel_name='openwebui.model',
string='Default Model',
domain="[('company_id', '=', id), ('is_active', '=', True)]",
help="Default OpenWebUI model to use for AI requests"
)
openwebui_context_size = fields.Integer(
string='Context Window Size',
default=2048,
help="Size of the context window in tokens (e.g., 2048, 4096, 8192)"
)
def test_openwebui_connection(self):
"""Test the connection to OpenWebUI API.
This method verifies the connection to OpenWebUI by:
1. Checking if OpenWebUI integration is enabled
2. Attempting to connect to the configured API endpoint
3. Validating the API credentials
Returns:
dict: A notification action with the test results:
{
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Success',
'message': str,
'type': 'success',
'sticky': False
}
}
Raises:
UserError: If any of the following conditions occur:
- OpenWebUI is not enabled for the company
- API endpoint is not reachable
- Invalid API credentials
- Connection timeout
"""
self.ensure_one()
if not self.openwebui_enabled:
raise UserError(_("OpenWebUI is not enabled for this company."))
success, result = self.env['openwebui.model'].test_connection()
if not success:
raise UserError(_("Connection test failed: %s") % result)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Connection to OpenWebUI successful!'),
'sticky': False,
'type': 'success',
}
}
def refresh_model_list(self):
"""Synchronize available AI models with Odoo.
This method performs the following operations based on the selected AI provider:
For OpenWebUI:
1. Verifies that OpenWebUI integration is enabled
2. Connects to the OpenWebUI API to fetch the latest model list
3. Updates the local database with model information
For Ollama:
1. Connects to the Ollama server
2. Fetches available models using the Ollama API
3. Updates the local database with model information
Returns:
dict: A notification action with the sync results
Raises:
UserError: If there are connection or synchronization issues
"""
self.ensure_one()
if self.ai_provider == 'ollama':
return self._refresh_ollama_models()
elif self.ai_provider == 'openwebui':
if not self.openwebui_enabled:
raise UserError(_("OpenWebUI is not enabled for this company."))
return self._refresh_openwebui_models()
else:
raise UserError(_("Unknown AI provider: %s", self.ai_provider))
def _refresh_ollama_models(self):
"""Fetch and sync available models from Ollama server."""
try:
import requests
url = f'http://localhost:{self.ollama_port}/api/tags'
response = requests.get(url, timeout=5)
response.raise_for_status()
models_data = response.json()
# Get the OpenWebUI model object
Model = self.env['openwebui.model'].with_context(sync_models=True)
# Process each model from Ollama
for model in models_data.get('models', []):
name = model.get('name', '')
if not name:
continue
# Split name and tag if present (format: name:tag)
identifier = name
if ':' in name:
name, tag = name.split(':', 1)
identifier = f"{name}:{tag}"
else:
identifier = f"{name}:latest"
# Check if model already exists
existing_model = Model.search([
('identifier', '=', identifier),
('company_id', '=', self.id)
], limit=1)
model_vals = {
'name': name,
'identifier': identifier,
'is_active': True,
'description': f"Ollama model: {name}",
'company_id': self.id
}
if existing_model:
# Update existing model
existing_model.write(model_vals)
else:
# Create new model
Model.create(model_vals)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Successfully synchronized Ollama models'),
'sticky': False,
'type': 'success',
}
}
except requests.exceptions.ConnectionError:
raise UserError(_("Could not connect to Ollama server. Please check if Ollama is running and the port number is correct."))
except requests.exceptions.Timeout:
raise UserError(_("Connection to Ollama server timed out. Please check your network settings."))
except requests.exceptions.RequestException as e:
raise UserError(_("Error connecting to Ollama server: %s", str(e)))
except Exception as e:
raise UserError(_("Error synchronizing Ollama models: %s", str(e)))
def _refresh_openwebui_models(self):
"""Fetch and sync available models from OpenWebUI."""
success, result = self.env['openwebui.model'].sync_models()
if not success:
raise UserError(_("Failed to refresh models: %s") % result)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Successfully synchronized OpenWebUI models'),
'sticky': False,
'type': 'success',
}
}

View file

@ -1,3 +0,0 @@
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,openwebui_integration.group_openwebui_user,1,0,0,0
access_openwebui_model_admin,access_openwebui_model_admin,model_openwebui_model,openwebui_integration.group_openwebui_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_openwebui_model_user access_openwebui_model_user model_openwebui_model openwebui_integration.group_openwebui_user 1 0 0 0
3 access_openwebui_model_admin access_openwebui_model_admin model_openwebui_model openwebui_integration.group_openwebui_admin 1 1 1 1

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Règle de sécurité pour le multi-compagnie -->
</data>
</odoo>

View file

@ -1,36 +0,0 @@
<?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>
</data>
</odoo>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,12 +0,0 @@
.o_openwebui_container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.o_openwebui_content {
flex: 1;
padding: 16px;
overflow: auto;
}

View file

@ -1,30 +0,0 @@
/** @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);

View file

@ -1,26 +0,0 @@
.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); }
}

View file

@ -1,23 +0,0 @@
<?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>

View file

@ -1,98 +0,0 @@
<?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="AI Configuration" name="ai_config">
<group>
<group string="Provider Selection">
<field name="ai_provider"/>
</group>
</group>
<!-- Ollama Configuration -->
<group string="Ollama Configuration" invisible="ai_provider != 'ollama'">
<group>
<field name="ollama_port" required="ai_provider == 'ollama'"/>
<button name="test_ollama_connection"
string="Test Connection"
type="object"
class="btn-primary"
invisible="ai_provider != 'ollama'"/>
</group>
</group>
<!-- OpenWebUI Configuration -->
<group string="OpenWebUI Configuration" invisible="ai_provider != 'openwebui'">
<group>
<field name="openwebui_enabled" required="ai_provider == 'openwebui'"/>
<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"/>
<field name="openwebui_context_size"
invisible="not openwebui_enabled"
help="Size of the context window in tokens (e.g., 2048, 4096, 8192)"/>
<field name="openwebui_products_per_request"
invisible="not openwebui_enabled"
help="Number of products to process in a single API request"/>
<button name="test_openwebui_connection"
string="Test Connection"
type="object"
class="oe_highlight"
invisible="not openwebui_enabled"/>
</group>
</group>
<!-- Ollama Configuration -->
<group string="Ollama Configuration" invisible="ai_provider != 'ollama'">
<group>
<field name="openwebui_api_url"
required="ai_provider == 'ollama'"
placeholder="http://localhost:11434"/>
<field name="openwebui_context_size"
help="Size of the context window in tokens (e.g., 2048, 4096, 8192)"/>
<field name="openwebui_products_per_request"
help="Number of products to process in a single API request"/>
</group>
</group>
<group string="Models Configuration" invisible="not openwebui_enabled">
<field name="openwebui_default_model_id"
options="{'no_create': True, 'no_open': True}"
domain="[('is_active', '=', True)]"/>
</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>

View file

@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import controllers

View file

@ -1,35 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'OpenWebUI Integration Chat',
'version': '1.0',
'summary': 'Chat integration with OpenWebUI for Discuss',
'sequence': 11,
'description': """
OpenWebUI Integration Chat
=========================
This module extends OpenWebUI Integration to provide chatbot features in Discuss.
Features:
---------
* Integrate chatbots with Discuss channels
* Control access to bots by channels and users
* Chat-specific bot configurations and behaviors
""",
'category': 'Discuss',
'website': 'https://www.odoo.com/app/discuss',
'depends': [
'mail',
'openwebui_integration'
],
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'views/discuss_channel_views.xml',
'views/openwebui_bot_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View file

@ -1,6 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import discuss_channel
from . import res_users
from . import openwebui_bot

View file

@ -1,46 +0,0 @@
# -*- 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

View file

@ -1,545 +0,0 @@
# -*- 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)

View file

@ -1,17 +0,0 @@
# -*- 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)

View file

@ -1,3 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_openwebui_bot_user,access_openwebui_bot_user,model_openwebui_bot,openwebui_integration.group_openwebui_user,1,0,0,0
access_openwebui_bot_admin,access_openwebui_bot_admin,model_openwebui_bot,openwebui_integration.group_openwebui_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_openwebui_bot_user access_openwebui_bot_user model_openwebui_bot openwebui_integration.group_openwebui_user 1 0 0 0
3 access_openwebui_bot_admin access_openwebui_bot_admin model_openwebui_bot openwebui_integration.group_openwebui_admin 1 1 1 1

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Règles de sécurité par société -->
<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('openwebui_integration.group_openwebui_user'))]"/>
</record>
</data>
</odoo>

View file

@ -1,61 +0,0 @@
<?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>

View file

@ -1,123 +0,0 @@
# Spécifications du module OpenWebUI Integration Product
## Objectif
Ce module étend les fonctionnalités d'OpenWebUI Integration pour ajouter des capacités d'IA spécifiques à la gestion des produits dans Odoo. Il permet d'automatiser et d'optimiser différents aspects de la gestion des produits grâce à l'intelligence artificielle.
## Architecture du Module
### Structure des Répertoires
```
openwebui_integration_product/
├── controllers/ # Contrôleurs pour les endpoints web spécifiques aux produits
├── models/ # Modèles de données pour les produits et l'historique
├── security/ # Fichiers de sécurité et accès
├── static/ # Ressources statiques (JS, CSS)
└── views/ # Vues XML Odoo pour les produits
```
## Composants Principaux
### 1. Intégration Produit (`product.integration`)
Extension du modèle `product.template` pour ajouter les fonctionnalités d'IA.
#### Fonctionnalités clés:
- Héritage du mixin `openwebui.bot.mixin`
- Gestion des suggestions de catégories
- Historique des suggestions
- Interface utilisateur intégrée
#### Méthodes principales:
- `_get_product_context()`: Prépare le contexte produit pour l'IA
- `suggest_category()`: Déclenche l'analyse IA pour les suggestions
- `apply_suggestion()`: Applique une suggestion validée
- `_process_category_suggestion()`: Traite la réponse de l'IA
### 2. Historique des Suggestions (`category.suggestion.history`)
Gère l'historique des suggestions de catégories.
#### Caractéristiques:
- Traçabilité des suggestions
- Scores de confiance
- Statut des suggestions (appliquée/rejetée)
- Métadonnées de contexte
#### Champs principaux:
- `product_id`: Produit concerné
- `suggested_category_id`: Catégorie suggérée
- `confidence_score`: Score de confiance
- `status`: État de la suggestion
- `applied_date`: Date d'application
- `user_id`: Utilisateur ayant traité la suggestion
### 3. Configuration Spécifique
Extension des paramètres de configuration d'OpenWebUI.
#### Paramètres configurables:
- `product_suggestion_threshold`: Seuil minimum de confiance
- `max_suggestions_per_product`: Nombre maximum de suggestions
- `suggestion_retention_days`: Durée de conservation de l'historique
- `auto_apply_threshold`: Seuil pour application automatique
## Processus de Suggestion de Catégorie
### Flux de Données
1. Collecte des données produit
- Informations de base (nom, description)
- Caractéristiques techniques
- Historique des achats
- Catégories existantes
2. Préparation du Contexte
- Formatage des données
- Ajout du contexte entreprise
- Inclusion des paramètres de configuration
3. Analyse IA
- Envoi à OpenWebUI
- Traitement de la réponse
- Calcul des scores de confiance
4. Validation et Application
- Vérification des seuils
- Présentation à l'utilisateur
- Enregistrement dans l'historique
## Sécurité et Gestion des Erreurs
### Sécurité
- Droits d'accès spécifiques aux produits
- Validation des suggestions
- Protection contre les suggestions malveillantes
- Traçabilité des modifications
### Gestion des Erreurs
- Validation des données produit
- Gestion des timeouts
- Traitement des réponses invalides
- Rollback en cas d'échec
## Intégration et Utilisation
### Installation
1. Installation des dépendances
2. Configuration des paramètres produit
3. Attribution des droits d'accès
4. Test initial des suggestions
### Développement d'Extensions
1. Héritage des modèles existants
2. Ajout de nouveaux critères d'analyse
3. Personnalisation des suggestions
4. Extension des fonctionnalités
## Dépendances
- Module `openwebui_integration`
- Module `product`
- Python 3.x
- Bibliothèques de traitement de texte
## Notes Techniques
- Optimisation des requêtes IA
- Cache des suggestions fréquentes
- Support multi-langue
- Extensible pour d'autres types d'analyses

View file

@ -1,2 +0,0 @@
from . import models
from . import wizard

View file

@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': 'OpenWebUI Integration Product',
'version': '18.0.1.0.1',
'summary': "AI-powered actions integration for Odoo products",
'description': """
OpenWebUI Integration Product Module
====================================
This module extends OpenWebUI Integration to add AI-powered features to product management.
""",
'author': 'Benoît Vézina',
'license': 'LGPL-3',
'website': 'https://www.bemade.org',
'depends': ['product', 'openwebui_integration'],
'data': [
'security/ir.model.access.csv',
'views/product_template_view.xml',
'views/product_template_action.xml',
'wizard/category_suggestion_wizard_view.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View file

@ -1 +0,0 @@
from . import pre-migration

View file

@ -1,17 +0,0 @@
def migrate(cr, version):
"""Add explanation column to product_category_suggestion_history table."""
if not version:
return
# Add explanation column if it doesn't exist
cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='product_category_suggestion_history'
AND column_name='explanation'
""")
if not cr.fetchone():
cr.execute("""
ALTER TABLE product_category_suggestion_history
ADD COLUMN explanation text
""")

View file

@ -1,2 +0,0 @@
from . import product_template
from . import category_suggestion_history

View file

@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class CategorySuggestionHistory(models.Model):
_name = 'product.category.suggestion.history'
_description = 'Category Suggestion History'
_order = 'create_date desc'
product_id = fields.Many2one(
comodel_name='product.template',
string='Product',
required=True,
ondelete='cascade',
help='Product for which the category was suggested'
)
suggested_category_id = fields.Many2one(
comodel_name='product.category',
string='Suggested Category',
required=True,
help='Category suggested by AI analysis'
)
suggestion_confidence = fields.Float(
string='Confidence Score',
help='Confidence score of the suggestion (0-100)'
)
applied = fields.Boolean(
string='Applied',
help='Indicates if the suggestion was applied to the product'
)
suggestion_date = fields.Datetime(
string='Suggestion Date',
readonly=True,
help='Date and time of the suggestion'
)
suggestion_uid = fields.Many2one(
comodel_name='res.users',
string='Created By',
readonly=True,
help='User who triggered the suggestion'
)
input_data = fields.Text(
string='Analyzed Data',
help='Data used by AI to generate the suggestion'
)
explanation = fields.Text(
string='Explanation',
help='Detailed explanation of category choice'
)

View file

@ -1,281 +0,0 @@
# -*- coding: utf-8 -*-
"""OpenWebUI Product Integration Module
This module extends product.template functionality to integrate
OpenWebUI artificial intelligence into product management.
It enables automatic product category suggestions
based on description and characteristics analysis.
Main features:
- AI-powered category suggestions
- Suggestion history tracking
- Integrated user interface
"""
import json
import logging
from datetime import datetime
from odoo import models, fields, _
from odoo.exceptions import UserError
from odoo.tools import float_round
from odoo.addons.openwebui_integration.models.openwebui_bot_mixin import OpenWebUIBotMixin
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model, OpenWebUIBotMixin):
_inherit = "product.template"
suggested_category_id = fields.Many2one(
comodel_name='product.category',
string="Suggested Category",
readonly=True,
help="Category suggested by AI based on product information analysis"
)
suggestion_confidence = fields.Float(
string="Confidence",
readonly=True,
help="Confidence score (0-100) indicating how sure the AI is about the suggested category"
)
suggestion_date = fields.Datetime(
string="Suggestion Date",
readonly=True,
help="Date and time when the category was suggested by the AI"
)
suggestion_history_ids = fields.One2many(
comodel_name='product.category.suggestion.history',
inverse_name='product_id',
string="Suggestion History",
help="History of all category suggestions made by AI for this product"
)
def _generate_bot_message(self, records, values, command=None):
"""Generates the message to send to the bot to get category suggestions."""
# Préparer les données des produits
# Prepare product data for AI analysis
products_data = []
for record in records:
products_data.append({
'odoo_id': record.id, # Unique Odoo product ID
'name': record.name,
'description': record.description or '',
'description_sale': record.description_sale or '',
'default_code': record.default_code or '',
'current_category': record.categ_id.display_name,
'sellers': [
{
'name': seller.partner_id.display_name,
'product_code': seller.product_code or '',
'product_name': seller.product_name or ''
} for seller in record.seller_ids
]
})
# Récupérer toutes les catégories disponibles
Category = self.env['product.category']
categories = Category.search([('parent_id', '!=', False)], order='complete_name')
available_categories = [{
'id': cat.id,
'name': cat.name,
'complete_name': cat.complete_name or cat.name,
'level': len(cat.parent_path.split('/')) - 1 if cat.parent_path else 0
} for cat in categories]
# Construire le message pour l'IA
message = {
'task': 'product_categorization',
'products': products_data,
'available_categories': available_categories,
'instructions': """For each product in the products list, analyze the product information and suggest the most appropriate product category from the available list.
Consider each product's name, description, and supplier information to make the best match.
IMPORTANT: Your response MUST be a JSON object with a 'products' array containing EXACTLY ONE object for EACH product in the input list.
Example format for a list of 2 products:
{
"products": [
{
"odoo_id": 1,
"category_id": 454,
"confidence": 85.0,
"explanation": "The product is categorized as safety equipment because..."
},
{
"odoo_id": 2,
"category_id": 448,
"confidence": 90.0,
"explanation": "This product belongs to vacuum systems because..."
}
]
}
Requirements:
1. Response must be a single JSON object with a 'products' array
2. You MUST return exactly one object in the products array for each product in the input list
3. Each object must have exactly these fields:
- odoo_id (integer): The index of the product in the input list (starting at 1)
- category_id (integer): The ID of the most appropriate category
- confidence (float between 0 and 100): How confident you are about this suggestion
- explanation (string): A detailed explanation of why this category was chosen
4. Do not add any other fields outside of the products array
5. Do not add any markdown formatting or code blocks
6. If you're not sure about a product's category, still provide a suggestion with lower confidence""",
'format': 'json'
}
return json.dumps(message)
def _process_bot_response(self, values, response):
"""Process the bot response to extract the suggested category."""
try:
# Parse the response
if isinstance(response, str):
response_data = json.loads(response)
else:
response_data = response # Already parsed JSON
# Extract products list
if isinstance(response_data, dict) and 'products' in response_data:
results = response_data['products']
elif isinstance(response_data, list):
results = response_data
else:
raise ValueError("La réponse ne contient pas de liste de produits valide")
if not isinstance(results, list):
raise ValueError("La réponse n'est pas une liste JSON valide")
for result in results:
category_id = result.get('category_id')
confidence = result.get('confidence', 0.0)
temp_id = result.get('odoo_id')
if not category_id:
raise ValueError("No category ID in response")
if not temp_id:
raise ValueError("No product ID in response")
# Vérifier que la catégorie existe
category = self.env['product.category'].browse(category_id).exists()
if not category:
raise ValueError(f"Category {category_id} not found")
# Trouver le produit concerné directement par son ID
product = self.filtered(lambda p: p.id == temp_id)
if not product:
raise ValueError(f"Product {temp_id} not found in selection")
# Mettre à jour les valeurs pour ce produit
product.write({
'suggested_category_id': category_id,
'suggestion_confidence': confidence,
'suggestion_date': fields.Datetime.now(),
})
# Créer l'historique
self.env['product.category.suggestion.history'].create({
'product_id': product.id,
'suggested_category_id': category_id,
'suggestion_confidence': confidence,
'input_data': json.dumps(result),
'explanation': result.get('explanation', ''),
'applied': False
})
except Exception as e:
raise UserError(_("Erreur lors du traitement de la réponse de l'IA: %s") % str(e))
return values
def _calculate_optimal_batch_size(self, products, max_chars=2048):
"""Calculate the optimal batch size based on message length limit.
Args:
products: recordset of products to process
max_chars: maximum number of characters allowed (default: 2048)
Returns:
int: optimal number of products to process in one batch
"""
# Test with a small batch first
test_size = 5
test_products = products[:test_size]
test_message = self._generate_bot_message(test_products, {})
# Calculate average characters per product
chars_per_product = len(test_message) / test_size
# Calculate optimal batch size with 10% safety margin
optimal_size = int((max_chars * 0.9) / chars_per_product)
# Ensure batch size is at least 1 and no more than 800 (existing limit)
return max(1, min(optimal_size, 800))
def action_suggest_category(self):
"""Request category suggestions from AI."""
# Get company settings
company = self.env.company
# Dédoublonner les produits
unique_products = self.filtered(lambda p: p.id).sorted(lambda p: p.id)
# Get max products from company settings
max_products = company.openwebui_max_products
if len(unique_products) > max_products:
raise UserError(_("For performance reasons, you cannot analyze more than %d products at once.") % max_products)
# Get company settings
company = self.env.company
if not company.openwebui_enabled:
raise UserError(_("OpenWebUI is not enabled for your company. Please enable it in company settings."))
model = company.openwebui_default_model_id
if not model:
raise UserError(_("No default OpenWebUI model configured. Please configure it in company settings."))
# Calculate optimal batch size
batch_size = self._calculate_optimal_batch_size(unique_products)
_logger.info(f"Processing products with calculated batch size: {batch_size}")
successful_products = self.env['product.template']
products_to_process = unique_products
for i in range(0, len(products_to_process), batch_size):
batch = products_to_process[i:i + batch_size]
# Créer une nouvelle transaction pour ce batch
with self.env.cr.savepoint():
try:
values = {'bot': model}
self._apply_logic(batch, values)
# Si on arrive ici, le batch a réussi
successful_products |= batch
_logger.info('Successfully processed batch of %d products: %s',
len(batch), batch.mapped('default_code'))
except Exception as e:
_logger.error('Batch processing failed for products %s: %s',
batch.mapped('default_code'), str(e))
# Le savepoint sera rollback automatiquement
continue
# Si aucun produit n'a été traité avec succès
if not successful_products:
raise UserError(_('No suggestions could be generated by AI.'))
# Ouvrir l'assistant si des suggestions ont été générées
if any(product.suggested_category_id for product in successful_products):
wizard = self.env['product.category.suggestion.wizard'].create({})
return {
'name': _('Category Suggestions'),
'type': 'ir.actions.act_window',
'res_model': 'product.category.suggestion.wizard',
'res_id': wizard.id,
'view_mode': 'form',
'target': 'new',
'context': self.env.context,
}
else:
raise UserError(_('No valid suggestions could be generated by AI.'))

View file

@ -1 +0,0 @@
../.repos/bemade-addons/openwebui_integration

View file

@ -1 +0,0 @@
../.repos/bemade-addons/openwebui_integration_chat

View file

@ -1,4 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_product_category_suggestion_history_user,product.category.suggestion.history user,model_product_category_suggestion_history,base.group_user,1,1,1,1
access_product_category_suggestion_wizard_user,product.category.suggestion.wizard user,model_product_category_suggestion_wizard,base.group_user,1,1,1,1
access_product_category_suggestion_wizard_line_user,product.category.suggestion.wizard.line user,model_product_category_suggestion_wizard_line,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_product_category_suggestion_history_user product.category.suggestion.history user model_product_category_suggestion_history base.group_user 1 1 1 1
3 access_product_category_suggestion_wizard_user product.category.suggestion.wizard user model_product_category_suggestion_wizard base.group_user 1 1 1 1
4 access_product_category_suggestion_wizard_line_user product.category.suggestion.wizard.line user model_product_category_suggestion_wizard_line base.group_user 1 1 1 1

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Action serveur pour suggérer des catégories -->
<record id="action_suggest_category_multi" model="ir.actions.server">
<field name="name">AI Suggested Category</field>
<field name="model_id" ref="product.model_product_template"/>
<field name="binding_model_id" ref="product.model_product_template"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
action = model.browse(env.context.get('active_ids', [])).action_suggest_category()
</field>
</record>
</odoo>

View file

@ -1,63 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue tree pour l'historique des suggestions -->
<record id="view_category_suggestion_history_tree" model="ir.ui.view">
<field name="name">product.category.suggestion.history.tree</field>
<field name="model">product.category.suggestion.history</field>
<field name="type">list</field>
<field name="arch" type="xml">
<list string="Historique des suggestions" editable="bottom">
<field name="create_date"/>
<field name="suggested_category_id"/>
<field name="suggestion_confidence" widget="percentage"/>
<field name="applied"/>
<field name="create_uid"/>
</list>
</field>
</record>
<!-- Vue héritée du formulaire de produit -->
<record id="product_template_form_view_inherit_openwebui" model="ir.ui.view">
<field name="name">product.template.form.inherit.openwebui</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_suggest_category"
type="object"
class="oe_stat_button"
icon="fa-magic"
help="Suggérer une catégorie basée sur l'IA">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Suggérer</span>
<span class="o_stat_text">Catégorie</span>
</div>
</button>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Suggestions IA" name="ai_suggestions">
<group>
<field name="suggested_category_id"/>
<field name="suggestion_confidence" widget="percentage"/>
<field name="suggestion_date"/>
</group>
<field name="suggestion_history_ids" readonly="1"/>
</page>
</xpath>
</field>
</record>
<!-- Vue héritée de la liste des produits -->
<record id="product_template_tree_view_inherit_openwebui" model="ir.ui.view">
<field name="name">product.template.tree.inherit.openwebui</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_tree_view"/>
<field name="arch" type="xml">
<field name="categ_id" position="after">
<field name="suggested_category_id" optional="show"/>
<field name="suggestion_confidence" widget="percentage" optional="show"/>
</field>
</field>
</record>
</odoo>

View file

@ -1 +0,0 @@
from . import category_suggestion_wizard

View file

@ -1,74 +0,0 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import json
class CategorySuggestionWizard(models.TransientModel):
_name = 'product.category.suggestion.wizard'
_description = 'Assistant de suggestions de catégories'
product_suggestion_ids = fields.One2many('product.category.suggestion.wizard.line', 'wizard_id',
string='Suggestions de produits')
@api.model
def default_get(self, fields):
res = super().default_get(fields)
context = self.env.context
if context.get('active_model') == 'product.template' and context.get('active_ids'):
products = self.env['product.template'].browse(context['active_ids'])
suggestion_lines = []
for product in products:
if product.suggested_category_id:
suggestion_lines.append((0, 0, {
'product_id': product.id,
'current_category_id': product.categ_id.id,
'suggested_category_id': product.suggested_category_id.id,
'confidence': product.suggestion_confidence,
'apply_suggestion': False
}))
res['product_suggestion_ids'] = suggestion_lines
return res
def action_apply_selected_suggestions(self):
"""Applique les suggestions sélectionnées aux produits."""
selected_lines = self.product_suggestion_ids.filtered(lambda l: l.apply_suggestion)
if not selected_lines:
raise UserError(_('Veuillez sélectionner au moins une suggestion à appliquer.'))
for line in selected_lines:
# Mise à jour du produit
line.product_id.write({
'categ_id': line.suggested_category_id.id
})
# Mise à jour de l'historique
history = self.env['product.category.suggestion.history'].search([
('product_id', '=', line.product_id.id),
('suggested_category_id', '=', line.suggested_category_id.id)
], limit=1)
if history:
history.write({'applied': True})
# Message de confirmation
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Succès'),
'message': _('%d catégories ont été mises à jour.') % len(selected_lines),
'type': 'success',
'sticky': False,
}
}
class CategorySuggestionWizardLine(models.TransientModel):
_name = 'product.category.suggestion.wizard.line'
_description = 'Ligne de suggestion de catégorie'
_order = 'confidence desc, product_id'
wizard_id = fields.Many2one('product.category.suggestion.wizard', string='Assistant')
product_id = fields.Many2one('product.template', string='Produit', required=True, readonly=True)
current_category_id = fields.Many2one('product.category', string='Catégorie actuelle', readonly=True)
suggested_category_id = fields.Many2one('product.category', string='Catégorie suggérée', readonly=True)
confidence = fields.Float(string='Confiance (%)', readonly=True)
apply_suggestion = fields.Boolean(string='Appliquer', default=False)

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_category_suggestion_wizard_form" model="ir.ui.view">
<field name="name">product.category.suggestion.wizard.form</field>
<field name="model">product.category.suggestion.wizard</field>
<field name="arch" type="xml">
<form string="Suggestions de Catégories">
<sheet>
<div class="alert alert-info" role="alert">
Sélectionnez les suggestions à appliquer en cochant la case dans la colonne 'Appliquer'.
</div>
<field name="product_suggestion_ids">
<list editable="bottom" create="0" delete="0">
<field name="product_id" readonly="1"/>
<field name="current_category_id" readonly="1"/>
<field name="suggested_category_id" readonly="1"/>
<field name="confidence" widget="percentage" readonly="1"/>
<field name="apply_suggestion" widget="boolean_toggle"/>
</list>
</field>
</sheet>
<footer>
<button string="Appliquer les suggestions sélectionnées"
name="action_apply_selected_suggestions"
type="object"
class="btn-primary"
data-hotkey="q"/>
<button string="Fermer"
class="btn-secondary"
special="cancel"
data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</odoo>