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:
ActiveBlue Build
2026-04-12 17:59:02 -04:00
parent 430ab966b2
commit 29409ed71d
24 changed files with 1394 additions and 0 deletions

View File

@@ -0,0 +1 @@
from . import models, controllers

View 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,
}

View File

@@ -0,0 +1 @@
from . import webhook, health_proxy, approval

View 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

View 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,
)

View 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)

View 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>

View File

@@ -0,0 +1 @@
from . import ab_ai_bot, ab_ai_directive, ab_ai_log, ab_ai_registry

View 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)

View 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)

View 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()

View 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)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ab_ai_bot_manager ab.ai.bot manager model_ab_ai_bot group_ai_manager 1 1 1 1
3 access_ab_ai_bot_user ab.ai.bot user model_ab_ai_bot group_ai_user 1 0 0 0
4 access_ab_ai_directive_manager ab.ai.directive manager model_ab_ai_directive group_ai_manager 1 1 1 1
5 access_ab_ai_directive_user ab.ai.directive user model_ab_ai_directive group_ai_user 1 0 0 0
6 access_ab_ai_log_manager ab.ai.log manager model_ab_ai_log group_ai_manager 1 1 1 1
7 access_ab_ai_log_user ab.ai.log user model_ab_ai_log group_ai_user 1 0 0 0
8 access_ab_ai_registry_manager ab.ai.agent.registry manager model_ab_ai_agent_registry group_ai_manager 1 1 1 1
9 access_ab_ai_registry_user ab.ai.agent.registry user model_ab_ai_agent_registry group_ai_user 1 0 0 0

View 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>

View 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;
}

View 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);
}
}
}

View File

@@ -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,
});

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>