feat(odoo): add activeblue_ai Odoo 18 module with OWL2 frontend
Models: - ab.ai.bot: service URL, webhook secret, privacy mode, ping/dispatch - ab.ai.directive: full directive lifecycle log with status tracking - ab.ai.log: activity log with level/agent/record linkage - ab.ai.agent.registry: agent list synced from agent service Controllers: - webhook.py: /ai/webhook/callback handles directive_completed, escalation, sweep_findings - health_proxy.py: /ai/health proxies agent service detailed health - approval.py: /ai/chat dispatch, /ai/approval/pending, /ai/approval/respond Security: - group_ai_user (chat) + group_ai_manager (configure, approve, logs) - ir.model.access.csv for all 4 models Views: list/form for bot, directives, logs, registry; main menu with AI brain icon OWL2 frontend: - systray_button.js: brain icon in top bar, status dot, pending approval badge - ai_panel.js: slide-in chat panel, approval workflow, 30s poll for pending items - CSS: slide-in animation, message bubbles, loading dots, approval section Data: 4 cron jobs (ping, registry sync, directive/log cleanup) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
addons/activeblue_ai/__init__.py
Normal file
1
addons/activeblue_ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models, controllers
|
||||||
37
addons/activeblue_ai/__manifest__.py
Normal file
37
addons/activeblue_ai/__manifest__.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
'name': 'ActiveBlue AI',
|
||||||
|
'version': '18.0.0.1.0',
|
||||||
|
'summary': 'Multi-agent AI assistant for Odoo 18',
|
||||||
|
'description': """
|
||||||
|
ActiveBlue AI integrates a multi-agent AI system into Odoo 18.
|
||||||
|
Features a Master AI with 8 specialist agents for finance, accounting,
|
||||||
|
CRM, sales, project management, eLearning, expenses, and HR.
|
||||||
|
""",
|
||||||
|
'author': 'ActiveBlue',
|
||||||
|
'website': 'https://activeblue.net',
|
||||||
|
'category': 'Productivity',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'depends': ['base', 'mail', 'web'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'security/res_groups.xml',
|
||||||
|
'data/ir_cron.xml',
|
||||||
|
'views/ab_ai_bot_views.xml',
|
||||||
|
'views/ab_ai_directive_views.xml',
|
||||||
|
'views/ab_ai_log_views.xml',
|
||||||
|
'views/ab_ai_registry_views.xml',
|
||||||
|
'views/menus.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'activeblue_ai/static/src/css/activeblue_ai.css',
|
||||||
|
'activeblue_ai/static/src/xml/systray.xml',
|
||||||
|
'activeblue_ai/static/src/xml/ai_panel.xml',
|
||||||
|
'activeblue_ai/static/src/js/components/ai_panel.js',
|
||||||
|
'activeblue_ai/static/src/js/components/systray_button.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
1
addons/activeblue_ai/controllers/__init__.py
Normal file
1
addons/activeblue_ai/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import webhook, health_proxy, approval
|
||||||
60
addons/activeblue_ai/controllers/approval.py
Normal file
60
addons/activeblue_ai/controllers/approval.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AiApprovalController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/ai/approval/pending', type='json', auth='user', methods=['GET'])
|
||||||
|
def list_pending(self):
|
||||||
|
if not request.env.user.has_group('activeblue_ai.group_ai_manager'):
|
||||||
|
return {'error': 'Access denied', 'items': []}
|
||||||
|
bot = request.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1)
|
||||||
|
if not bot:
|
||||||
|
return {'items': []}
|
||||||
|
url = bot._get_service_url() + '/approval/pending'
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=bot._build_headers(), timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return {'items': resp.json()}
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error('list_pending failed: %s', exc)
|
||||||
|
return {'error': str(exc), 'items': []}
|
||||||
|
|
||||||
|
@http.route('/ai/approval/respond', type='json', auth='user', methods=['POST'])
|
||||||
|
def respond(self, directive_id, approved, note=None):
|
||||||
|
if not request.env.user.has_group('activeblue_ai.group_ai_manager'):
|
||||||
|
return {'error': 'Access denied'}
|
||||||
|
bot = request.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1)
|
||||||
|
if not bot:
|
||||||
|
return {'error': 'No bot configured'}
|
||||||
|
url = bot._get_service_url() + '/approval/respond'
|
||||||
|
payload = {
|
||||||
|
'directive_id': directive_id,
|
||||||
|
'approved': approved,
|
||||||
|
'approver_id': str(request.env.user.id),
|
||||||
|
'note': note or '',
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, headers=bot._build_headers(), timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error('respond approval failed: %s', exc)
|
||||||
|
return {'error': str(exc)}
|
||||||
|
|
||||||
|
@http.route('/ai/chat', type='json', auth='user', methods=['POST'])
|
||||||
|
def chat(self, message, context=None, session_id=None):
|
||||||
|
bot = request.env['ab.ai.bot'].sudo().get_active_bot()
|
||||||
|
result = bot.dispatch_message(
|
||||||
|
user_id=request.env.user.id,
|
||||||
|
message=message,
|
||||||
|
context=context or {},
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return result
|
||||||
37
addons/activeblue_ai/controllers/health_proxy.py
Normal file
37
addons/activeblue_ai/controllers/health_proxy.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AiHealthProxyController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/ai/health', type='http', auth='user', methods=['GET'])
|
||||||
|
def ai_health(self):
|
||||||
|
bot = request.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1)
|
||||||
|
if not bot:
|
||||||
|
return request.make_response(
|
||||||
|
'{"status":"no_bot_configured"}',
|
||||||
|
headers=[('Content-Type', 'application/json')],
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
url = bot._get_service_url() + '/health/detailed'
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=bot._build_headers(), timeout=5)
|
||||||
|
return request.make_response(
|
||||||
|
resp.text,
|
||||||
|
headers=[('Content-Type', 'application/json')],
|
||||||
|
status=resp.status_code,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning('health proxy failed: %s', exc)
|
||||||
|
import json
|
||||||
|
body = json.dumps({'status': 'unreachable', 'error': str(exc)})
|
||||||
|
return request.make_response(
|
||||||
|
body,
|
||||||
|
headers=[('Content-Type', 'application/json')],
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
86
addons/activeblue_ai/controllers/webhook.py
Normal file
86
addons/activeblue_ai/controllers/webhook.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AiWebhookController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/ai/webhook/callback', type='json', auth='none', methods=['POST'], csrf=False)
|
||||||
|
def ai_callback(self):
|
||||||
|
# Verify webhook secret
|
||||||
|
bot = request.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1)
|
||||||
|
if bot and bot.webhook_secret:
|
||||||
|
incoming_sig = request.httprequest.headers.get('X-ActiveBlue-Signature', '')
|
||||||
|
if incoming_sig != bot.webhook_secret:
|
||||||
|
_logger.warning('Webhook signature mismatch from %s', request.httprequest.remote_addr)
|
||||||
|
return Response(status=401)
|
||||||
|
|
||||||
|
# Check IP whitelist
|
||||||
|
if bot and bot.webhook_secret:
|
||||||
|
pass # IP check handled at network level via ALLOWED_CALLBACK_IP env var in agent service
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(request.httprequest.data)
|
||||||
|
except Exception:
|
||||||
|
return Response(status=400)
|
||||||
|
|
||||||
|
event_type = data.get('event', '')
|
||||||
|
_logger.debug('AI webhook callback: event=%s', event_type)
|
||||||
|
|
||||||
|
if event_type == 'directive_completed':
|
||||||
|
self._handle_directive_completed(data)
|
||||||
|
elif event_type == 'escalation':
|
||||||
|
self._handle_escalation(data)
|
||||||
|
elif event_type == 'sweep_findings':
|
||||||
|
self._handle_sweep_findings(data)
|
||||||
|
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
def _handle_directive_completed(self, data):
|
||||||
|
try:
|
||||||
|
request.env['ab.ai.directive'].sudo().record_directive(
|
||||||
|
directive_id=data.get('directive_id', ''),
|
||||||
|
user_id=int(data.get('user_id', 0)) or None,
|
||||||
|
message=data.get('message', ''),
|
||||||
|
reply=data.get('reply', ''),
|
||||||
|
status='completed',
|
||||||
|
agents=data.get('agents', []),
|
||||||
|
escalations=data.get('escalations', []),
|
||||||
|
actions=data.get('actions_taken', []),
|
||||||
|
session_id=data.get('session_id'),
|
||||||
|
duration_ms=data.get('duration_ms'),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error('_handle_directive_completed error: %s', exc)
|
||||||
|
|
||||||
|
def _handle_escalation(self, data):
|
||||||
|
try:
|
||||||
|
request.env['ab.ai.log'].sudo().log(
|
||||||
|
summary=data.get('message', 'AI escalation'),
|
||||||
|
level='warning',
|
||||||
|
agent=data.get('agent'),
|
||||||
|
directive_id=data.get('directive_id'),
|
||||||
|
details=json.dumps(data),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error('_handle_escalation error: %s', exc)
|
||||||
|
|
||||||
|
def _handle_sweep_findings(self, data):
|
||||||
|
findings = data.get('findings', [])
|
||||||
|
agent = data.get('agent', 'unknown')
|
||||||
|
try:
|
||||||
|
for finding in findings:
|
||||||
|
request.env['ab.ai.log'].sudo().log(
|
||||||
|
summary=f"Sweep finding [{agent}]: {finding.get('type', '')}",
|
||||||
|
level='info',
|
||||||
|
agent=agent,
|
||||||
|
details=json.dumps(finding),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error('_handle_sweep_findings error: %s', exc)
|
||||||
48
addons/activeblue_ai/data/ir_cron.xml
Normal file
48
addons/activeblue_ai/data/ir_cron.xml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="cron_ai_ping" model="ir.cron">
|
||||||
|
<field name="name">ActiveBlue AI: Ping Service</field>
|
||||||
|
<field name="model_id" ref="model_ab_ai_bot"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_ping_all()</field>
|
||||||
|
<field name="interval_number">5</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="cron_ai_registry_sync" model="ir.cron">
|
||||||
|
<field name="name">ActiveBlue AI: Sync Agent Registry</field>
|
||||||
|
<field name="model_id" ref="model_ab_ai_agent_registry"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.sync_from_service()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">hours</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="cron_ai_directive_cleanup" model="ir.cron">
|
||||||
|
<field name="name">ActiveBlue AI: Clean Up Old Directives</field>
|
||||||
|
<field name="model_id" ref="model_ab_ai_directive"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_cleanup_old()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="cron_ai_log_cleanup" model="ir.cron">
|
||||||
|
<field name="name">ActiveBlue AI: Clean Up Old Logs</field>
|
||||||
|
<field name="model_id" ref="model_ab_ai_log"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_cleanup()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
1
addons/activeblue_ai/models/__init__.py
Normal file
1
addons/activeblue_ai/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import ab_ai_bot, ab_ai_directive, ab_ai_log, ab_ai_registry
|
||||||
102
addons/activeblue_ai/models/ab_ai_bot.py
Normal file
102
addons/activeblue_ai/models/ab_ai_bot.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AGENT_SERVICE_URL_PARAM = 'activeblue_ai.agent_service_url'
|
||||||
|
WEBHOOK_SECRET_PARAM = 'activeblue_ai.webhook_secret'
|
||||||
|
|
||||||
|
|
||||||
|
class AbAiBot(models.Model):
|
||||||
|
_name = 'ab.ai.bot'
|
||||||
|
_description = 'ActiveBlue AI Bot'
|
||||||
|
_rec_name = 'display_name'
|
||||||
|
|
||||||
|
display_name = fields.Char(string='Bot Name', default='ActiveBlue AI', required=True)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
agent_service_url = fields.Char(
|
||||||
|
string='Agent Service URL',
|
||||||
|
default='http://192.168.2.47:8001',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
webhook_secret = fields.Char(string='Webhook Secret')
|
||||||
|
privacy_mode = fields.Selection(
|
||||||
|
[('local', 'Local (Ollama only)'), ('hybrid', 'Hybrid'), ('cloud', 'Cloud (Claude)')],
|
||||||
|
string='Privacy Mode',
|
||||||
|
default='local',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
status = fields.Selection(
|
||||||
|
[('online', 'Online'), ('offline', 'Offline'), ('error', 'Error')],
|
||||||
|
string='Status',
|
||||||
|
default='offline',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
last_ping = fields.Datetime(string='Last Ping', readonly=True)
|
||||||
|
notes = fields.Text(string='Notes')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_active_bot(self):
|
||||||
|
bot = self.search([('active', '=', True)], limit=1)
|
||||||
|
if not bot:
|
||||||
|
raise UserError(_('No active AI bot configured. Please configure ActiveBlue AI.'))
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def _get_service_url(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return (self.agent_service_url or '').rstrip('/')
|
||||||
|
|
||||||
|
def _build_headers(self):
|
||||||
|
self.ensure_one()
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if self.webhook_secret:
|
||||||
|
headers['X-ActiveBlue-Signature'] = self.webhook_secret
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def action_ping(self):
|
||||||
|
self.ensure_one()
|
||||||
|
url = self._get_service_url() + '/health'
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=5, headers=self._build_headers())
|
||||||
|
if resp.status_code == 200:
|
||||||
|
self.write({'status': 'online', 'last_ping': fields.Datetime.now()})
|
||||||
|
return {'type': 'ir.actions.client', 'tag': 'display_notification',
|
||||||
|
'params': {'message': _('AI service is online'), 'type': 'success'}}
|
||||||
|
else:
|
||||||
|
self.write({'status': 'error'})
|
||||||
|
return {'type': 'ir.actions.client', 'tag': 'display_notification',
|
||||||
|
'params': {'message': _('AI service returned %s') % resp.status_code, 'type': 'warning'}}
|
||||||
|
except Exception as exc:
|
||||||
|
self.write({'status': 'offline'})
|
||||||
|
return {'type': 'ir.actions.client', 'tag': 'display_notification',
|
||||||
|
'params': {'message': _('AI service unreachable: %s') % exc, 'type': 'danger'}}
|
||||||
|
|
||||||
|
def dispatch_message(self, user_id, message, context=None, session_id=None):
|
||||||
|
self.ensure_one()
|
||||||
|
url = self._get_service_url() + '/dispatch'
|
||||||
|
payload = {
|
||||||
|
'user_id': str(user_id),
|
||||||
|
'message': message,
|
||||||
|
'context': context or {},
|
||||||
|
}
|
||||||
|
if session_id:
|
||||||
|
payload['session_id'] = session_id
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, headers=self._build_headers(), timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise UserError(_('AI service timed out. Please try again.'))
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
_logger.error('dispatch_message failed: %s', exc)
|
||||||
|
raise UserError(_('Could not reach AI service: %s') % exc)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_ping_all(self):
|
||||||
|
for bot in self.search([('active', '=', True)]):
|
||||||
|
try:
|
||||||
|
bot.action_ping()
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning('Ping failed for bot %s: %s', bot.id, exc)
|
||||||
77
addons/activeblue_ai/models/ab_ai_directive.py
Normal file
77
addons/activeblue_ai/models/ab_ai_directive.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AbAiDirective(models.Model):
|
||||||
|
_name = 'ab.ai.directive'
|
||||||
|
_description = 'AI Directive Log'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
_rec_name = 'directive_id'
|
||||||
|
|
||||||
|
directive_id = fields.Char(string='Directive ID', required=True, index=True, readonly=True)
|
||||||
|
user_id = fields.Many2one('res.users', string='User', readonly=True, index=True)
|
||||||
|
message = fields.Text(string='User Message', readonly=True)
|
||||||
|
reply = fields.Text(string='AI Reply', readonly=True)
|
||||||
|
status = fields.Selection(
|
||||||
|
[
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('running', 'Running'),
|
||||||
|
('pending_approval', 'Pending Approval'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('timeout', 'Timeout'),
|
||||||
|
],
|
||||||
|
string='Status',
|
||||||
|
default='pending',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
agents_involved = fields.Char(string='Agents', readonly=True)
|
||||||
|
escalations = fields.Text(string='Escalations', readonly=True)
|
||||||
|
actions_taken = fields.Text(string='Actions Taken', readonly=True)
|
||||||
|
session_id = fields.Char(string='Session ID', readonly=True, index=True)
|
||||||
|
duration_ms = fields.Integer(string='Duration (ms)', readonly=True)
|
||||||
|
error_message = fields.Text(string='Error', readonly=True)
|
||||||
|
|
||||||
|
def action_view_detail(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'ab.ai.directive',
|
||||||
|
'res_id': self.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def record_directive(self, directive_id, user_id, message, reply, status,
|
||||||
|
agents=None, escalations=None, actions=None,
|
||||||
|
session_id=None, duration_ms=None, error=None):
|
||||||
|
import json
|
||||||
|
vals = {
|
||||||
|
'directive_id': directive_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'message': message,
|
||||||
|
'reply': reply,
|
||||||
|
'status': status,
|
||||||
|
'agents_involved': ', '.join(agents) if agents else '',
|
||||||
|
'escalations': json.dumps(escalations) if escalations else '',
|
||||||
|
'actions_taken': json.dumps(actions) if actions else '',
|
||||||
|
'session_id': session_id,
|
||||||
|
'duration_ms': duration_ms or 0,
|
||||||
|
'error_message': error or '',
|
||||||
|
}
|
||||||
|
return self.create(vals)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_cleanup_old(self):
|
||||||
|
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=90)
|
||||||
|
old = self.search([('create_date', '<', cutoff), ('status', 'in', ['completed', 'failed', 'timeout'])])
|
||||||
|
count = len(old)
|
||||||
|
old.unlink()
|
||||||
|
_logger.info('Cleaned up %d old directives', count)
|
||||||
46
addons/activeblue_ai/models/ab_ai_log.py
Normal file
46
addons/activeblue_ai/models/ab_ai_log.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AbAiLog(models.Model):
|
||||||
|
_name = 'ab.ai.log'
|
||||||
|
_description = 'AI Activity Log'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
_rec_name = 'summary'
|
||||||
|
|
||||||
|
summary = fields.Char(string='Summary', required=True, readonly=True)
|
||||||
|
level = fields.Selection(
|
||||||
|
[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('debug', 'Debug')],
|
||||||
|
string='Level',
|
||||||
|
default='info',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
agent_name = fields.Char(string='Agent', readonly=True, index=True)
|
||||||
|
directive_id = fields.Char(string='Directive ID', readonly=True, index=True)
|
||||||
|
user_id = fields.Many2one('res.users', string='User', readonly=True)
|
||||||
|
details = fields.Text(string='Details', readonly=True)
|
||||||
|
model_name = fields.Char(string='Model', readonly=True)
|
||||||
|
record_id = fields.Integer(string='Record ID', readonly=True)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def log(self, summary, level='info', agent=None, directive_id=None,
|
||||||
|
user_id=None, details=None, model=None, record_id=None):
|
||||||
|
return self.create({
|
||||||
|
'summary': summary[:255] if summary else '',
|
||||||
|
'level': level,
|
||||||
|
'agent_name': agent or '',
|
||||||
|
'directive_id': directive_id or '',
|
||||||
|
'user_id': user_id,
|
||||||
|
'details': details or '',
|
||||||
|
'model_name': model or '',
|
||||||
|
'record_id': record_id or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_cleanup(self):
|
||||||
|
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=30)
|
||||||
|
old = self.search([('create_date', '<', cutoff), ('level', 'in', ['info', 'debug'])])
|
||||||
|
old.unlink()
|
||||||
87
addons/activeblue_ai/models/ab_ai_registry.py
Normal file
87
addons/activeblue_ai/models/ab_ai_registry.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AbAiAgentRegistry(models.Model):
|
||||||
|
_name = 'ab.ai.agent.registry'
|
||||||
|
_description = 'AI Agent Registry'
|
||||||
|
_rec_name = 'agent_name'
|
||||||
|
|
||||||
|
agent_name = fields.Char(string='Agent Name', required=True, index=True)
|
||||||
|
domain = fields.Char(string='Domain')
|
||||||
|
active = fields.Boolean(default=True, index=True)
|
||||||
|
backend = fields.Selection(
|
||||||
|
[('ollama', 'Ollama (Local)'), ('claude', 'Claude (Cloud)')],
|
||||||
|
string='LLM Backend',
|
||||||
|
default='ollama',
|
||||||
|
)
|
||||||
|
description = fields.Text(string='Description')
|
||||||
|
last_sweep = fields.Datetime(string='Last Sweep', readonly=True)
|
||||||
|
sweep_count = fields.Integer(string='Sweep Count', default=0, readonly=True)
|
||||||
|
error_count = fields.Integer(string='Error Count', default=0, readonly=True)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('agent_name_uniq', 'unique(agent_name)', 'Agent name must be unique'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def sync_from_service(self):
|
||||||
|
bot = self.env['ab.ai.bot'].search([('active', '=', True)], limit=1)
|
||||||
|
if not bot:
|
||||||
|
_logger.warning('No active bot — cannot sync registry')
|
||||||
|
return 0
|
||||||
|
url = bot._get_service_url() + '/registry/agents'
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=bot._build_headers(), timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
agents = resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error('Registry sync failed: %s', exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
synced = 0
|
||||||
|
for agent_data in agents:
|
||||||
|
name = agent_data.get('name', '')
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
existing = self.search([('agent_name', '=', name)], limit=1)
|
||||||
|
vals = {
|
||||||
|
'agent_name': name,
|
||||||
|
'domain': agent_data.get('domain', ''),
|
||||||
|
'active': agent_data.get('active', True),
|
||||||
|
'backend': agent_data.get('backend', 'ollama'),
|
||||||
|
}
|
||||||
|
if existing:
|
||||||
|
existing.write(vals)
|
||||||
|
else:
|
||||||
|
self.create(vals)
|
||||||
|
synced += 1
|
||||||
|
_logger.info('Synced %d agents from service', synced)
|
||||||
|
return synced
|
||||||
|
|
||||||
|
def action_set_backend_claude(self):
|
||||||
|
self._set_backend('claude')
|
||||||
|
|
||||||
|
def action_set_backend_ollama(self):
|
||||||
|
self._set_backend('ollama')
|
||||||
|
|
||||||
|
def _set_backend(self, backend):
|
||||||
|
self.ensure_one()
|
||||||
|
bot = self.env['ab.ai.bot'].search([('active', '=', True)], limit=1)
|
||||||
|
if not bot:
|
||||||
|
return
|
||||||
|
url = bot._get_service_url() + '/registry/backend'
|
||||||
|
payload = {
|
||||||
|
'agent_name': self.agent_name,
|
||||||
|
'backend': backend,
|
||||||
|
'set_by': str(self.env.user.id),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, headers=bot._build_headers(), timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self.write({'backend': backend})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error('set_backend failed: %s', exc)
|
||||||
9
addons/activeblue_ai/security/ir.model.access.csv
Normal file
9
addons/activeblue_ai/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_ab_ai_bot_manager,ab.ai.bot manager,model_ab_ai_bot,group_ai_manager,1,1,1,1
|
||||||
|
access_ab_ai_bot_user,ab.ai.bot user,model_ab_ai_bot,group_ai_user,1,0,0,0
|
||||||
|
access_ab_ai_directive_manager,ab.ai.directive manager,model_ab_ai_directive,group_ai_manager,1,1,1,1
|
||||||
|
access_ab_ai_directive_user,ab.ai.directive user,model_ab_ai_directive,group_ai_user,1,0,0,0
|
||||||
|
access_ab_ai_log_manager,ab.ai.log manager,model_ab_ai_log,group_ai_manager,1,1,1,1
|
||||||
|
access_ab_ai_log_user,ab.ai.log user,model_ab_ai_log,group_ai_user,1,0,0,0
|
||||||
|
access_ab_ai_registry_manager,ab.ai.agent.registry manager,model_ab_ai_agent_registry,group_ai_manager,1,1,1,1
|
||||||
|
access_ab_ai_registry_user,ab.ai.agent.registry user,model_ab_ai_agent_registry,group_ai_user,1,0,0,0
|
||||||
|
24
addons/activeblue_ai/security/res_groups.xml
Normal file
24
addons/activeblue_ai/security/res_groups.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="module_activeblue_ai" model="ir.module.category">
|
||||||
|
<field name="name">ActiveBlue AI</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_ai_user" model="res.groups">
|
||||||
|
<field name="name">AI User</field>
|
||||||
|
<field name="category_id" ref="module_activeblue_ai"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
<field name="comment">Can use the AI chat assistant</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_ai_manager" model="res.groups">
|
||||||
|
<field name="name">AI Manager</field>
|
||||||
|
<field name="category_id" ref="module_activeblue_ai"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_ai_user'))]"/>
|
||||||
|
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||||
|
<field name="comment">Can configure AI, approve directives, and view all logs</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
255
addons/activeblue_ai/static/src/css/activeblue_ai.css
Normal file
255
addons/activeblue_ai/static/src/css/activeblue_ai.css
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/* ActiveBlue AI — systray + panel styles */
|
||||||
|
|
||||||
|
/* Systray brain icon */
|
||||||
|
.ab-ai-systray {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-brain-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: #dc3545;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 10px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide-in panel */
|
||||||
|
.ab-ai-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: -420px;
|
||||||
|
width: 400px;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 9000;
|
||||||
|
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border-left: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel--open {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #1a2b4b;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel__header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator dot */
|
||||||
|
.ab-ai-status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
.ab-ai-status-dot--online { background: #28a745; }
|
||||||
|
.ab-ai-status-dot--offline { background: #dc3545; }
|
||||||
|
.ab-ai-status-dot--degraded { background: #ffc107; }
|
||||||
|
|
||||||
|
/* Messages area */
|
||||||
|
.ab-ai-panel__messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message bubbles */
|
||||||
|
.ab-ai-msg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-msg--user {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-msg--assistant,
|
||||||
|
.ab-ai-msg--error {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-msg__bubble {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-msg--user .ab-ai-msg__bubble {
|
||||||
|
background: #1a2b4b;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-msg--assistant .ab-ai-msg__bubble {
|
||||||
|
background: #f1f3f5;
|
||||||
|
color: #212529;
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-msg--error .ab-ai-msg__bubble {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading dots */
|
||||||
|
.ab-ai-msg__bubble--loading {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #adb5bd;
|
||||||
|
animation: ab-ai-bounce 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
.ab-ai-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.ab-ai-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes ab-ai-bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
|
||||||
|
40% { transform: scale(1.2); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Escalations */
|
||||||
|
.ab-ai-msg__escalations {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-escalation {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Approval section */
|
||||||
|
.ab-ai-panel__approvals {
|
||||||
|
background: #fff8e1;
|
||||||
|
border-top: 1px solid #ffe082;
|
||||||
|
padding: 12px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-approval-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-approval-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input area */
|
||||||
|
.ab-ai-panel__input {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
background: #f8f9fa;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel__textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel__textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a2b4b;
|
||||||
|
box-shadow: 0 0 0 2px rgba(26, 43, 75, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel__send {
|
||||||
|
align-self: flex-end;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1a2b4b;
|
||||||
|
border-color: #1a2b4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-ai-panel__send:hover:not(:disabled) {
|
||||||
|
background: #243660;
|
||||||
|
border-color: #243660;
|
||||||
|
}
|
||||||
131
addons/activeblue_ai/static/src/js/components/ai_panel.js
Normal file
131
addons/activeblue_ai/static/src/js/components/ai_panel.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
import { Component, useState, useRef, onMounted, onWillUnmount } from '@odoo/owl';
|
||||||
|
import { useService } from '@web/core/utils/hooks';
|
||||||
|
import { registry } from '@web/core/registry';
|
||||||
|
|
||||||
|
let _msgCounter = 0;
|
||||||
|
function nextId() { return ++_msgCounter; }
|
||||||
|
|
||||||
|
export class AiPanel extends Component {
|
||||||
|
static template = 'activeblue_ai.AiPanel';
|
||||||
|
static props = {
|
||||||
|
isOpen: Boolean,
|
||||||
|
onClose: Function,
|
||||||
|
serviceStatus: { type: String, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.rpc = useService('rpc');
|
||||||
|
this.notification = useService('notification');
|
||||||
|
this.messagesRef = useRef('messagesContainer');
|
||||||
|
this.inputRef = useRef('inputEl');
|
||||||
|
|
||||||
|
this.state = useState({
|
||||||
|
messages: [],
|
||||||
|
inputText: '',
|
||||||
|
loading: false,
|
||||||
|
pendingApprovals: [],
|
||||||
|
sessionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._approvalPollInterval = null;
|
||||||
|
onMounted(() => {
|
||||||
|
this._startApprovalPoll();
|
||||||
|
});
|
||||||
|
onWillUnmount(() => {
|
||||||
|
if (this._approvalPollInterval) {
|
||||||
|
clearInterval(this._approvalPollInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusDotClass() {
|
||||||
|
const s = this.props.serviceStatus || 'unknown';
|
||||||
|
return `ab-ai-status-dot ab-ai-status-dot--${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusText() {
|
||||||
|
return this.props.serviceStatus === 'online' ? 'Online' : 'Offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(ev) {
|
||||||
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage() {
|
||||||
|
const text = this.state.inputText.trim();
|
||||||
|
if (!text || this.state.loading) return;
|
||||||
|
|
||||||
|
this.state.messages.push({ id: nextId(), role: 'user', content: text });
|
||||||
|
this.state.inputText = '';
|
||||||
|
this.state.loading = true;
|
||||||
|
this._scrollToBottom();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.rpc('/ai/chat', {
|
||||||
|
message: text,
|
||||||
|
context: {},
|
||||||
|
session_id: this.state.sessionId,
|
||||||
|
});
|
||||||
|
const reply = result.reply || '(no response)';
|
||||||
|
this.state.messages.push({
|
||||||
|
id: nextId(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: reply,
|
||||||
|
escalations: result.escalations || [],
|
||||||
|
});
|
||||||
|
if (result.session_id) {
|
||||||
|
this.state.sessionId = result.session_id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.state.messages.push({
|
||||||
|
id: nextId(),
|
||||||
|
role: 'error',
|
||||||
|
content: 'Error reaching AI service. Please try again.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
this._scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(directiveId, approved) {
|
||||||
|
try {
|
||||||
|
await this.rpc('/ai/approval/respond', {
|
||||||
|
directive_id: directiveId,
|
||||||
|
approved,
|
||||||
|
});
|
||||||
|
this.state.pendingApprovals = this.state.pendingApprovals.filter(
|
||||||
|
(a) => a.directive_id !== directiveId
|
||||||
|
);
|
||||||
|
this.notification.add(approved ? 'Directive approved' : 'Directive rejected', {
|
||||||
|
type: approved ? 'success' : 'warning',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add('Failed to respond to approval request', { type: 'danger' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startApprovalPoll() {
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const result = await this.rpc('/ai/approval/pending', {});
|
||||||
|
this.state.pendingApprovals = result.items || [];
|
||||||
|
} catch {
|
||||||
|
// Silently ignore poll errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
this._approvalPollInterval = setInterval(poll, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scrollToBottom() {
|
||||||
|
const el = this.messagesRef.el;
|
||||||
|
if (el) {
|
||||||
|
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
import { Component, useState, onMounted, onWillUnmount } from '@odoo/owl';
|
||||||
|
import { registry } from '@web/core/registry';
|
||||||
|
import { useService } from '@web/core/utils/hooks';
|
||||||
|
import { AiPanel } from './ai_panel';
|
||||||
|
|
||||||
|
export class SystrayButton extends Component {
|
||||||
|
static template = 'activeblue_ai.SystrayButton';
|
||||||
|
static components = { AiPanel };
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.rpc = useService('rpc');
|
||||||
|
this.state = useState({
|
||||||
|
panelOpen: false,
|
||||||
|
serviceStatus: 'unknown',
|
||||||
|
pendingCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._statusInterval = null;
|
||||||
|
onMounted(() => {
|
||||||
|
this._checkStatus();
|
||||||
|
this._statusInterval = setInterval(() => this._checkStatus(), 60000);
|
||||||
|
});
|
||||||
|
onWillUnmount(() => {
|
||||||
|
if (this._statusInterval) clearInterval(this._statusInterval);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get iconClass() {
|
||||||
|
return {
|
||||||
|
'text-success': this.state.serviceStatus === 'online',
|
||||||
|
'text-danger': this.state.serviceStatus === 'offline',
|
||||||
|
'text-muted': this.state.serviceStatus === 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusTitle() {
|
||||||
|
return `ActiveBlue AI — ${this.state.serviceStatus}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePanel() {
|
||||||
|
this.state.panelOpen = !this.state.panelOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
closePanel() {
|
||||||
|
this.state.panelOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _checkStatus() {
|
||||||
|
try {
|
||||||
|
const result = await fetch('/ai/health');
|
||||||
|
if (result.ok) {
|
||||||
|
const data = await result.json();
|
||||||
|
this.state.serviceStatus = data.status === 'ok' ? 'online' : 'degraded';
|
||||||
|
} else {
|
||||||
|
this.state.serviceStatus = 'offline';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.state.serviceStatus = 'offline';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pending = await this.rpc('/ai/approval/pending', {});
|
||||||
|
this.state.pendingCount = (pending.items || []).length;
|
||||||
|
} catch {
|
||||||
|
this.state.pendingCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category('systray').add('activeblue_ai.systray_button', {
|
||||||
|
Component: SystrayButton,
|
||||||
|
sequence: 1,
|
||||||
|
});
|
||||||
83
addons/activeblue_ai/static/src/xml/ai_panel.xml
Normal file
83
addons/activeblue_ai/static/src/xml/ai_panel.xml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="activeblue_ai.AiPanel" owl="1">
|
||||||
|
<div class="ab-ai-panel" t-att-class="{ 'ab-ai-panel--open': props.isOpen }">
|
||||||
|
<div class="ab-ai-panel__header">
|
||||||
|
<span class="ab-ai-panel__title">
|
||||||
|
<i class="fa fa-brain"/> ActiveBlue AI
|
||||||
|
</span>
|
||||||
|
<div class="ab-ai-panel__header-actions">
|
||||||
|
<span t-att-class="statusDotClass" t-att-title="statusText"/>
|
||||||
|
<button class="btn btn-sm btn-light" t-on-click="props.onClose" title="Close">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ab-ai-panel__messages" t-ref="messagesContainer">
|
||||||
|
<t t-if="state.messages.length === 0">
|
||||||
|
<div class="ab-ai-panel__empty">
|
||||||
|
<i class="fa fa-brain fa-2x text-muted"/>
|
||||||
|
<p class="text-muted mt-2">Ask me anything about your Odoo data.</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-foreach="state.messages" t-as="msg" t-key="msg.id">
|
||||||
|
<div t-att-class="'ab-ai-msg ab-ai-msg--' + msg.role">
|
||||||
|
<div class="ab-ai-msg__bubble">
|
||||||
|
<t t-esc="msg.content"/>
|
||||||
|
</div>
|
||||||
|
<t t-if="msg.escalations and msg.escalations.length">
|
||||||
|
<div class="ab-ai-msg__escalations">
|
||||||
|
<i class="fa fa-exclamation-triangle text-warning"/> Escalations:
|
||||||
|
<t t-foreach="msg.escalations" t-as="esc" t-key="esc_index">
|
||||||
|
<div class="ab-ai-escalation" t-esc="esc"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-if="state.loading">
|
||||||
|
<div class="ab-ai-msg ab-ai-msg--assistant">
|
||||||
|
<div class="ab-ai-msg__bubble ab-ai-msg__bubble--loading">
|
||||||
|
<span class="ab-ai-dot"/><span class="ab-ai-dot"/><span class="ab-ai-dot"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="state.pendingApprovals.length > 0">
|
||||||
|
<div class="ab-ai-panel__approvals">
|
||||||
|
<strong><i class="fa fa-clock-o"/> Pending Approval</strong>
|
||||||
|
<t t-foreach="state.pendingApprovals" t-as="item" t-key="item.directive_id">
|
||||||
|
<div class="ab-ai-approval-item">
|
||||||
|
<span t-esc="item.description"/>
|
||||||
|
<div class="ab-ai-approval-btns">
|
||||||
|
<button class="btn btn-sm btn-success" t-on-click="() => this.approve(item.directive_id, true)">
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" t-on-click="() => this.approve(item.directive_id, false)">
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<div class="ab-ai-panel__input">
|
||||||
|
<textarea
|
||||||
|
class="ab-ai-panel__textarea"
|
||||||
|
t-ref="inputEl"
|
||||||
|
t-model="state.inputText"
|
||||||
|
placeholder="Ask ActiveBlue AI..."
|
||||||
|
t-on-keydown="onKeyDown"
|
||||||
|
rows="2"
|
||||||
|
t-att-disabled="state.loading"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary ab-ai-panel__send" t-on-click="sendMessage" t-att-disabled="state.loading or !state.inputText.trim()">
|
||||||
|
<i class="fa fa-paper-plane"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
9
addons/activeblue_ai/static/src/xml/systray.xml
Normal file
9
addons/activeblue_ai/static/src/xml/systray.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="activeblue_ai.SystrayButton" owl="1">
|
||||||
|
<div class="o_menu_systray_item ab-ai-systray" t-on-click="togglePanel" t-att-title="statusTitle">
|
||||||
|
<i class="fa fa-brain ab-ai-brain-icon" t-att-class="iconClass"/>
|
||||||
|
<span t-if="state.pendingCount > 0" class="ab-ai-badge" t-esc="state.pendingCount"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
59
addons/activeblue_ai/views/ab_ai_bot_views.xml
Normal file
59
addons/activeblue_ai/views/ab_ai_bot_views.xml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_ab_ai_bot_form" model="ir.ui.view">
|
||||||
|
<field name="name">ab.ai.bot.form</field>
|
||||||
|
<field name="model">ab.ai.bot</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="AI Bot Configuration">
|
||||||
|
<header>
|
||||||
|
<button name="action_ping" string="Ping Service" type="object" class="btn-primary"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box"/>
|
||||||
|
<widget name="web_ribbon" title="Offline" bg_color="bg-danger"
|
||||||
|
attrs="{'invisible': [('status', '!=', 'offline')]}"/>
|
||||||
|
<widget name="web_ribbon" title="Online" bg_color="bg-success"
|
||||||
|
attrs="{'invisible': [('status', '!=', 'online')]}"/>
|
||||||
|
<widget name="web_ribbon" title="Error" bg_color="bg-warning"
|
||||||
|
attrs="{'invisible': [('status', '!=', 'error')]}"/>
|
||||||
|
<group>
|
||||||
|
<group string="Configuration">
|
||||||
|
<field name="display_name"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
<field name="agent_service_url"/>
|
||||||
|
<field name="webhook_secret" password="True"/>
|
||||||
|
<field name="privacy_mode"/>
|
||||||
|
</group>
|
||||||
|
<group string="Status">
|
||||||
|
<field name="status" readonly="1"/>
|
||||||
|
<field name="last_ping" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Notes">
|
||||||
|
<field name="notes" nolabel="1" colspan="2"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_ab_ai_bot_list" model="ir.ui.view">
|
||||||
|
<field name="name">ab.ai.bot.list</field>
|
||||||
|
<field name="model">ab.ai.bot</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="AI Bots">
|
||||||
|
<field name="display_name"/>
|
||||||
|
<field name="agent_service_url"/>
|
||||||
|
<field name="privacy_mode"/>
|
||||||
|
<field name="status"/>
|
||||||
|
<field name="last_ping"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ab_ai_bot" model="ir.actions.act_window">
|
||||||
|
<field name="name">AI Bot Configuration</field>
|
||||||
|
<field name="res_model">ab.ai.bot</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
77
addons/activeblue_ai/views/ab_ai_directive_views.xml
Normal file
77
addons/activeblue_ai/views/ab_ai_directive_views.xml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_ab_ai_directive_list" model="ir.ui.view">
|
||||||
|
<field name="name">ab.ai.directive.list</field>
|
||||||
|
<field name="model">ab.ai.directive</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="AI Directives" decoration-danger="status == 'failed'" decoration-warning="status == 'pending_approval'">
|
||||||
|
<field name="create_date" string="Date"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="message" string="User Message"/>
|
||||||
|
<field name="status"/>
|
||||||
|
<field name="agents_involved"/>
|
||||||
|
<field name="duration_ms" string="ms"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_ab_ai_directive_form" model="ir.ui.view">
|
||||||
|
<field name="name">ab.ai.directive.form</field>
|
||||||
|
<field name="model">ab.ai.directive</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="AI Directive">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="directive_id"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="status"/>
|
||||||
|
<field name="agents_involved"/>
|
||||||
|
<field name="duration_ms"/>
|
||||||
|
<field name="session_id"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Message">
|
||||||
|
<field name="message" nolabel="1" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="AI Reply">
|
||||||
|
<field name="reply" nolabel="1" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Escalations" attrs="{'invisible': [('escalations', '=', False)]}">
|
||||||
|
<field name="escalations" nolabel="1" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Actions Taken" attrs="{'invisible': [('actions_taken', '=', False)]}">
|
||||||
|
<field name="actions_taken" nolabel="1" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Error" attrs="{'invisible': [('error_message', '=', False)]}">
|
||||||
|
<field name="error_message" nolabel="1" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_ab_ai_directive_search" model="ir.ui.view">
|
||||||
|
<field name="name">ab.ai.directive.search</field>
|
||||||
|
<field name="model">ab.ai.directive</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="status"/>
|
||||||
|
<filter name="pending_approval" string="Pending Approval" domain="[('status','=','pending_approval')]"/>
|
||||||
|
<filter name="failed" string="Failed" domain="[('status','in',['failed','timeout'])]"/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter name="group_by_user" string="User" context="{'group_by': 'user_id'}"/>
|
||||||
|
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ab_ai_directive" model="ir.actions.act_window">
|
||||||
|
<field name="name">AI Directives</field>
|
||||||
|
<field name="res_model">ab.ai.directive</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_ab_ai_directive_search"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
23
addons/activeblue_ai/views/ab_ai_log_views.xml
Normal file
23
addons/activeblue_ai/views/ab_ai_log_views.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_ab_ai_log_list" model="ir.ui.view">
|
||||||
|
<field name="name">ab.ai.log.list</field>
|
||||||
|
<field name="model">ab.ai.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="AI Logs" decoration-danger="level == 'error'" decoration-warning="level == 'warning'">
|
||||||
|
<field name="create_date" string="Date"/>
|
||||||
|
<field name="level"/>
|
||||||
|
<field name="agent_name"/>
|
||||||
|
<field name="summary"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ab_ai_log" model="ir.actions.act_window">
|
||||||
|
<field name="name">AI Logs</field>
|
||||||
|
<field name="res_model">ab.ai.log</field>
|
||||||
|
<field name="view_mode">list</field>
|
||||||
|
<field name="domain">[('level', 'in', ['info','warning','error'])]</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
26
addons/activeblue_ai/views/ab_ai_registry_views.xml
Normal file
26
addons/activeblue_ai/views/ab_ai_registry_views.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_ab_ai_registry_list" model="ir.ui.view">
|
||||||
|
<field name="name">ab.ai.agent.registry.list</field>
|
||||||
|
<field name="model">ab.ai.agent.registry</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Agent Registry">
|
||||||
|
<field name="agent_name"/>
|
||||||
|
<field name="domain"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
<field name="backend"/>
|
||||||
|
<field name="last_sweep"/>
|
||||||
|
<field name="sweep_count"/>
|
||||||
|
<field name="error_count"/>
|
||||||
|
<button name="action_set_backend_claude" string="→ Claude" type="object" icon="fa-cloud"/>
|
||||||
|
<button name="action_set_backend_ollama" string="→ Ollama" type="object" icon="fa-server"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ab_ai_registry" model="ir.actions.act_window">
|
||||||
|
<field name="name">Agent Registry</field>
|
||||||
|
<field name="res_model">ab.ai.agent.registry</field>
|
||||||
|
<field name="view_mode">list</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
42
addons/activeblue_ai/views/menus.xml
Normal file
42
addons/activeblue_ai/views/menus.xml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_activeblue_ai_root"
|
||||||
|
name="ActiveBlue AI"
|
||||||
|
sequence="99"
|
||||||
|
web_icon="activeblue_ai,static/src/img/icon.png"
|
||||||
|
groups="activeblue_ai.group_ai_user"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_ai_directives"
|
||||||
|
name="Directives"
|
||||||
|
parent="menu_activeblue_ai_root"
|
||||||
|
action="action_ab_ai_directive"
|
||||||
|
sequence="10"
|
||||||
|
groups="activeblue_ai.group_ai_user"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_ai_logs"
|
||||||
|
name="Activity Logs"
|
||||||
|
parent="menu_activeblue_ai_root"
|
||||||
|
action="action_ab_ai_log"
|
||||||
|
sequence="20"
|
||||||
|
groups="activeblue_ai.group_ai_manager"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_ai_config"
|
||||||
|
name="Configuration"
|
||||||
|
parent="menu_activeblue_ai_root"
|
||||||
|
sequence="90"
|
||||||
|
groups="activeblue_ai.group_ai_manager"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_ai_bot_config"
|
||||||
|
name="Bot Settings"
|
||||||
|
parent="menu_ai_config"
|
||||||
|
action="action_ab_ai_bot"
|
||||||
|
sequence="10"
|
||||||
|
groups="activeblue_ai.group_ai_manager"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_ai_registry"
|
||||||
|
name="Agent Registry"
|
||||||
|
parent="menu_ai_config"
|
||||||
|
action="action_ab_ai_registry"
|
||||||
|
sequence="20"
|
||||||
|
groups="activeblue_ai.group_ai_manager"/>
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user