new ai 2
This commit is contained in:
parent
9a130bae6b
commit
0676cf1e7f
68 changed files with 0 additions and 5143 deletions
|
|
@ -1 +0,0 @@
|
|||
from . import models
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
from . import res_config_settings
|
||||
from . import res_company
|
||||
|
|
@ -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-'")
|
||||
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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,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>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
from . import models
|
||||
from . import wizard
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from . import res_partner
|
||||
# from . import partner_purchase_analysis_wizard
|
||||
from . import res_config_settings
|
||||
from . import res_company
|
||||
|
|
@ -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))
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
|
@ -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)]})
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
../.repos/bemade-addons/openai_connector
|
||||
|
|
@ -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,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1 +0,0 @@
|
|||
from . import partner_purchase_analysis_wizard
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
from . import main
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
""")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)}"
|
||||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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>
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
from . import models
|
||||
from . import wizard
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
from . import pre-migration
|
||||
|
|
@ -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
|
||||
""")
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
from . import product_template
|
||||
from . import category_suggestion_history
|
||||
|
|
@ -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'
|
||||
)
|
||||
|
|
@ -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.'))
|
||||
|
|
@ -1 +0,0 @@
|
|||
../.repos/bemade-addons/openwebui_integration
|
||||
|
|
@ -1 +0,0 @@
|
|||
../.repos/bemade-addons/openwebui_integration_chat
|
||||
|
|
@ -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,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1 +0,0 @@
|
|||
from . import category_suggestion_wizard
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue