fuck my life with ollama
This commit is contained in:
parent
a3b4d3ed39
commit
b95ca97022
13 changed files with 2253 additions and 158 deletions
1627
openwebui_integration/Docs/OllamaAPI.md
Normal file
1627
openwebui_integration/Docs/OllamaAPI.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': 'OpenWebUI Integration',
|
||||
'version': '18.0.1.0.1',
|
||||
'version': '18.0.1.1.0',
|
||||
'summary': 'Core integration with OpenWebUI',
|
||||
'sequence': 10,
|
||||
'description': """
|
||||
|
|
@ -30,4 +30,5 @@ Features:
|
|||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
'i18n': True,
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
|
|
|
|||
9
openwebui_integration/hooks.py
Normal file
9
openwebui_integration/hooks.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# -*- 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 +0,0 @@
|
|||
from . import pre-migration
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
def migrate(cr, version):
|
||||
"""Add products_per_request column to res_company table."""
|
||||
if not version:
|
||||
return
|
||||
|
||||
# Add products_per_request column if it doesn't exist
|
||||
cr.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='res_company'
|
||||
AND column_name='openwebui_products_per_request'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
cr.execute("""
|
||||
ALTER TABLE res_company
|
||||
ADD COLUMN openwebui_products_per_request integer DEFAULT 10
|
||||
""")
|
||||
|
|
@ -75,13 +75,16 @@ class OpenWebUIBotMixin(models.AbstractModel):
|
|||
# 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('Received response from model: %r', response)
|
||||
_logger.info('Processed response from model: %r', response)
|
||||
|
||||
if not response:
|
||||
raise ValueError("Empty response from model")
|
||||
|
|
@ -89,18 +92,62 @@ class OpenWebUIBotMixin(models.AbstractModel):
|
|||
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
|
||||
# Supprimer les blocs de code markdown et autres caractères problématiques
|
||||
response = re.sub(r'```json\n|\n```', '', response.strip())
|
||||
_logger.debug('Response after markdown cleanup: %r', response)
|
||||
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)
|
||||
|
||||
try:
|
||||
# Essayer de parser directement la réponse nettoyée
|
||||
parsed_response = json.loads(response)
|
||||
# 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
|
||||
except json.JSONDecodeError as e:
|
||||
_logger.warning('Failed to parse response directly: %s', e)
|
||||
# Si échec, chercher une liste JSON dans la réponse
|
||||
# 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(']')
|
||||
|
||||
|
|
@ -111,11 +158,30 @@ class OpenWebUIBotMixin(models.AbstractModel):
|
|||
clean_response = response[start:end + 1]
|
||||
_logger.debug('Extracted JSON list: %r', clean_response)
|
||||
|
||||
try:
|
||||
parsed_response = json.loads(clean_response)
|
||||
except json.JSONDecodeError as e:
|
||||
_logger.error('Failed to parse extracted JSON: %s', e)
|
||||
raise ValueError("Invalid JSON list in 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)
|
||||
|
|
|
|||
|
|
@ -115,32 +115,108 @@ class OpenWebUIModel(models.Model):
|
|||
str: The model's response or error message
|
||||
"""
|
||||
self.ensure_one()
|
||||
company = self.env.company
|
||||
|
||||
# Initialize messages list with history if provided
|
||||
messages = []
|
||||
if message_history:
|
||||
messages.extend(message_history)
|
||||
|
||||
# Add the current message
|
||||
messages.append({'role': 'user', 'content': message})
|
||||
|
||||
data = {
|
||||
'model': self.identifier,
|
||||
'messages': messages,
|
||||
}
|
||||
# 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'
|
||||
|
||||
if context:
|
||||
data['context'] = context
|
||||
if instructions:
|
||||
data['system'] = instructions
|
||||
|
||||
success, result = self._make_request('chat/completions', method='POST', data=data, timeout=timeout)
|
||||
_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:
|
||||
return result['choices'][0]['message']['content']
|
||||
except (KeyError, IndexError) as e:
|
||||
# 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):
|
||||
|
|
@ -217,7 +293,32 @@ class OpenWebUIModel(models.Model):
|
|||
return False, "OpenWebUI is not enabled"
|
||||
|
||||
try:
|
||||
url = f"{config['api_url'].rstrip('/')}/api/{endpoint.lstrip('/')}"
|
||||
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'
|
||||
}
|
||||
|
|
@ -240,10 +341,46 @@ class OpenWebUIModel(models.Model):
|
|||
elif method in ['POST', 'PUT', 'PATCH']:
|
||||
kwargs['json'] = data
|
||||
|
||||
# Send the request
|
||||
response = requests.request(method=method, url=url, **kwargs)
|
||||
response.raise_for_status()
|
||||
|
||||
return True, response.json()
|
||||
# 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"
|
||||
|
|
@ -260,7 +397,7 @@ class OpenWebUIModel(models.Model):
|
|||
def test_connection(self):
|
||||
"""Test the connection to the OpenWebUI API.
|
||||
|
||||
This method performs a simple request to the /api/models endpoint
|
||||
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
|
||||
|
|
@ -274,37 +411,46 @@ class OpenWebUIModel(models.Model):
|
|||
Note:
|
||||
Uses a reduced timeout of 5 seconds to avoid long waits.
|
||||
"""
|
||||
return self._make_request('models', timeout=5)
|
||||
return self._make_request('api/chat', timeout=5)
|
||||
|
||||
@api.model
|
||||
def get_available_models(self):
|
||||
"""Get the list of available models from the OpenWebUI API.
|
||||
"""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: [('gpt-3.5-turbo', 'GPT-3.5 Turbo'), ('gpt-4', 'GPT-4')]
|
||||
Example: [('llama2', 'Llama 2'), ('mistral', 'Mistral')]
|
||||
|
||||
Note:
|
||||
- Handles both possible API response formats (list or dict)
|
||||
- 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
|
||||
"""
|
||||
success, result = self._make_request('models')
|
||||
# Try Ollama endpoint first
|
||||
success, result = self._make_request('api/tags')
|
||||
|
||||
if not success:
|
||||
_logger.error(f"Error fetching models: {result}")
|
||||
return DEFAULT_MODELS
|
||||
# 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:
|
||||
if isinstance(result, list):
|
||||
# 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 OpenWebUI API")
|
||||
_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)}")
|
||||
|
|
@ -327,7 +473,7 @@ class OpenWebUIModel(models.Model):
|
|||
|
||||
@api.model
|
||||
def _sync_models(self):
|
||||
"""Internal method to synchronize models from OpenWebUI.
|
||||
"""Internal method to synchronize models from OpenWebUI/Ollama.
|
||||
|
||||
This method performs the actual synchronization work:
|
||||
1. Cleans up temporary models
|
||||
|
|
@ -342,27 +488,47 @@ class OpenWebUIModel(models.Model):
|
|||
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()
|
||||
|
||||
success, result = self._make_request('models')
|
||||
# Try Ollama endpoint first
|
||||
success, result = self._make_request('api/tags')
|
||||
|
||||
if not success:
|
||||
return False, result
|
||||
# Try OpenWebUI endpoint as fallback
|
||||
success, result = self._make_request('models')
|
||||
if not success:
|
||||
return False, result
|
||||
|
||||
try:
|
||||
models_data = result if isinstance(result, list) else result.get('data', [])
|
||||
for model_data in models_data:
|
||||
existing = self.search([('identifier', '=', model_data['id'])])
|
||||
if not existing:
|
||||
self.with_context(sync_models=True).create({
|
||||
'name': model_data.get('name', model_data['id']),
|
||||
'identifier': model_data['id'],
|
||||
'description': model_data.get('description', ''),
|
||||
})
|
||||
# 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)}")
|
||||
|
|
|
|||
|
|
@ -6,13 +6,34 @@ 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, _
|
||||
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,
|
||||
|
|
@ -41,6 +62,54 @@ class ResCompany(models.Model):
|
|||
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,
|
||||
|
|
@ -59,6 +128,12 @@ class ResCompany(models.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.
|
||||
|
|
@ -108,63 +183,116 @@ class ResCompany(models.Model):
|
|||
}
|
||||
|
||||
def refresh_model_list(self):
|
||||
"""Synchronize available AI models from OpenWebUI with Odoo.
|
||||
"""Synchronize available AI models with Odoo.
|
||||
|
||||
This method performs the following operations:
|
||||
1. Verifies that OpenWebUI integration is enabled for the company
|
||||
2. Connects to the OpenWebUI API to fetch the latest model list
|
||||
3. Updates the local database:
|
||||
- Creates records for new models
|
||||
- Updates existing model information
|
||||
- Archives models that are no longer available
|
||||
This method performs the following operations based on the selected AI provider:
|
||||
|
||||
Technical Details:
|
||||
- Uses the sync_models context to trigger a full synchronization
|
||||
- Performs all operations in a single transaction
|
||||
- Handles API connection errors gracefully
|
||||
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: An action dictionary with the following structure:
|
||||
{
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': str,
|
||||
'message': str,
|
||||
'sticky': bool,
|
||||
'type': str,
|
||||
}
|
||||
}
|
||||
dict: A notification action with the sync results
|
||||
|
||||
Raises:
|
||||
UserError: In the following cases:
|
||||
- OpenWebUI is not enabled for the company
|
||||
- API connection fails
|
||||
- Model synchronization fails
|
||||
|
||||
Example:
|
||||
>>> company = env['res.company'].browse(1)
|
||||
>>> result = company.refresh_model_list()
|
||||
>>> print(result['params']['message'])
|
||||
'Models list has been refreshed successfully!'
|
||||
UserError: If there are connection or synchronization issues
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.openwebui_enabled:
|
||||
raise UserError(_("OpenWebUI is not enabled for this company."))
|
||||
|
||||
Model = self.env['openwebui.model'].with_context(sync_models=True)
|
||||
success, result = Model._sync_models()
|
||||
|
||||
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': _('Models list has been refreshed successfully!'),
|
||||
'message': _('Successfully synchronized OpenWebUI models'),
|
||||
'sticky': False,
|
||||
'type': 'success',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,29 @@
|
|||
<field name="inherit_id" ref="base.view_company_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="OpenWebUI" name="openwebui">
|
||||
<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="openwebui_enabled"/>
|
||||
<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"
|
||||
|
|
@ -24,6 +43,9 @@
|
|||
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"/>
|
||||
|
|
@ -34,6 +56,19 @@
|
|||
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}"
|
||||
|
|
|
|||
|
|
@ -55,21 +55,24 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
|||
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
|
||||
products_data = [{
|
||||
'odoo_id': record.id, # ID interne Odoo
|
||||
'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
|
||||
]
|
||||
} for record in records]
|
||||
# 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']
|
||||
|
|
@ -88,11 +91,37 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
|||
'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.
|
||||
Return a list of JSON objects, one for each product, with:
|
||||
- 'odoo_id' (integer): The internal Odoo ID of the product
|
||||
- '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, including analysis of the product name, description, and other relevant information.""",
|
||||
|
||||
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)
|
||||
|
|
@ -100,19 +129,32 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
|||
def _process_bot_response(self, values, response):
|
||||
"""Process the bot response to extract the suggested category."""
|
||||
try:
|
||||
results = json.loads(response)
|
||||
# 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)
|
||||
product_id = result.get('odoo_id')
|
||||
temp_id = result.get('odoo_id')
|
||||
|
||||
if not category_id:
|
||||
raise ValueError("No category ID in response")
|
||||
|
||||
if not product_id:
|
||||
if not temp_id:
|
||||
raise ValueError("No product ID in response")
|
||||
|
||||
# Vérifier que la catégorie existe
|
||||
|
|
@ -120,10 +162,10 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
|||
if not category:
|
||||
raise ValueError(f"Category {category_id} not found")
|
||||
|
||||
# Trouver le produit concerné
|
||||
product = self.filtered(lambda p: p.id == product_id)
|
||||
# Trouver le produit concerné directement par son ID
|
||||
product = self.filtered(lambda p: p.id == temp_id)
|
||||
if not product:
|
||||
raise ValueError(f"Product {product_id} not found in selection")
|
||||
raise ValueError(f"Product {temp_id} not found in selection")
|
||||
|
||||
# Mettre à jour les valeurs pour ce produit
|
||||
product.write({
|
||||
|
|
@ -134,7 +176,7 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
|||
|
||||
# Créer l'historique
|
||||
self.env['product.category.suggestion.history'].create({
|
||||
'product_id': product_id,
|
||||
'product_id': product.id,
|
||||
'suggested_category_id': category_id,
|
||||
'suggestion_confidence': confidence,
|
||||
'input_data': json.dumps(result),
|
||||
|
|
@ -147,10 +189,43 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
|||
|
||||
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."""
|
||||
if len(self) > 800:
|
||||
raise UserError(_("For performance reasons, you cannot analyze more than 800 products at once."))
|
||||
# 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
|
||||
|
|
@ -161,12 +236,15 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
|||
if not model:
|
||||
raise UserError(_("No default OpenWebUI model configured. Please configure it in company settings."))
|
||||
|
||||
# Process products in batches
|
||||
batch_size = company.openwebui_products_per_request
|
||||
successful_products = self.env['product.template']
|
||||
# Calculate optimal batch size
|
||||
batch_size = self._calculate_optimal_batch_size(unique_products)
|
||||
_logger.info(f"Processing products with calculated batch size: {batch_size}")
|
||||
|
||||
for i in range(0, len(self), batch_size):
|
||||
batch = self[i:i + 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():
|
||||
|
|
|
|||
1
openwebui_integration_product/openwebui_integration
Symbolic link
1
openwebui_integration_product/openwebui_integration
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../.repos/bemade-addons/openwebui_integration
|
||||
1
openwebui_integration_product/openwebui_integration_chat
Symbolic link
1
openwebui_integration_product/openwebui_integration_chat
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../.repos/bemade-addons/openwebui_integration_chat
|
||||
|
|
@ -53,9 +53,10 @@
|
|||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_tree_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//list" position="attributes">
|
||||
<attribute name="action">action_suggest_category_multi</attribute>
|
||||
</xpath>
|
||||
<field name="categ_id" position="after">
|
||||
<field name="suggested_category_id" optional="show"/>
|
||||
<field name="suggestion_confidence" widget="percentage" optional="show"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue