From d87f3c3e992952751b6f5b8a9d87ea80f4b40b3c Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Sat, 16 May 2026 12:27:03 -0400 Subject: [PATCH] Non-blocking agent dispatch: run LLM call in background thread message_post now returns immediately after collecting attachment data. The agent HTTP call and reply posting happen in a daemon thread, so Odoo commits the user's message and the browser confirms receipt right away -- instead of waiting 10+ seconds for Ollama to respond. File clarification (no LLM) still posts inline since it's instant. The background thread opens its own DB cursor to post the bot reply. Co-Authored-By: Claude Sonnet 4.6 --- addons/activeblue_ai/models/ab_ai_mail.py | 132 +++++++++++++++------- 1 file changed, 94 insertions(+), 38 deletions(-) diff --git a/addons/activeblue_ai/models/ab_ai_mail.py b/addons/activeblue_ai/models/ab_ai_mail.py index 3ea938c..29fd17d 100644 --- a/addons/activeblue_ai/models/ab_ai_mail.py +++ b/addons/activeblue_ai/models/ab_ai_mail.py @@ -3,10 +3,12 @@ import base64 import io import logging import re +import threading import zipfile +import requests as _requests from markupsafe import Markup, escape -from odoo import models, api +from odoo import SUPERUSER_ID, api, registry as odoo_registry, models _logger = logging.getLogger(__name__) @@ -34,7 +36,7 @@ def _ext(filename: str) -> str: def _text_to_html(text: str) -> Markup: - """Convert plain text to HTML — escapes content, turns newlines into
.""" + """Convert plain text to HTML -- escapes content, turns newlines into
.""" return Markup('
').join(Markup(escape(line)) for line in text.split('\n')) @@ -62,6 +64,66 @@ def _describe_zip(datas_b64: str, zip_name: str) -> str: return f'{zip_name} (could not inspect contents)' +def _post_bot_reply(db: str, channel_id: int, bot_partner_id: int, reply_text: str): + """Open a fresh DB cursor and post the bot reply to the channel.""" + try: + with odoo_registry(db).cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + env['discuss.channel'].browse(channel_id).sudo().message_post( + body=_text_to_html(reply_text), + author_id=bot_partner_id, + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + cr.commit() + except Exception as exc: + _logger.error('_post_bot_reply failed channel=%s: %s', channel_id, exc) + + +def _agent_thread(db: str, uid: int, text: str, att_data: list, + bot_partner_id: int, channel_id: int, + bot_url: str, bot_secret: str): + """ + Background thread: calls the agent service and posts the reply. + Runs entirely outside the Odoo HTTP request so message_post returns + immediately and the user sees their message without waiting for the LLM. + """ + try: + headers = {} + if bot_secret: + headers['X-ActiveBlue-Signature'] = bot_secret + + if att_data: + files = [('files', (name, data, mime)) for name, data, mime in att_data] + if not files: + files = [('files', ('empty', b'', 'text/plain'))] + form = { + 'user_id': str(uid), + 'message': text or 'Create an employee expense report from these receipts.', + 'session_id': '', + } + resp = _requests.post(bot_url + '/upload', data=form, files=files, + headers=headers, timeout=600) + else: + payload = {'user_id': str(uid), 'message': text, + 'context': {'source': 'discuss'}} + headers['Content-Type'] = 'application/json' + resp = _requests.post(bot_url + '/dispatch', json=payload, + headers=headers, timeout=600) + + resp.raise_for_status() + response = resp.json() + reply_text = (response or {}).get('reply') or (response or {}).get('message') or \ + 'I could not process your request right now.' + except _requests.exceptions.Timeout: + reply_text = 'The request timed out. Please try again.' + except Exception as exc: + _logger.error('Agent thread error channel=%s: %s', channel_id, exc) + reply_text = 'I encountered an error. Please try again or contact your administrator.' + + _post_bot_reply(db, channel_id, bot_partner_id, reply_text) + + class DiscussChannel(models.Model): _inherit = 'discuss.channel' @@ -90,9 +152,6 @@ class DiscussChannel(models.Model): text = _strip_html(body) - # Odoo 18 Discuss uploads attachments before posting the message and - # passes their IDs in kwargs as attachment_ids (list of ints or ORM - # commands). result.attachment_ids resolves those after super() runs. _logger.info( 'AB AI mail hook: body=%r kwargs_keys=%s ' 'attachment_ids_kwarg=%r result.attachment_ids=%s', @@ -104,59 +163,56 @@ class DiscussChannel(models.Model): attachments = result.attachment_ids - # Nothing to work with if not text and not attachments: return result # -- Case 1: file(s) with no instruction -------------------------------- - # Show the user what we received and ask what to do with it. + # Clarification is quick (no LLM) -- post inline, no thread needed. if attachments and not text: self._post_file_clarification(attachments, bot_partner) return result - # -- Case 2: text only -- look back for a pending file upload ----------- - # If the user just replied to our clarification question, find the - # attachment(s) they uploaded earlier in this conversation. + # -- Case 2: text (possibly with pending files from earlier upload) ----- pending = self.env['ir.attachment'].browse() if text and not attachments: pending = self._find_pending_attachments(bot_partner) effective_attachments = attachments or pending - # -- Case 3: text (+ possibly pending files) -> dispatch ---------------- human_partner = member_partners.filtered(lambda p: p != bot_partner)[:1] user = self.env['res.users'].search([('partner_id', '=', human_partner.id)], limit=1) uid = user.id if user else self.env.uid - try: - bot = self.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1) - if not bot: - return result + bot = self.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1) + if not bot: + return result - if effective_attachments: - response = bot.dispatch_message_with_files( - user_id=uid, - message=text, - attachments=effective_attachments, - context={'channel_id': self.id, 'source': 'discuss'}, - ) - else: - response = bot.dispatch_message( - user_id=uid, - message=text, - context={'channel_id': self.id, 'source': 'discuss'}, - ) + # Read everything we need from the DB NOW (current transaction) before + # the background thread starts. The thread must not touch ORM objects + # from this transaction. + db = self.env.cr.dbname + bot_url = bot._get_service_url() + bot_secret = bot.webhook_secret or '' + channel_id = self.id + bot_partner_id = bot_partner.id - reply_text = ((response or {}).get('reply') or (response or {}).get('message') or - 'I could not process your request right now.') - self.sudo().message_post( - body=_text_to_html(reply_text), - author_id=bot_partner.id, - message_type='comment', - subtype_xmlid='mail.mt_comment', - ) - except Exception as exc: - _logger.error('AI bot Discuss reply failed: %s', exc) + att_data: list[tuple[str, bytes, str]] = [] + for att in effective_attachments: + try: + data = base64.b64decode(att.datas) if att.datas else b'' + att_data.append((att.name or 'attachment', data, + att.mimetype or 'application/octet-stream')) + except Exception as exc: + _logger.warning('Could not read attachment %s: %s', att.id, exc) + + # Launch the agent call in a daemon thread so this message_post returns + # immediately -- the user sees their message without waiting for the LLM. + threading.Thread( + target=_agent_thread, + args=(db, uid, text, att_data, bot_partner_id, channel_id, + bot_url, bot_secret), + daemon=True, + ).start() return result