diff --git a/addons/activeblue_ai/__init__.py b/addons/activeblue_ai/__init__.py new file mode 100644 index 0000000..76a74f9 --- /dev/null +++ b/addons/activeblue_ai/__init__.py @@ -0,0 +1 @@ +from . import models, controllers diff --git a/addons/activeblue_ai/__manifest__.py b/addons/activeblue_ai/__manifest__.py new file mode 100644 index 0000000..496ada3 --- /dev/null +++ b/addons/activeblue_ai/__manifest__.py @@ -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, +} diff --git a/addons/activeblue_ai/controllers/__init__.py b/addons/activeblue_ai/controllers/__init__.py new file mode 100644 index 0000000..b7b0bec --- /dev/null +++ b/addons/activeblue_ai/controllers/__init__.py @@ -0,0 +1 @@ +from . import webhook, health_proxy, approval diff --git a/addons/activeblue_ai/controllers/approval.py b/addons/activeblue_ai/controllers/approval.py new file mode 100644 index 0000000..940f4fb --- /dev/null +++ b/addons/activeblue_ai/controllers/approval.py @@ -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 diff --git a/addons/activeblue_ai/controllers/health_proxy.py b/addons/activeblue_ai/controllers/health_proxy.py new file mode 100644 index 0000000..6ef1c4f --- /dev/null +++ b/addons/activeblue_ai/controllers/health_proxy.py @@ -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, + ) diff --git a/addons/activeblue_ai/controllers/webhook.py b/addons/activeblue_ai/controllers/webhook.py new file mode 100644 index 0000000..8fb85dd --- /dev/null +++ b/addons/activeblue_ai/controllers/webhook.py @@ -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) diff --git a/addons/activeblue_ai/data/ir_cron.xml b/addons/activeblue_ai/data/ir_cron.xml new file mode 100644 index 0000000..e2e8bcb --- /dev/null +++ b/addons/activeblue_ai/data/ir_cron.xml @@ -0,0 +1,48 @@ + + + + + ActiveBlue AI: Ping Service + + code + model.cron_ping_all() + 5 + minutes + -1 + True + + + + ActiveBlue AI: Sync Agent Registry + + code + model.sync_from_service() + 1 + hours + -1 + True + + + + ActiveBlue AI: Clean Up Old Directives + + code + model.cron_cleanup_old() + 1 + days + -1 + True + + + + ActiveBlue AI: Clean Up Old Logs + + code + model.cron_cleanup() + 1 + days + -1 + True + + + diff --git a/addons/activeblue_ai/models/__init__.py b/addons/activeblue_ai/models/__init__.py new file mode 100644 index 0000000..37413e9 --- /dev/null +++ b/addons/activeblue_ai/models/__init__.py @@ -0,0 +1 @@ +from . import ab_ai_bot, ab_ai_directive, ab_ai_log, ab_ai_registry diff --git a/addons/activeblue_ai/models/ab_ai_bot.py b/addons/activeblue_ai/models/ab_ai_bot.py new file mode 100644 index 0000000..3bfc2f7 --- /dev/null +++ b/addons/activeblue_ai/models/ab_ai_bot.py @@ -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) diff --git a/addons/activeblue_ai/models/ab_ai_directive.py b/addons/activeblue_ai/models/ab_ai_directive.py new file mode 100644 index 0000000..06cfbf7 --- /dev/null +++ b/addons/activeblue_ai/models/ab_ai_directive.py @@ -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) diff --git a/addons/activeblue_ai/models/ab_ai_log.py b/addons/activeblue_ai/models/ab_ai_log.py new file mode 100644 index 0000000..b2e793a --- /dev/null +++ b/addons/activeblue_ai/models/ab_ai_log.py @@ -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() diff --git a/addons/activeblue_ai/models/ab_ai_registry.py b/addons/activeblue_ai/models/ab_ai_registry.py new file mode 100644 index 0000000..42afcf6 --- /dev/null +++ b/addons/activeblue_ai/models/ab_ai_registry.py @@ -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) diff --git a/addons/activeblue_ai/security/ir.model.access.csv b/addons/activeblue_ai/security/ir.model.access.csv new file mode 100644 index 0000000..c4a5455 --- /dev/null +++ b/addons/activeblue_ai/security/ir.model.access.csv @@ -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 diff --git a/addons/activeblue_ai/security/res_groups.xml b/addons/activeblue_ai/security/res_groups.xml new file mode 100644 index 0000000..36e614b --- /dev/null +++ b/addons/activeblue_ai/security/res_groups.xml @@ -0,0 +1,24 @@ + + + + + ActiveBlue AI + 50 + + + + AI User + + + Can use the AI chat assistant + + + + AI Manager + + + + Can configure AI, approve directives, and view all logs + + + diff --git a/addons/activeblue_ai/static/src/css/activeblue_ai.css b/addons/activeblue_ai/static/src/css/activeblue_ai.css new file mode 100644 index 0000000..e28a933 --- /dev/null +++ b/addons/activeblue_ai/static/src/css/activeblue_ai.css @@ -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; +} diff --git a/addons/activeblue_ai/static/src/js/components/ai_panel.js b/addons/activeblue_ai/static/src/js/components/ai_panel.js new file mode 100644 index 0000000..3fd9e3d --- /dev/null +++ b/addons/activeblue_ai/static/src/js/components/ai_panel.js @@ -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); + } + } +} diff --git a/addons/activeblue_ai/static/src/js/components/systray_button.js b/addons/activeblue_ai/static/src/js/components/systray_button.js new file mode 100644 index 0000000..ca7831c --- /dev/null +++ b/addons/activeblue_ai/static/src/js/components/systray_button.js @@ -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, +}); diff --git a/addons/activeblue_ai/static/src/xml/ai_panel.xml b/addons/activeblue_ai/static/src/xml/ai_panel.xml new file mode 100644 index 0000000..d6955dc --- /dev/null +++ b/addons/activeblue_ai/static/src/xml/ai_panel.xml @@ -0,0 +1,83 @@ + + + + + + + ActiveBlue AI + + + + + + + + + + + + + + Ask me anything about your Odoo data. + + + + + + + + + + Escalations: + + + + + + + + + + + + + + + + + + + Pending Approval + + + + + + Approve + + + Reject + + + + + + + + + + + + + + + + diff --git a/addons/activeblue_ai/static/src/xml/systray.xml b/addons/activeblue_ai/static/src/xml/systray.xml new file mode 100644 index 0000000..b52e94e --- /dev/null +++ b/addons/activeblue_ai/static/src/xml/systray.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/activeblue_ai/views/ab_ai_bot_views.xml b/addons/activeblue_ai/views/ab_ai_bot_views.xml new file mode 100644 index 0000000..2aa8dab --- /dev/null +++ b/addons/activeblue_ai/views/ab_ai_bot_views.xml @@ -0,0 +1,59 @@ + + + + ab.ai.bot.form + ab.ai.bot + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ab.ai.bot.list + ab.ai.bot + + + + + + + + + + + + + AI Bot Configuration + ab.ai.bot + list,form + + diff --git a/addons/activeblue_ai/views/ab_ai_directive_views.xml b/addons/activeblue_ai/views/ab_ai_directive_views.xml new file mode 100644 index 0000000..2f28c46 --- /dev/null +++ b/addons/activeblue_ai/views/ab_ai_directive_views.xml @@ -0,0 +1,77 @@ + + + + ab.ai.directive.list + ab.ai.directive + + + + + + + + + + + + + + ab.ai.directive.form + ab.ai.directive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ab.ai.directive.search + ab.ai.directive + + + + + + + + + + + + + + + + AI Directives + ab.ai.directive + list,form + + + diff --git a/addons/activeblue_ai/views/ab_ai_log_views.xml b/addons/activeblue_ai/views/ab_ai_log_views.xml new file mode 100644 index 0000000..82337c9 --- /dev/null +++ b/addons/activeblue_ai/views/ab_ai_log_views.xml @@ -0,0 +1,23 @@ + + + + ab.ai.log.list + ab.ai.log + + + + + + + + + + + + + AI Logs + ab.ai.log + list + [('level', 'in', ['info','warning','error'])] + + diff --git a/addons/activeblue_ai/views/ab_ai_registry_views.xml b/addons/activeblue_ai/views/ab_ai_registry_views.xml new file mode 100644 index 0000000..109ee4a --- /dev/null +++ b/addons/activeblue_ai/views/ab_ai_registry_views.xml @@ -0,0 +1,26 @@ + + + + ab.ai.agent.registry.list + ab.ai.agent.registry + + + + + + + + + + + + + + + + + Agent Registry + ab.ai.agent.registry + list + + diff --git a/addons/activeblue_ai/views/menus.xml b/addons/activeblue_ai/views/menus.xml new file mode 100644 index 0000000..ad983d5 --- /dev/null +++ b/addons/activeblue_ai/views/menus.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + +
Ask me anything about your Odoo data.