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