new ai
This commit is contained in:
parent
e1e8c94c94
commit
9a130bae6b
44 changed files with 4041 additions and 0 deletions
1
ai_integration/__init__.py
Normal file
1
ai_integration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
37
ai_integration/__manifest__.py
Normal file
37
ai_integration/__manifest__.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
'name': 'AI Integration Base',
|
||||
'version': '1.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Base module for AI integration',
|
||||
'description': """
|
||||
AI Integration Base
|
||||
==================
|
||||
This module provides the base framework for integrating various AI providers
|
||||
into Odoo. It includes:
|
||||
|
||||
* Abstract interfaces for AI providers
|
||||
* Base configuration for AI models
|
||||
* Common utilities for AI integration
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'base',
|
||||
'web',
|
||||
],
|
||||
'data': [
|
||||
'security/ai_security.xml',
|
||||
'security/ir_rule.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/menu.xml',
|
||||
'views/res_company_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/ai_provider_instance_views.xml',
|
||||
'views/ai_model_views.xml',
|
||||
'views/ai_model_stats_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
9
ai_integration/models/__init__.py
Normal file
9
ai_integration/models/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from .mixins import ai_mixin
|
||||
from . import ai_generation_params
|
||||
from . import ai_model
|
||||
from . import ai_provider_interface
|
||||
from . import ai_provider
|
||||
from . import ai_provider_instance
|
||||
from . import res_config_settings
|
||||
from . import res_company
|
||||
from . import ai_model_stats
|
||||
36
ai_integration/models/ai_generation_params.py
Normal file
36
ai_integration/models/ai_generation_params.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
class AIGenerationParams(models.AbstractModel):
|
||||
_name = 'ai.generation.params'
|
||||
_description = 'AI Generation Parameters'
|
||||
|
||||
# Base Generation Parameters
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
help='Controls randomness in generation. Higher values make output more random, lower values more deterministic.',
|
||||
default=0.7)
|
||||
|
||||
repeat_penalty = fields.Float(
|
||||
string='Repeat Penalty',
|
||||
help='Penalty for repeating tokens. Higher values make repetition less likely.',
|
||||
default=1.1)
|
||||
|
||||
max_tokens = fields.Integer(
|
||||
string='Max Tokens',
|
||||
help='Maximum number of tokens to generate.',
|
||||
default=2048)
|
||||
|
||||
stop_sequences = fields.Char(
|
||||
string='Stop Sequences',
|
||||
help='Comma-separated list of sequences where generation should stop.',
|
||||
default='')
|
||||
|
||||
frequency_penalty = fields.Float(
|
||||
string='Frequency Penalty',
|
||||
help='Penalty for using frequent tokens. Higher values encourage using less frequent tokens.',
|
||||
default=0.0)
|
||||
|
||||
presence_penalty = fields.Float(
|
||||
string='Presence Penalty',
|
||||
help='Penalty for using tokens already in the text. Higher values encourage using new tokens.',
|
||||
default=0.0)
|
||||
80
ai_integration/models/ai_model.py
Normal file
80
ai_integration/models/ai_model.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AIModel(models.Model):
|
||||
_name = 'ai.model'
|
||||
_description = 'AI Model'
|
||||
_order = 'sequence, name'
|
||||
_check_company = False # Disable automatic company checks
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='Whether this model is active and available for use')
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='Name of the AI model'
|
||||
)
|
||||
|
||||
identifier = fields.Char(
|
||||
string='Identifier',
|
||||
required=True,
|
||||
help='Technical identifier of the model (e.g., gpt-3.5-turbo, mistral-7b)'
|
||||
)
|
||||
|
||||
provider_instance_id = fields.Many2one(
|
||||
'ai.provider.instance',
|
||||
string='Provider Instance',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
help='Provider instance this model belongs to'
|
||||
)
|
||||
|
||||
provider_type = fields.Selection(
|
||||
related='provider_instance_id.provider_type',
|
||||
string='Provider Type',
|
||||
store=True,
|
||||
readonly=True,
|
||||
help='Type of AI provider'
|
||||
)
|
||||
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
help='Description of the model and its capabilities'
|
||||
)
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
help='Sequence for ordering models in lists and dropdowns'
|
||||
)
|
||||
|
||||
is_active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='Whether this model is active'
|
||||
)
|
||||
|
||||
context_window = fields.Integer(
|
||||
string='Context Window',
|
||||
default=2048,
|
||||
help='Maximum number of tokens in the context window'
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_identifier_provider',
|
||||
'unique(identifier, provider_instance_id)',
|
||||
'The model identifier must be unique per provider instance!')
|
||||
]
|
||||
|
||||
def name_get(self):
|
||||
"""Custom name_get to include provider instance in display name."""
|
||||
result = []
|
||||
for model in self:
|
||||
name = f"{model.name} ({model.provider_instance_id.name})"
|
||||
result.append((model.id, name))
|
||||
return result
|
||||
81
ai_integration/models/ai_model_stats.py
Normal file
81
ai_integration/models/ai_model_stats.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from odoo import models, fields, api
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class AIModelStats(models.Model):
|
||||
_name = 'ai.model.stats'
|
||||
_description = 'AI Model Usage Statistics'
|
||||
_order = 'date desc'
|
||||
|
||||
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
|
||||
provider_instance_id = fields.Many2one(
|
||||
related='model_id.provider_instance_id',
|
||||
string='Provider Instance',
|
||||
store=True)
|
||||
|
||||
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
|
||||
request_count = fields.Integer(string='Number of Requests', default=0)
|
||||
token_count = fields.Integer(string='Total Tokens', default=0)
|
||||
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
|
||||
error_count = fields.Integer(string='Number of Errors', default=0)
|
||||
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
|
||||
]
|
||||
|
||||
def _update_stats(self, model, tokens, response_time, error=False, version=None):
|
||||
"""Update statistics for a model."""
|
||||
today = fields.Date.context_today(self)
|
||||
stats = self.search([
|
||||
('model_id', '=', model.id),
|
||||
('date', '=', today)
|
||||
])
|
||||
|
||||
if not stats:
|
||||
stats = self.create({
|
||||
'model_id': model.id,
|
||||
'date': today,
|
||||
'version': version
|
||||
})
|
||||
|
||||
# Update statistics
|
||||
new_count = stats.request_count + 1
|
||||
new_tokens = stats.token_count + tokens
|
||||
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
|
||||
new_errors = stats.error_count + (1 if error else 0)
|
||||
|
||||
stats.write({
|
||||
'request_count': new_count,
|
||||
'token_count': new_tokens,
|
||||
'avg_response_time': new_time,
|
||||
'error_count': new_errors,
|
||||
'version': version or stats.version # Update version if provided
|
||||
})
|
||||
|
||||
@api.model
|
||||
def get_model_stats(self, model_id, days=30):
|
||||
"""Get statistics for a model over the specified number of days."""
|
||||
start_date = fields.Date.today() - timedelta(days=days)
|
||||
stats = self.search([
|
||||
('model_id', '=', model_id),
|
||||
('date', '>=', start_date)
|
||||
])
|
||||
|
||||
return {
|
||||
'daily_stats': [{
|
||||
'date': stat.date,
|
||||
'requests': stat.request_count,
|
||||
'tokens': stat.token_count,
|
||||
'response_time': stat.avg_response_time,
|
||||
'errors': stat.error_count,
|
||||
'version': stat.version
|
||||
} for stat in stats],
|
||||
'summary': {
|
||||
'total_requests': sum(stat.request_count for stat in stats),
|
||||
'total_tokens': sum(stat.token_count for stat in stats),
|
||||
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
|
||||
(sum(stat.request_count for stat in stats) if stats else 1),
|
||||
'total_errors': sum(stat.error_count for stat in stats),
|
||||
'versions_used': list(set(stat.version for stat in stats if stat.version))
|
||||
}
|
||||
}
|
||||
73
ai_integration/models/ai_provider.py
Normal file
73
ai_integration/models/ai_provider.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIProvider(models.Model):
|
||||
_name = 'ai.provider'
|
||||
_description = 'AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(string='Code', required=True)
|
||||
description = fields.Text(string='Description')
|
||||
default_host = fields.Char(string='Default Host')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
@api.model
|
||||
def send_message(self, message, **kwargs):
|
||||
"""Send a message to the AI provider and get a response.
|
||||
|
||||
Args:
|
||||
message (dict): The message to send
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
str: The response from the AI provider
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method send_message must be implemented by specific AI providers"))
|
||||
|
||||
@api.model
|
||||
def get_models(self):
|
||||
"""Get the list of available models from the provider.
|
||||
|
||||
Returns:
|
||||
list: List of model information dictionaries
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method get_models must be implemented by specific AI providers"))
|
||||
|
||||
@api.model
|
||||
def test_connection(self):
|
||||
"""Test the connection to the AI provider.
|
||||
|
||||
Returns:
|
||||
bool: True if connection is successful
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method test_connection must be implemented by specific AI providers"))
|
||||
|
||||
def _handle_error(self, error, context=''):
|
||||
"""Common error handling for AI provider operations.
|
||||
|
||||
Args:
|
||||
error (Exception): The error that occurred
|
||||
context (str): Additional context about where the error occurred
|
||||
|
||||
Returns:
|
||||
tuple: (success, message)
|
||||
"""
|
||||
error_msg = str(error)
|
||||
log_msg = f"AI Provider Error{' in ' + context if context else ''}: {error_msg}"
|
||||
_logger.error(log_msg)
|
||||
return False, error_msg
|
||||
101
ai_integration/models/ai_provider_instance.py
Normal file
101
ai_integration/models/ai_provider_instance.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AIProviderInstance(models.Model):
|
||||
_name = 'ai.provider.instance'
|
||||
_description = 'AI Provider Instance'
|
||||
_order = 'name'
|
||||
_check_company = False # Disable automatic company checks
|
||||
_inherit = ['ai.generation.params']
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='Whether this provider instance is active and available for use')
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='Name of this provider instance (e.g., "OpenWebUI Production", "Ollama Local")'
|
||||
)
|
||||
|
||||
provider_id = fields.Many2one(
|
||||
'ai.provider',
|
||||
string='Provider',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
help='The AI provider configuration'
|
||||
)
|
||||
|
||||
provider_type = fields.Selection(
|
||||
selection=[
|
||||
('none', 'None') # Base selection, will be extended by provider modules
|
||||
],
|
||||
string='Provider Type',
|
||||
required=True,
|
||||
ondelete={'none': 'set default'},
|
||||
default='none',
|
||||
help='Type of AI provider'
|
||||
)
|
||||
|
||||
host = fields.Char(
|
||||
string='Host',
|
||||
required=True,
|
||||
help='Host address (e.g., "http://localhost:8080" or "https://api.example.com")'
|
||||
)
|
||||
|
||||
api_key = fields.Char(
|
||||
string='API Key',
|
||||
help='API key if required by the provider'
|
||||
)
|
||||
|
||||
|
||||
@api.onchange('provider_id')
|
||||
def _onchange_provider_id(self):
|
||||
if self.provider_id:
|
||||
self.provider_type = self.provider_id.code
|
||||
|
||||
model_ids = fields.One2many(
|
||||
'ai.model',
|
||||
'provider_instance_id',
|
||||
copy=True,
|
||||
string='Available Models'
|
||||
)
|
||||
|
||||
timeout = fields.Integer(
|
||||
string='Timeout',
|
||||
default=60,
|
||||
help='Maximum wait time for API calls (in seconds)'
|
||||
)
|
||||
|
||||
max_retries = fields.Integer(
|
||||
string='Max Retries',
|
||||
default=3,
|
||||
help='Maximum number of retry attempts for failed API calls'
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq',
|
||||
'unique(name)',
|
||||
'Provider instance name must be unique!')
|
||||
]
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to this provider instance."""
|
||||
self.ensure_one()
|
||||
provider_model = f'ai.provider.{self.provider_type}'
|
||||
if provider_model not in self.env:
|
||||
raise UserError(_("Provider type %s is not installed", self.provider_type))
|
||||
|
||||
return self.env[provider_model].test_connection(self)
|
||||
|
||||
def sync_models(self):
|
||||
"""Synchronize models from this provider instance."""
|
||||
self.ensure_one()
|
||||
provider_model = f'ai.provider.{self.provider_type}'
|
||||
if provider_model not in self.env:
|
||||
raise UserError(_("Provider type %s is not installed", self.provider_type))
|
||||
|
||||
return self.env[provider_model].sync_models(self)
|
||||
55
ai_integration/models/ai_provider_interface.py
Normal file
55
ai_integration/models/ai_provider_interface.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIProviderInterface(models.AbstractModel):
|
||||
_name = 'ai.provider.interface'
|
||||
_description = 'AI Provider Interface'
|
||||
|
||||
@api.model
|
||||
def send_message(self, message, **kwargs):
|
||||
"""Send a message to the AI provider and get a response.
|
||||
|
||||
Args:
|
||||
message (dict): The message to send
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
dict: The response from the AI provider
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_provider_type(self):
|
||||
"""Get the provider type code.
|
||||
|
||||
Returns:
|
||||
str: The provider type code
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model.
|
||||
|
||||
Args:
|
||||
instance (ai.provider.instance): The provider instance
|
||||
model_name (str): Name of the model
|
||||
|
||||
Returns:
|
||||
dict: Model information
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _list_models(self, instance):
|
||||
"""List available models for this provider instance.
|
||||
|
||||
Args:
|
||||
instance (ai.provider.instance): The provider instance
|
||||
|
||||
Returns:
|
||||
list: List of available models
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
2
ai_integration/models/mixins/__init__.py
Normal file
2
ai_integration/models/mixins/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import ai_mixin
|
||||
from . import ai_generation_params
|
||||
64
ai_integration/models/mixins/ai_generation_params.py
Normal file
64
ai_integration/models/mixins/ai_generation_params.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
class AIGenerationParams(models.AbstractModel):
|
||||
"""Mixin for common AI generation parameters across different providers."""
|
||||
_name = 'ai.generation.params'
|
||||
_description = 'Common AI Generation Parameters'
|
||||
|
||||
# Basic Generation Parameters
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
help='Sampling temperature. Range: [0.0 - 2.0]. Higher values make output more random, lower values more deterministic.',
|
||||
default=0.7,
|
||||
digits=(3, 2))
|
||||
|
||||
top_p = fields.Float(
|
||||
string='Top P',
|
||||
help='Nucleus sampling: limits cumulative probability of tokens to sample from. Range: [0.0 - 1.0].',
|
||||
default=0.9,
|
||||
digits=(3, 2))
|
||||
|
||||
max_tokens = fields.Integer(
|
||||
string='Max Tokens',
|
||||
help='Maximum number of tokens to generate. Range: [1 - 32768].',
|
||||
default=2048)
|
||||
|
||||
stop_sequences = fields.Char(
|
||||
string='Stop Sequences',
|
||||
help='Comma-separated list of sequences where the model should stop generating')
|
||||
|
||||
# System Settings
|
||||
timeout = fields.Integer(
|
||||
string='Timeout',
|
||||
help='Request timeout in seconds. Range: [1 - 300].',
|
||||
default=30)
|
||||
|
||||
retry_count = fields.Integer(
|
||||
string='Retry Count',
|
||||
help='Number of times to retry failed requests. Range: [0 - 5].',
|
||||
default=3)
|
||||
|
||||
stream_response = fields.Boolean(
|
||||
string='Stream Response',
|
||||
help='Enable response streaming for real-time output.',
|
||||
default=False)
|
||||
|
||||
def _get_base_generation_params(self):
|
||||
"""Get common generation parameters as a dictionary."""
|
||||
self.ensure_one()
|
||||
|
||||
params = {
|
||||
'temperature': self.temperature,
|
||||
'top_p': self.top_p,
|
||||
'max_tokens': self.max_tokens,
|
||||
'stream': self.stream_response,
|
||||
}
|
||||
|
||||
if self.stop_sequences:
|
||||
params['stop'] = [
|
||||
seq.strip()
|
||||
for seq in self.stop_sequences.split(',')
|
||||
]
|
||||
|
||||
return params
|
||||
150
ai_integration/models/mixins/ai_mixin.py
Normal file
150
ai_integration/models/mixins/ai_mixin.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from odoo import models, api, fields, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIMixin(models.AbstractModel):
|
||||
_name = 'ai.mixin'
|
||||
_description = 'AI Integration Mixin'
|
||||
|
||||
def _get_ai_provider_instance(self, provider_instance_id=None):
|
||||
"""Get the AI provider instance to use.
|
||||
|
||||
Args:
|
||||
provider_instance_id: Optional specific provider instance to use
|
||||
|
||||
Returns:
|
||||
ai.provider.instance: The provider instance to use
|
||||
|
||||
Raises:
|
||||
UserError: If no provider instance is configured or available
|
||||
"""
|
||||
if provider_instance_id:
|
||||
instance = self.env['ai.provider.instance'].browse(provider_instance_id)
|
||||
if not instance.exists():
|
||||
raise UserError(_("Invalid provider instance"))
|
||||
else:
|
||||
provider_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_provider_instance_id')
|
||||
if not provider_id:
|
||||
raise UserError(_("No default AI provider instance configured"))
|
||||
instance = self.env['ai.provider.instance'].browse(int(provider_id))
|
||||
if not instance.exists():
|
||||
raise UserError(_("Default provider instance not found"))
|
||||
|
||||
if not instance.is_active:
|
||||
raise UserError(_("The selected AI provider instance is not active"))
|
||||
|
||||
return instance
|
||||
|
||||
def _get_ai_model(self, model_id=None, provider_instance=None):
|
||||
"""Get the AI model to use.
|
||||
|
||||
Args:
|
||||
model_id: Optional specific model to use
|
||||
provider_instance: Optional provider instance (to avoid duplicate lookup)
|
||||
|
||||
Returns:
|
||||
ai.model: The model to use
|
||||
|
||||
Raises:
|
||||
UserError: If no model is configured or available
|
||||
"""
|
||||
provider_instance = provider_instance or self._get_ai_provider_instance()
|
||||
|
||||
if model_id:
|
||||
model = self.env['ai.model'].browse(model_id)
|
||||
if not model.exists():
|
||||
raise UserError(_("Invalid AI model"))
|
||||
if model.provider_instance_id != provider_instance:
|
||||
raise UserError(_("The specified model does not belong to the selected provider instance"))
|
||||
else:
|
||||
model_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_model_id')
|
||||
if not model_id:
|
||||
raise UserError(_("No default AI model configured"))
|
||||
model = self.env['ai.model'].browse(int(model_id))
|
||||
if not model.exists():
|
||||
raise UserError(_("Default AI model not found"))
|
||||
|
||||
if not model.is_active:
|
||||
raise UserError(_("The selected AI model is not active"))
|
||||
|
||||
return model
|
||||
|
||||
def send_ai_message(self, message: Dict[str, Any], provider_instance_id: Optional[int] = None,
|
||||
model_id: Optional[int] = None, **kwargs) -> str:
|
||||
"""Send a message to an AI provider instance.
|
||||
|
||||
Args:
|
||||
message: The message to send
|
||||
provider_instance_id: Optional specific provider instance to use
|
||||
model_id: Optional specific model to use
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
str: The response from the AI provider
|
||||
|
||||
Raises:
|
||||
UserError: If there's an error with the AI provider
|
||||
"""
|
||||
try:
|
||||
instance = self._get_ai_provider_instance(provider_instance_id)
|
||||
model = self._get_ai_model(model_id, instance)
|
||||
return instance.send_message(message, model, **kwargs)
|
||||
except Exception as e:
|
||||
_logger.error("Error sending AI message: %s", str(e))
|
||||
raise UserError(_("Error communicating with AI provider: %s", str(e)))
|
||||
|
||||
def process_batch_ai(self, items: List[Any], processor_func: callable,
|
||||
provider_instance_id: Optional[int] = None,
|
||||
model_id: Optional[int] = None, **kwargs) -> List[Any]:
|
||||
"""Process a batch of items using AI.
|
||||
|
||||
Args:
|
||||
items: List of items to process
|
||||
processor_func: Function that processes each item and returns AI message
|
||||
provider_instance_id: Optional specific provider instance to use
|
||||
model_id: Optional specific model to use
|
||||
**kwargs: Additional parameters passed to processor_func
|
||||
|
||||
Returns:
|
||||
List[Any]: List of processed results
|
||||
|
||||
Example:
|
||||
def _process_item(item, **kwargs):
|
||||
return {'role': 'user', 'content': f'Analyze: {item.name}'}
|
||||
|
||||
results = self.process_batch_ai(items, _process_item)
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
company = self.env.company
|
||||
batch_size = company.ai_batch_size or 10
|
||||
results = []
|
||||
|
||||
for i in range(0, len(items), batch_size):
|
||||
batch = items[i:i + batch_size]
|
||||
batch_messages = [processor_func(item, **kwargs) for item in batch]
|
||||
|
||||
for message in batch_messages:
|
||||
result = self.send_ai_message(
|
||||
message,
|
||||
provider_instance_id=provider_instance_id,
|
||||
model_id=model_id
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
def _prepare_ai_message(self, **kwargs):
|
||||
"""Prepare a message to send to the AI provider.
|
||||
This method should be implemented by models using this mixin.
|
||||
|
||||
Returns:
|
||||
dict: The prepared message
|
||||
"""
|
||||
raise NotImplementedError(_("Method _prepare_ai_message must be implemented by models using AI mixin"))
|
||||
21
ai_integration/models/res_company.py
Normal file
21
ai_integration/models/res_company.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
def _get_default_provider_instance(self):
|
||||
"""Get the default AI provider instance from config parameters"""
|
||||
provider_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_provider_instance_id')
|
||||
return self.env['ai.provider.instance'].browse(int(provider_id)) if provider_id else False
|
||||
|
||||
def _get_default_model(self):
|
||||
"""Get the default AI model from config parameters"""
|
||||
model_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_model_id')
|
||||
return self.env['ai.model'].browse(int(model_id)) if model_id else False
|
||||
|
||||
def _get_ai_batch_size(self):
|
||||
"""Get the AI batch size from config parameters"""
|
||||
return int(self.env['ir.config_parameter'].sudo().get_param('ai_integration.ai_batch_size', '100'))
|
||||
28
ai_integration/models/res_config_settings.py
Normal file
28
ai_integration/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from odoo import api, fields, models
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
default_provider_instance_id = fields.Many2one(
|
||||
'ai.provider.instance',
|
||||
string='Default AI Provider Instance',
|
||||
config_parameter='ai_integration.default_provider_instance_id',
|
||||
default_model='ai.provider.instance')
|
||||
|
||||
default_model_id = fields.Many2one(
|
||||
'ai.model',
|
||||
string='Default AI Model',
|
||||
config_parameter='ai_integration.default_model_id',
|
||||
default_model='ai.model')
|
||||
|
||||
ai_batch_size = fields.Integer(
|
||||
string='AI Batch Processing Size',
|
||||
config_parameter='ai_integration.ai_batch_size',
|
||||
default=100,
|
||||
default_model='ai.provider.instance')
|
||||
|
||||
@api.onchange('default_provider_instance_id')
|
||||
def _onchange_default_provider_instance(self):
|
||||
"""Reset model when provider instance changes"""
|
||||
if self.default_provider_instance_id:
|
||||
self.default_model_id = False
|
||||
20
ai_integration/security/ai_security.xml
Normal file
20
ai_integration/security/ai_security.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- AI User Group -->
|
||||
<record id="group_ai_user" model="res.groups">
|
||||
<field name="name">AI User</field>
|
||||
<field name="category_id" ref="base.module_category_services"/>
|
||||
<field name="comment">Users can use AI services but cannot configure them.</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Manager Group -->
|
||||
<record id="group_ai_manager" model="res.groups">
|
||||
<field name="name">AI Manager</field>
|
||||
<field name="category_id" ref="base.module_category_services"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_ai_user'))]"/>
|
||||
<field name="comment">Full access to AI configuration and usage.</field>
|
||||
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
9
ai_integration/security/ir.model.access.csv
Normal file
9
ai_integration/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ai_model_user,ai.model.user,model_ai_model,base.group_user,1,0,0,0
|
||||
access_ai_model_system,ai.model.system,model_ai_model,base.group_system,1,1,1,1
|
||||
access_ai_provider_instance_user,ai.provider.instance.user,model_ai_provider_instance,base.group_user,1,0,0,0
|
||||
access_ai_provider_instance_system,ai.provider.instance.system,model_ai_provider_instance,base.group_system,1,1,1,1
|
||||
access_ai_model_stats_user,ai.model.stats.user,model_ai_model_stats,base.group_user,1,0,0,0
|
||||
access_ai_model_stats_system,ai.model.stats.system,model_ai_model_stats,base.group_system,1,1,1,1
|
||||
access_ai_generation_params_user,ai.generation.params.user,model_ai_generation_params,base.group_user,1,0,0,0
|
||||
access_ai_generation_params_system,ai.generation.params.system,model_ai_generation_params,base.group_system,1,1,1,1
|
||||
|
28
ai_integration/security/ir_rule.xml
Normal file
28
ai_integration/security/ir_rule.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- Global Provider Instance Rule -->
|
||||
<record id="ai_provider_instance_global_rule" model="ir.rule">
|
||||
<field name="name">AI Provider Instance: Global Access</field>
|
||||
<field name="model_id" ref="model_ai_provider_instance"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Global Model Rule -->
|
||||
<record id="ai_model_global_rule" model="ir.rule">
|
||||
<field name="name">AI Model: Global Access</field>
|
||||
<field name="model_id" ref="model_ai_model"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
123
ai_integration/views/ai_model_stats_views.xml
Normal file
123
ai_integration/views/ai_model_stats_views.xml
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="view_ai_model_stats_list" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.list</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Model Statistics">
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_ai_model_stats_form" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.form</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Model Statistics">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_ai_model_stats_search" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.search</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Model Statistics">
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
<field name="date"/>
|
||||
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
|
||||
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
|
||||
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
|
||||
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Graph View -->
|
||||
<record id="view_ai_model_stats_graph" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.graph</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Model Statistics" type="line" sample="1">
|
||||
<field name="date" type="row"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Pivot View -->
|
||||
<record id="view_ai_model_stats_pivot" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.pivot</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Model Statistics" sample="1">
|
||||
<field name="model_id" type="row"/>
|
||||
<field name="provider_instance_id" type="row"/>
|
||||
<field name="date" type="col"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="avg_response_time" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ai_model_stats" model="ir.actions.act_window">
|
||||
<field name="name">Model Statistics</field>
|
||||
<field name="res_model">ai.model.stats</field>
|
||||
<field name="view_mode">list,form,graph,pivot</field>
|
||||
<field name="context">{'search_default_last_month': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No statistics recorded yet
|
||||
</p>
|
||||
<p>
|
||||
Statistics will be automatically recorded as you use your AI models.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_ai_model_stats"
|
||||
name="Model Statistics"
|
||||
parent="menu_ai_integration"
|
||||
action="action_ai_model_stats"
|
||||
sequence="30"/>
|
||||
</odoo>
|
||||
110
ai_integration/views/ai_model_views.xml
Normal file
110
ai_integration/views/ai_model_views.xml
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="ai_model_view_list" model="ir.ui.view">
|
||||
<field name="name">ai.model.list</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Models">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="ai_model_view_form" model="ir.ui.view">
|
||||
<field name="name">ai.model.form</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Model">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. GPT-3.5 Turbo"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="provider_instance_id" options="{'no_create': True}"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" placeholder="Enter a description of the model and its capabilities..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ai_model_view_search" model="ir.ui.view">
|
||||
<field name="name">ai.model.search</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Models">
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="provider_type"/>
|
||||
<filter string="Archived" name="inactive" domain="[('is_active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
|
||||
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
|
||||
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="ai_model_action" model="ir.actions.act_window">
|
||||
<field name="name">AI Models</field>
|
||||
<field name="res_model">ai.model</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="ai_model_view_search"/>
|
||||
<field name="context">{'search_default_group_by_provider': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No AI models found
|
||||
</p>
|
||||
<p>
|
||||
AI models will be automatically synchronized when you configure and sync a provider instance.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Items -->
|
||||
<menuitem id="ai_integration_menu_root"
|
||||
name="AI Integration"
|
||||
sequence="50"/>
|
||||
|
||||
<menuitem id="ai_integration_menu_config"
|
||||
name="Configuration"
|
||||
parent="ai_integration_menu_root"
|
||||
sequence="100"/>
|
||||
|
||||
<menuitem id="ai_model_menu"
|
||||
name="AI Models"
|
||||
parent="ai_integration_menu_config"
|
||||
action="ai_model_action"
|
||||
sequence="10"/>
|
||||
</odoo>
|
||||
112
ai_integration/views/ai_provider_instance_views.xml
Normal file
112
ai_integration/views/ai_provider_instance_views.xml
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="ai_provider_instance_view_list" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.list</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Provider Instances">
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="ai_provider_instance_view_form" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.form</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Provider Instance">
|
||||
<header>
|
||||
<button name="test_connection"
|
||||
string="Test Connection"
|
||||
type="object"
|
||||
class="oe_highlight"/>
|
||||
<button name="sync_models"
|
||||
string="Sync Models"
|
||||
type="object"
|
||||
class="btn-primary"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. OpenWebUI Production"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="api_key" password="True"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="timeout"/>
|
||||
<field name="max_retries"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Models" name="models">
|
||||
<field name="model_ids" nolabel="1">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ai_provider_instance_view_search" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.search</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Provider Instances">
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="ai_provider_instance_action" model="ir.actions.act_window">
|
||||
<field name="name">AI Provider Instances</field>
|
||||
<field name="res_model">ai.provider.instance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="ai_provider_instance_view_search"/>
|
||||
<field name="context">{'company_id': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first AI provider instance
|
||||
</p>
|
||||
<p>
|
||||
Configure AI provider instances to connect to different AI services.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="ai_provider_instance_menu"
|
||||
name="Provider Instances"
|
||||
parent="ai_integration_menu_config"
|
||||
action="ai_provider_instance_action"
|
||||
sequence="5"/>
|
||||
</odoo>
|
||||
14
ai_integration/views/menu.xml
Normal file
14
ai_integration/views/menu.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Top level menu -->
|
||||
<menuitem id="menu_ai_integration"
|
||||
name="AI Integration"
|
||||
web_icon="ai_integration,static/description/icon.png"
|
||||
sequence="50"/>
|
||||
|
||||
<!-- Configuration menu -->
|
||||
<menuitem id="ai_integration_menu_config"
|
||||
name="Configuration"
|
||||
parent="menu_ai_integration"
|
||||
sequence="100"/>
|
||||
</odoo>
|
||||
19
ai_integration/views/res_company_views.xml
Normal file
19
ai_integration/views/res_company_views.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add AI Integration to Settings Menu -->
|
||||
<record id="action_ai_integration_configuration" model="ir.actions.act_window">
|
||||
<field name="name">AI Integration</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">res.config.settings</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">inline</field>
|
||||
<field name="context">{'module': 'ai_integration'}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_ai_integration_configuration"
|
||||
name="Settings"
|
||||
parent="ai_integration_menu_config"
|
||||
action="action_ai_integration_configuration"
|
||||
sequence="0"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
56
ai_integration/views/res_config_settings_views.xml
Normal file
56
ai_integration/views/res_config_settings_views.xml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.ai.integration</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<div class="app_settings_block" data-string="AI Integration" string="AI Integration" data-key="ai_integration">
|
||||
<h2>AI Integration</h2>
|
||||
<div class="row mt16 o_settings_container" name="ai_integration_setting_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="default_provider_settings">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Default Provider Configuration</span>
|
||||
<div class="text-muted">
|
||||
Configure the default AI provider instance and model for this company
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16 row">
|
||||
<label for="default_provider_instance_id" class="col-lg-3 o_light_label"/>
|
||||
<field name="default_provider_instance_id" options="{'no_create': True}"/>
|
||||
</div>
|
||||
<div class="mt16 row">
|
||||
<label for="default_model_id" class="col-lg-3 o_light_label"/>
|
||||
<field name="default_model_id"
|
||||
options="{'no_create': True}"
|
||||
invisible="default_provider_instance_id == False"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="batch_processing_settings">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Batch Processing</span>
|
||||
<div class="text-muted">
|
||||
Configure batch processing parameters for AI operations
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16 row">
|
||||
<label for="ai_batch_size" class="col-lg-3 o_light_label"/>
|
||||
<field name="ai_batch_size"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
chatgpt_ai_integration/__init__.py
Normal file
1
chatgpt_ai_integration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
35
chatgpt_ai_integration/__manifest__.py
Normal file
35
chatgpt_ai_integration/__manifest__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
'name': 'ChatGPT-Compatible AI Integration',
|
||||
'version': '1.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Integration for ChatGPT-compatible AI providers',
|
||||
'description': """
|
||||
ChatGPT-Compatible AI Integration
|
||||
================================
|
||||
This module provides integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc), offering:
|
||||
|
||||
* ChatGPT-compatible provider implementation
|
||||
* Model synchronization
|
||||
* Advanced parameter configuration
|
||||
* Usage statistics tracking
|
||||
* Support for multiple providers using the ChatGPT API format
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'ai_integration',
|
||||
'openwebui_integration'
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/chatgpt_instance_views.xml',
|
||||
'data/chatgpt_provider.xml',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['requests'],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
14
chatgpt_ai_integration/data/chatgpt_provider.xml
Normal file
14
chatgpt_ai_integration/data/chatgpt_provider.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- ChatGPT-Compatible Provider -->
|
||||
<record id="ai_provider_chatgpt" model="ai.provider">
|
||||
<field name="name">ChatGPT-Compatible</field>
|
||||
<field name="code">chatgpt</field>
|
||||
<field name="instance_model">chatgpt.ai.instance</field>
|
||||
<field name="provider_model">ai.provider.chatgpt</field>
|
||||
<field name="description">Integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc).</field>
|
||||
<field name="website">https://platform.openai.com/docs/api-reference/chat</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
2
chatgpt_ai_integration/models/__init__.py
Normal file
2
chatgpt_ai_integration/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import chatgpt_provider
|
||||
from . import chatgpt_instance
|
||||
48
chatgpt_ai_integration/models/chatgpt_instance.py
Normal file
48
chatgpt_ai_integration/models/chatgpt_instance.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
class ChatGPTInstance(models.Model):
|
||||
_name = 'chatgpt.ai.instance'
|
||||
_description = 'ChatGPT-Compatible Instance Configuration'
|
||||
_inherit = ['ai.provider.instance', 'ai.generation.params']
|
||||
|
||||
provider_type = fields.Selection(
|
||||
selection_add=[('chatgpt', 'ChatGPT-Compatible')],
|
||||
ondelete={'chatgpt': 'cascade'})
|
||||
|
||||
# ChatGPT API Parameters
|
||||
|
||||
presence_penalty = fields.Float(
|
||||
string='Presence Penalty',
|
||||
help='Penalty for new tokens based on their presence in text. Range: [-2.0 - 2.0].',
|
||||
default=0.0,
|
||||
digits=(3, 2))
|
||||
|
||||
frequency_penalty = fields.Float(
|
||||
string='Frequency Penalty',
|
||||
help='Penalty for new tokens based on their frequency in text. Range: [-2.0 - 2.0].',
|
||||
default=0.0,
|
||||
digits=(3, 2))
|
||||
|
||||
# Provider Settings
|
||||
|
||||
def _get_provider_options(self):
|
||||
"""Get ChatGPT-compatible options for API calls."""
|
||||
self.ensure_one()
|
||||
|
||||
options = {
|
||||
'temperature': self.temperature,
|
||||
'top_p': self.top_p,
|
||||
'max_tokens': self.max_tokens,
|
||||
'presence_penalty': self.presence_penalty,
|
||||
'frequency_penalty': self.frequency_penalty,
|
||||
'stream': self.stream_response,
|
||||
}
|
||||
|
||||
if self.stop_sequences:
|
||||
options['stop'] = [
|
||||
seq.strip()
|
||||
for seq in self.stop_sequences.split(',')
|
||||
]
|
||||
|
||||
return options
|
||||
163
chatgpt_ai_integration/models/chatgpt_provider.py
Normal file
163
chatgpt_ai_integration/models/chatgpt_provider.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class ChatGPTProvider(models.AbstractModel):
|
||||
_name = 'ai.provider.chatgpt'
|
||||
_description = 'ChatGPT-Compatible AI Provider'
|
||||
_inherit = ['ai.provider']
|
||||
|
||||
def _get_provider_type(self):
|
||||
return 'chatgpt'
|
||||
|
||||
def test_connection(self, instance):
|
||||
"""Test the connection to the OpenWebUI server."""
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/v1/models")
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Failed to connect to AI server. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to connect to AI server: %s", str(e)
|
||||
))
|
||||
|
||||
def sync_models(self, instance):
|
||||
"""Synchronize available models from the OpenWebUI server."""
|
||||
self.test_connection(instance)
|
||||
|
||||
try:
|
||||
# Get models from provider
|
||||
response = requests.get(f"{instance.host}/api/v1/models")
|
||||
models_data = response.json()
|
||||
|
||||
# Get existing models for this instance
|
||||
existing_models = self.env['ai.model'].search([
|
||||
('provider_instance_id', '=', instance.id)
|
||||
])
|
||||
existing_identifiers = {m.identifier: m for m in existing_models}
|
||||
|
||||
for model_data in models_data:
|
||||
identifier = model_data.get('id')
|
||||
if not identifier:
|
||||
continue
|
||||
|
||||
# Get model details
|
||||
model_info = self._get_model_info(instance, identifier)
|
||||
model_details = model_info.get('details', {})
|
||||
|
||||
model_values = {
|
||||
'name': model_data.get('name', identifier),
|
||||
'identifier': identifier,
|
||||
'description': model_details.get('description', ''),
|
||||
'version': model_details.get('version', ''),
|
||||
'provider_instance_id': instance.id,
|
||||
}
|
||||
|
||||
if identifier in existing_identifiers:
|
||||
# Update existing model
|
||||
existing_identifiers[identifier].write(model_values)
|
||||
else:
|
||||
# Create new model
|
||||
self.env['ai.model'].create(model_values)
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error synchronizing models: %s") % str(e))
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{instance.host}/api/v1/models/{model_name}/info"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
_logger.error(
|
||||
"Failed to get model info for %s. Status: %s, Error: %s",
|
||||
model_name, response.status_code, response.text
|
||||
)
|
||||
return {}
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Error getting model info: %s", str(e))
|
||||
return {}
|
||||
|
||||
def _format_chat_messages(self, messages):
|
||||
"""Format chat messages for OpenWebUI API."""
|
||||
formatted_messages = []
|
||||
for message in messages:
|
||||
role = message.get('role', 'user')
|
||||
content = message.get('content', '')
|
||||
|
||||
formatted_messages.append({
|
||||
'role': role,
|
||||
'content': content
|
||||
})
|
||||
|
||||
return formatted_messages
|
||||
|
||||
def generate_response(self, instance, model, messages, **kwargs):
|
||||
"""Generate a response using the chat completion API."""
|
||||
try:
|
||||
# Format messages for OpenWebUI
|
||||
formatted_messages = self._format_chat_messages(messages)
|
||||
|
||||
# Get model options from instance
|
||||
options = instance._get_provider_options()
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'messages': formatted_messages,
|
||||
**options
|
||||
}
|
||||
|
||||
# Make the API call
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/v1/chat/completions",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to generate response: %s") % response.text)
|
||||
|
||||
response_data = response.json()
|
||||
generated_text = response_data.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
|
||||
# Update statistics
|
||||
total_tokens = response_data.get('usage', {}).get('total_tokens', 0)
|
||||
response_time = response_data.get('response_ms', 0)
|
||||
version = self._get_model_info(instance, model.identifier)\
|
||||
.get('details', {}).get('version', '')
|
||||
|
||||
self._track_model_usage(
|
||||
model, total_tokens, response_time, version=version
|
||||
)
|
||||
|
||||
# Return the response in a standardized format
|
||||
return {
|
||||
'content': generated_text,
|
||||
'role': 'assistant',
|
||||
'metadata': {
|
||||
'total_tokens': total_tokens,
|
||||
'response_time': response_time,
|
||||
'model_version': version,
|
||||
'usage': response_data.get('usage', {})
|
||||
}
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Log error in statistics
|
||||
if model:
|
||||
self._track_model_usage(model, error=True)
|
||||
raise UserError(_("Error generating response: %s") % str(e))
|
||||
3
chatgpt_ai_integration/security/ir.model.access.csv
Normal file
3
chatgpt_ai_integration/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_openwebui_ai_instance_user,openwebui.ai.instance user,model_openwebui_ai_instance,base.group_user,1,0,0,0
|
||||
access_openwebui_ai_instance_system,openwebui.ai.instance system,model_openwebui_ai_instance,base.group_system,1,1,1,1
|
||||
|
106
chatgpt_ai_integration/views/chatgpt_instance_views.xml
Normal file
106
chatgpt_ai_integration/views/chatgpt_instance_views.xml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form View -->
|
||||
<record id="view_chatgpt_ai_instance_form" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.form</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="ChatGPT-Compatible Instance">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Production OpenWebUI/ChatGPT"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Connection">
|
||||
<field name="host" placeholder="e.g. http://localhost:8080"/>
|
||||
<field name="api_key"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="is_default"/>
|
||||
</group>
|
||||
<group string="Basic Generation">
|
||||
<field name="temperature"/>
|
||||
<field name="top_p"/>
|
||||
<field name="max_tokens"/>
|
||||
<field name="presence_penalty"/>
|
||||
<field name="frequency_penalty"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Advanced Settings">
|
||||
<group>
|
||||
<field name="stop_sequences" placeholder="e.g. END,STOP,DONE"/>
|
||||
<field name="timeout"/>
|
||||
<field name="retry_count"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="stream_response"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Models" attrs="{'invisible': [('id', '=', False)]}">
|
||||
<field name="model_ids" nolabel="1">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="version"/>
|
||||
<field name="description"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="view_chatgpt_ai_instance_tree" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.tree</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="ChatGPT-Compatible Instances">
|
||||
<field name="name"/>
|
||||
<field name="host"/>
|
||||
<field name="is_default"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_chatgpt_ai_instance_search" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.search</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search ChatGPT-Compatible Instances">
|
||||
<field name="name"/>
|
||||
<field name="host"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<filter string="Default Instance" name="is_default" domain="[('is_default', '=', True)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Company" name="company" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_chatgpt_ai_instance" model="ir.actions.act_window">
|
||||
<field name="name">ChatGPT-Compatible Instances</field>
|
||||
<field name="res_model">chatgpt.ai.instance</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first ChatGPT-compatible instance
|
||||
</p>
|
||||
<p>
|
||||
Configure instances to connect to ChatGPT-compatible servers (OpenWebUI, ChatGPT, etc).
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_chatgpt_ai_instance"
|
||||
name="ChatGPT-Compatible"
|
||||
parent="ai_integration.menu_ai_integration_config"
|
||||
action="action_chatgpt_ai_instance"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
1
ollama_ai_integration/__init__.py
Normal file
1
ollama_ai_integration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
33
ollama_ai_integration/__manifest__.py
Normal file
33
ollama_ai_integration/__manifest__.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
'name': 'Ollama Integration',
|
||||
'version': '1.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Integration with Ollama AI models',
|
||||
'description': """
|
||||
Ollama Integration
|
||||
=================
|
||||
This module provides integration with Ollama, allowing you to use local AI models
|
||||
in your Odoo instance. Features include:
|
||||
|
||||
* Connection to local Ollama server
|
||||
* Support for all Ollama models
|
||||
* Automatic model discovery and synchronization
|
||||
* Configurable model parameters
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': ['ai_integration'],
|
||||
'data': [
|
||||
'data/ollama_provider.xml',
|
||||
'views/ollama_views.xml',
|
||||
'views/ollama_stats_views.xml',
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['requests'],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
10
ollama_ai_integration/data/ir_model_inherit.xml
Normal file
10
ollama_ai_integration/data/ir_model_inherit.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Declare model inheritance -->
|
||||
<record id="ollama_provider_inherit" model="ir.model.inherit">
|
||||
<field name="model">ai.provider.ollama</field>
|
||||
<field name="parent_id" ref="ai_integration.model_ai_provider_interface"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
13
ollama_ai_integration/data/ollama_provider.xml
Normal file
13
ollama_ai_integration/data/ollama_provider.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Register Ollama Provider -->
|
||||
<record id="ai_provider_ollama" model="ai.provider">
|
||||
<field name="name">Ollama</field>
|
||||
<field name="code">ollama</field>
|
||||
<field name="description">Local AI models powered by Ollama</field>
|
||||
<field name="default_host">http://localhost:11434</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
1627
ollama_ai_integration/doc/ollama_api.md
Normal file
1627
ollama_ai_integration/doc/ollama_api.md
Normal file
File diff suppressed because it is too large
Load diff
4
ollama_ai_integration/models/__init__.py
Normal file
4
ollama_ai_integration/models/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from . import ollama_provider_mixin
|
||||
from . import ai_provider_ollama
|
||||
from . import ollama_instance
|
||||
from . import ollama_model_stats
|
||||
34
ollama_ai_integration/models/ai_provider_ollama.py
Normal file
34
ollama_ai_integration/models/ai_provider_ollama.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
class OllamaProvider(models.Model):
|
||||
_name = 'ai.provider.ollama'
|
||||
_description = 'Ollama AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
provider_type = fields.Selection(
|
||||
selection=[('ollama', 'Ollama')],
|
||||
default='ollama',
|
||||
required=True,
|
||||
help='Type of AI provider')
|
||||
|
||||
# Ollama-specific Parameters
|
||||
host = fields.Char(
|
||||
string='Host',
|
||||
default='http://localhost:11434',
|
||||
required=True,
|
||||
help='Ollama server host URL')
|
||||
|
||||
def _get_available_models(self):
|
||||
"""Get list of available models from Ollama server."""
|
||||
# TODO: Implement model discovery
|
||||
return []
|
||||
|
||||
def _generate_text(self, prompt, **kwargs):
|
||||
"""Generate text using Ollama model."""
|
||||
# TODO: Implement text generation
|
||||
return ""
|
||||
|
||||
def _embed_text(self, text, **kwargs):
|
||||
"""Generate embeddings for text using Ollama model."""
|
||||
# TODO: Implement text embedding
|
||||
return []
|
||||
82
ollama_ai_integration/models/ollama_model_stats.py
Normal file
82
ollama_ai_integration/models/ollama_model_stats.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from odoo import models, fields, api
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class OllamaModelStats(models.Model):
|
||||
_name = 'ollama.model.stats'
|
||||
_description = 'Ollama Model Usage Statistics'
|
||||
_inherit = ['ai.model.stats']
|
||||
_order = 'date desc'
|
||||
|
||||
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
|
||||
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
|
||||
request_count = fields.Integer(string='Number of Requests', default=0)
|
||||
token_count = fields.Integer(string='Total Tokens', default=0)
|
||||
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
|
||||
error_count = fields.Integer(string='Number of Errors', default=0)
|
||||
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
|
||||
]
|
||||
|
||||
def _update_stats(self, model, tokens, response_time, error=False):
|
||||
"""Update statistics for a model."""
|
||||
today = fields.Date.context_today(self)
|
||||
stats = self.search([
|
||||
('model_id', '=', model.id),
|
||||
('date', '=', today)
|
||||
])
|
||||
|
||||
if not stats:
|
||||
# Get model version
|
||||
version = self.env['ai.provider.ollama']._get_model_info(
|
||||
model.provider_instance_id,
|
||||
model.identifier
|
||||
).get('details', {}).get('sha256', '')[:8] # First 8 chars of SHA
|
||||
|
||||
stats = self.create({
|
||||
'model_id': model.id,
|
||||
'date': today,
|
||||
'version': version
|
||||
})
|
||||
|
||||
# Update statistics
|
||||
new_count = stats.request_count + 1
|
||||
new_tokens = stats.token_count + tokens
|
||||
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
|
||||
new_errors = stats.error_count + (1 if error else 0)
|
||||
|
||||
stats.write({
|
||||
'request_count': new_count,
|
||||
'token_count': new_tokens,
|
||||
'avg_response_time': new_time,
|
||||
'error_count': new_errors
|
||||
})
|
||||
|
||||
@api.model
|
||||
def get_model_stats(self, model_id, days=30):
|
||||
"""Get statistics for a model over the specified number of days."""
|
||||
start_date = fields.Date.today() - timedelta(days=days)
|
||||
stats = self.search([
|
||||
('model_id', '=', model_id),
|
||||
('date', '>=', start_date)
|
||||
])
|
||||
|
||||
return {
|
||||
'daily_stats': [{
|
||||
'date': stat.date,
|
||||
'requests': stat.request_count,
|
||||
'tokens': stat.token_count,
|
||||
'response_time': stat.avg_response_time,
|
||||
'errors': stat.error_count,
|
||||
'version': stat.version
|
||||
} for stat in stats],
|
||||
'summary': {
|
||||
'total_requests': sum(stat.request_count for stat in stats),
|
||||
'total_tokens': sum(stat.token_count for stat in stats),
|
||||
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
|
||||
(sum(stat.request_count for stat in stats) if stats else 1),
|
||||
'total_errors': sum(stat.error_count for stat in stats),
|
||||
'versions_used': list(set(stat.version for stat in stats if stat.version))
|
||||
}
|
||||
}
|
||||
269
ollama_ai_integration/models/ollama_provider.py
Normal file
269
ollama_ai_integration/models/ollama_provider.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import json
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OllamaProvider(models.Model):
|
||||
_name = 'ai.provider.ollama'
|
||||
_description = 'Ollama AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
def _get_provider_type(self):
|
||||
return 'ollama'
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/show",
|
||||
json={'name': model_name}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
_logger.error(
|
||||
"Failed to get model info for %s. Status: %s, Error: %s",
|
||||
model_name, response.status_code, response.text
|
||||
)
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Error getting model info: %s", str(e))
|
||||
return None
|
||||
|
||||
def pull_model(self, instance, model_name):
|
||||
"""Pull a model from Ollama."""
|
||||
try:
|
||||
# Start the pull
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/pull",
|
||||
json={'name': model_name},
|
||||
stream=True # Enable streaming for progress updates
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to pull model %s: %s") %
|
||||
(model_name, response.text))
|
||||
|
||||
# Process the streaming response
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
try:
|
||||
progress = json.loads(line)
|
||||
status = progress.get('status')
|
||||
if status:
|
||||
_logger.info(
|
||||
"Pulling model %s: %s",
|
||||
model_name, status
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error pulling model %s: %s") %
|
||||
(model_name, str(e)))
|
||||
|
||||
def delete_model(self, instance, model_name):
|
||||
"""Delete a model from Ollama."""
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{instance.host}/api/delete",
|
||||
json={'name': model_name}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to delete model %s: %s") %
|
||||
(model_name, response.text))
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error deleting model %s: %s") %
|
||||
(model_name, str(e)))
|
||||
|
||||
def test_connection(self, instance):
|
||||
"""Test the connection to the Ollama server."""
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/tags")
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Failed to connect to Ollama server. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to connect to Ollama server: %s", str(e)
|
||||
))
|
||||
|
||||
def _format_chat_messages(self, messages):
|
||||
"""Format chat messages for Ollama API."""
|
||||
formatted_prompt = ""
|
||||
for message in messages:
|
||||
role = message.get('role', 'user')
|
||||
content = message.get('content', '')
|
||||
|
||||
if role == 'system':
|
||||
formatted_prompt += f"<system>{content}</system>\n"
|
||||
elif role == 'assistant':
|
||||
formatted_prompt += f"Assistant: {content}\n"
|
||||
else: # user
|
||||
formatted_prompt += f"Human: {content}\n"
|
||||
|
||||
return formatted_prompt.strip()
|
||||
|
||||
def generate_response(self, instance, model, messages, **kwargs):
|
||||
"""Generate a response using the chat completion API."""
|
||||
try:
|
||||
# Format messages into Ollama's expected format
|
||||
prompt = self._format_chat_messages(messages)
|
||||
|
||||
# Get model options from instance
|
||||
options = instance._get_provider_options()
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'prompt': prompt,
|
||||
'stream': False,
|
||||
**options
|
||||
}
|
||||
|
||||
# Make the API call
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/generate",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to generate response: %s") % response.text)
|
||||
|
||||
response_data = response.json()
|
||||
generated_text = response_data.get('response', '')
|
||||
|
||||
# Update statistics
|
||||
total_tokens = response_data.get('eval_count', 0)
|
||||
response_time = response_data.get('total_duration', 0)
|
||||
version = self._get_model_info(instance, model.identifier)\
|
||||
.get('details', {}).get('sha256', '')[:8]
|
||||
self._track_model_usage(
|
||||
model, total_tokens, response_time, version=version
|
||||
)
|
||||
|
||||
# Return the response in a standardized format
|
||||
return {
|
||||
'content': generated_text,
|
||||
'role': 'assistant',
|
||||
'metadata': {
|
||||
'eval_count': response_data.get('eval_count'),
|
||||
'eval_duration': response_data.get('eval_duration'),
|
||||
'total_duration': response_data.get('total_duration'),
|
||||
'load_duration': response_data.get('load_duration'),
|
||||
}
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Log error in statistics
|
||||
if model:
|
||||
self._track_model_usage(model, error=True)
|
||||
raise UserError(_("Error generating response: %s") % str(e))
|
||||
|
||||
def sync_models(self, instance):
|
||||
"""Synchronize available models from the Ollama server."""
|
||||
self.test_connection(instance)
|
||||
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/tags")
|
||||
models_data = response.json().get('models', [])
|
||||
|
||||
# Get existing models for this instance
|
||||
existing_models = self.env['ai.model'].search([
|
||||
('provider_instance_id', '=', instance.id)
|
||||
])
|
||||
existing_identifiers = {m.identifier: m for m in existing_models}
|
||||
|
||||
for model_data in models_data:
|
||||
identifier = model_data.get('name')
|
||||
if not identifier:
|
||||
continue
|
||||
|
||||
# Get model details
|
||||
model_info = self._get_model_info(instance, identifier)
|
||||
model_details = model_info.get('details', {})
|
||||
|
||||
model_values = {
|
||||
'name': identifier.title(),
|
||||
'identifier': identifier,
|
||||
'provider_instance_id': instance.id,
|
||||
'company_id': instance.company_id.id,
|
||||
'active': True,
|
||||
'description': f"Ollama model: {identifier}\\nSize: {model_data.get('size', 'Unknown')}\\nModified: {model_data.get('modified', 'Unknown')}",
|
||||
}
|
||||
|
||||
if identifier in existing_identifiers:
|
||||
# Update existing model
|
||||
existing_identifiers[identifier].write(model_values)
|
||||
del existing_identifiers[identifier]
|
||||
else:
|
||||
# Create new model
|
||||
self.env['ai.model'].create(model_values)
|
||||
|
||||
# Deactivate models that no longer exist
|
||||
for model in existing_identifiers.values():
|
||||
model.active = False
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to sync models from Ollama server: %s", str(e)
|
||||
))
|
||||
|
||||
def send_message(self, message, model, **kwargs):
|
||||
"""Send a message to the Ollama server."""
|
||||
instance = model.provider_instance_id
|
||||
|
||||
try:
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'prompt': message.get('content', ''),
|
||||
'stream': False,
|
||||
'options': kwargs.get('options', {}),
|
||||
}
|
||||
|
||||
# Add system message if provided
|
||||
if message.get('role') == 'system':
|
||||
payload['system'] = message['content']
|
||||
|
||||
# Send the request
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/generate",
|
||||
json=payload,
|
||||
timeout=(instance.timeout or 30)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Ollama server error. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
|
||||
result = response.json()
|
||||
return result.get('response', '')
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise UserError(_("Request to Ollama server timed out"))
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Error communicating with Ollama server: %s", str(e)
|
||||
))
|
||||
except Exception as e:
|
||||
_logger.error("Unexpected error in Ollama provider: %s", str(e))
|
||||
raise UserError(_(
|
||||
"Unexpected error in Ollama provider: %s", str(e)
|
||||
))
|
||||
141
ollama_ai_integration/models/ollama_provider_mixin.py
Normal file
141
ollama_ai_integration/models/ollama_provider_mixin.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
class OllamaProviderMixin(models.AbstractModel):
|
||||
_name = 'ollama.provider.mixin'
|
||||
_description = 'Ollama Provider Configuration Mixin'
|
||||
_inherit = ['ai.generation.params']
|
||||
|
||||
provider_type = fields.Selection(
|
||||
selection_add=[('ollama', 'Ollama')],
|
||||
ondelete={'ollama': 'cascade'})
|
||||
|
||||
# Ollama-specific Parameters
|
||||
num_ctx = fields.Integer(
|
||||
string='Context Length',
|
||||
help='Maximum number of tokens to consider for context. Range: [0 - 32768].',
|
||||
default=4096)
|
||||
|
||||
# Advanced Sampling Parameters
|
||||
top_k = fields.Integer(
|
||||
string='Top K',
|
||||
help='Limits the number of tokens to sample from. Range: [1 - 100].',
|
||||
default=40)
|
||||
|
||||
min_p = fields.Float(
|
||||
string='Min P',
|
||||
help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].',
|
||||
default=0.05,
|
||||
digits=(3, 2))
|
||||
|
||||
repeat_penalty = fields.Float(
|
||||
string='Repeat Penalty',
|
||||
help='Penalty for repeating tokens. Range: [1.0 - 2.0]. Higher values make repetition less likely.',
|
||||
default=1.1,
|
||||
digits=(3, 2))
|
||||
|
||||
repeat_last_n = fields.Integer(
|
||||
string='Repeat Last N',
|
||||
help='Sets the context window for repeat penalty. Range: [0 - 4096]. Default is 64, 0 disables.',
|
||||
default=64)
|
||||
|
||||
# Advanced Generation Parameters
|
||||
seed = fields.Integer(
|
||||
string='Random Seed',
|
||||
help='Sets the random seed for generation. Range: [0 - 2147483647]. Use 0 for random.',
|
||||
default=0)
|
||||
|
||||
num_gpu = fields.Integer(
|
||||
string='Number of GPUs',
|
||||
help='Number of GPUs to use for generation. Range: [0 - 8]. 0 means CPU only.',
|
||||
default=1)
|
||||
|
||||
num_thread = fields.Integer(
|
||||
string='Number of Threads',
|
||||
help='Number of CPU threads to use for generation. Range: [1 - 32].',
|
||||
default=8)
|
||||
|
||||
mirostat = fields.Selection([
|
||||
('0', 'Disabled'),
|
||||
('1', 'Mirostat'),
|
||||
('2', 'Mirostat 2.0')],
|
||||
string='Mirostat Mode',
|
||||
help='Enable Mirostat sampling for controlling perplexity',
|
||||
default='0')
|
||||
|
||||
mirostat_tau = fields.Float(
|
||||
string='Mirostat Tau',
|
||||
help='Mirostat target entropy. Range: [0.0 - 10.0].',
|
||||
default=5.0,
|
||||
digits=(3, 2))
|
||||
|
||||
mirostat_eta = fields.Float(
|
||||
string='Mirostat Eta',
|
||||
help='Mirostat learning rate. Range: [0.0 - 1.0].',
|
||||
default=0.1,
|
||||
digits=(3, 2))
|
||||
|
||||
# Ollama-specific Response Control
|
||||
|
||||
tfs_z = fields.Float(
|
||||
string='Tail Free Sampling Z',
|
||||
help='Tail free sampling parameter. Range: [0.0 - 2.0]. Higher value = more focused.',
|
||||
default=1.0,
|
||||
digits=(3, 2))
|
||||
|
||||
# System Settings
|
||||
num_batch = fields.Integer(
|
||||
string='Batch Size',
|
||||
help='Number of prompts to batch together',
|
||||
default=8)
|
||||
|
||||
num_keep = fields.Integer(
|
||||
string='Keep Last N Tokens',
|
||||
help='Number of tokens to keep from initial prompt',
|
||||
default=0)
|
||||
|
||||
skip_special_tokens = fields.Boolean(
|
||||
string='Skip Special Tokens',
|
||||
help='Skip special tokens in generation',
|
||||
default=True)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
if 'provider_type' in fields_list:
|
||||
defaults['provider_type'] = 'ollama'
|
||||
if 'host' in fields_list and not defaults.get('host'):
|
||||
defaults['host'] = 'http://localhost:11434'
|
||||
return defaults
|
||||
|
||||
def _get_provider_options(self):
|
||||
"""Get Ollama-specific options for API calls."""
|
||||
self.ensure_one()
|
||||
options = {
|
||||
'temperature': self.temperature,
|
||||
'num_ctx': self.num_ctx,
|
||||
'num_predict': self.num_predict,
|
||||
'top_k': self.top_k,
|
||||
'top_p': self.top_p,
|
||||
'min_p': self.min_p,
|
||||
'repeat_penalty': self.repeat_penalty,
|
||||
'repeat_last_n': self.repeat_last_n,
|
||||
'seed': self.seed,
|
||||
'num_gpu': self.num_gpu,
|
||||
'num_thread': self.num_thread,
|
||||
'mirostat': int(self.mirostat),
|
||||
'mirostat_tau': self.mirostat_tau,
|
||||
'mirostat_eta': self.mirostat_eta,
|
||||
'num_batch': self.num_batch,
|
||||
'num_keep': self.num_keep,
|
||||
'tfs_z': self.tfs_z,
|
||||
'skip_special_tokens': self.skip_special_tokens,
|
||||
}
|
||||
|
||||
if self.stop_sequences:
|
||||
options['stop'] = [
|
||||
seq.strip()
|
||||
for seq in self.stop_sequences.split(',')
|
||||
if seq.strip()
|
||||
]
|
||||
|
||||
return options
|
||||
3
ollama_ai_integration/security/ir.model.access.csv
Normal file
3
ollama_ai_integration/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ollama_model_stats_user,ollama.model.stats.user,model_ollama_model_stats,base.group_user,1,0,0,0
|
||||
access_ollama_model_stats_manager,ollama.model.stats.manager,model_ollama_model_stats,base.group_system,1,1,1,1
|
||||
|
118
ollama_ai_integration/views/ollama_stats_views.xml
Normal file
118
ollama_ai_integration/views/ollama_stats_views.xml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- List View -->
|
||||
<record id="view_ollama_model_stats_list" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.list</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Model Statistics">
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_ollama_model_stats_form" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.form</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Model Statistics">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_ollama_model_stats_search" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.search</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Model Statistics">
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
<field name="date"/>
|
||||
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
|
||||
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
|
||||
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Graph View -->
|
||||
<record id="view_ollama_model_stats_graph" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.graph</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Model Statistics" type="line" sample="1">
|
||||
<field name="date" type="row"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Pivot View -->
|
||||
<record id="view_ollama_model_stats_pivot" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.pivot</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Model Statistics" sample="1">
|
||||
<field name="model_id" type="row"/>
|
||||
<field name="date" type="col"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="avg_response_time" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ollama_model_stats" model="ir.actions.act_window">
|
||||
<field name="name">Model Statistics</field>
|
||||
<field name="res_model">ollama.model.stats</field>
|
||||
<field name="view_mode">list,form,graph,pivot</field>
|
||||
<field name="context">{'search_default_last_month': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No statistics recorded yet
|
||||
</p>
|
||||
<p>
|
||||
Statistics will be automatically recorded as you use your AI models.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_ollama_model_stats"
|
||||
name="Model Statistics"
|
||||
parent="ai_integration.menu_ai_integration"
|
||||
action="action_ollama_model_stats"
|
||||
sequence="30"/>
|
||||
</odoo>
|
||||
105
ollama_ai_integration/views/ollama_views.xml
Normal file
105
ollama_ai_integration/views/ollama_views.xml
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form View -->
|
||||
<record id="ollama_instance_view_form" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.form</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="type">form</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="replace">
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Ollama Settings" name="ollama_settings"
|
||||
invisible="provider_type != 'ollama'">
|
||||
<group>
|
||||
<group string="Generation Parameters">
|
||||
<field name="num_ctx"/>
|
||||
<field name="temperature"/>
|
||||
<field name="top_p"/>
|
||||
<field name="top_k"/>
|
||||
<field name="repeat_penalty"/>
|
||||
</group>
|
||||
<group string="Advanced Settings">
|
||||
<field name="stop_sequences" placeholder="Enter comma-separated stop sequences"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert" style="margin-top: 10px;">
|
||||
<p><strong>Note:</strong> These settings will be used as defaults for all requests to this Ollama instance.
|
||||
They can be overridden on a per-request basis.</p>
|
||||
<ul>
|
||||
<li><strong>Context Length:</strong> Longer context allows the model to consider more previous text but uses more memory.</li>
|
||||
<li><strong>Temperature:</strong> Higher values (>1.0) make output more random, lower values make it more focused and deterministic.</li>
|
||||
<li><strong>Top P:</strong> Controls diversity via nucleus sampling. Lower values (0.1) are more focused, higher values (0.9) more diverse.</li>
|
||||
<li><strong>Top K:</strong> Limits the cumulative probability of tokens to sample from. Lower values are more focused.</li>
|
||||
<li><strong>Repeat Penalty:</strong> Higher values (>1.0) make the model less likely to repeat itself.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="ollama_instance_view_list" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.list</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="num_ctx" optional="show"/>
|
||||
<field name="temperature" optional="hide"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ollama_instance_view_search" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.search</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group" position="inside">
|
||||
<filter string="Context Length" name="group_by_num_ctx"
|
||||
domain="[('provider_type', '=', 'ollama')]"
|
||||
context="{'group_by': 'num_ctx'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ollama_instance" model="ir.actions.act_window">
|
||||
<field name="name">Ollama Instances</field>
|
||||
<field name="res_model">ai.provider.instance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('provider_type', '=', 'ollama')]</field>
|
||||
<field name="context">{'default_provider_type': 'ollama'}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first Ollama instance
|
||||
</p>
|
||||
<p>
|
||||
Configure Ollama instances to use local AI models in your Odoo instance.
|
||||
Make sure you have Ollama installed and running on your server.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_ollama_instance"
|
||||
name="Ollama Instances"
|
||||
parent="ai_integration.ai_integration_menu_config"
|
||||
action="action_ollama_instance"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue