From d7033a66d40cd8480dfcb51d573457a3bbaa89d7 Mon Sep 17 00:00:00 2001 From: Pierre Verkest Date: Tue, 1 Sep 2026 23:01:59 +0200 Subject: [PATCH 1/9] [ADD] ai_oca_native_base: Base module for ai_oca_native_* modules Assisted-by: Gemini 3.x Flash --- ai_oca_native_base/README.rst | 75 ++++ ai_oca_native_base/__init__.py | 2 + ai_oca_native_base/__manifest__.py | 21 + ai_oca_native_base/pyproject.toml | 3 + ai_oca_native_base/readme/DESCRIPTION.md | 1 + ai_oca_native_base/security/ai_security.xml | 25 ++ .../static/description/ai_brain.svg | 9 + .../static/description/icon.png | Bin 0 -> 3808 bytes .../static/description/index.html | 422 ++++++++++++++++++ ai_oca_native_base/tests/__init__.py | 3 + ai_oca_native_base/tests/test_base.py | 11 + ai_oca_native_base/views/ai_menus.xml | 12 + 12 files changed, 584 insertions(+) create mode 100644 ai_oca_native_base/README.rst create mode 100644 ai_oca_native_base/__init__.py create mode 100644 ai_oca_native_base/__manifest__.py create mode 100644 ai_oca_native_base/pyproject.toml create mode 100644 ai_oca_native_base/readme/DESCRIPTION.md create mode 100644 ai_oca_native_base/security/ai_security.xml create mode 100644 ai_oca_native_base/static/description/ai_brain.svg create mode 100644 ai_oca_native_base/static/description/icon.png create mode 100644 ai_oca_native_base/static/description/index.html create mode 100644 ai_oca_native_base/tests/__init__.py create mode 100644 ai_oca_native_base/tests/test_base.py create mode 100644 ai_oca_native_base/views/ai_menus.xml diff --git a/ai_oca_native_base/README.rst b/ai_oca_native_base/README.rst new file mode 100644 index 00000000..4811f2d3 --- /dev/null +++ b/ai_oca_native_base/README.rst @@ -0,0 +1,75 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============== +AI Native Base +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0cbbde7ce80754f43e2ff0ccb3c829459b33f0fc604223751c9da1b42e79a86d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/19.0/ai_oca_native_base + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-19-0/ai-19-0-ai_oca_native_base + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Base AI configuration root menu & security groups. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Pierre Verkest + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_oca_native_base/__init__.py b/ai_oca_native_base/__init__.py new file mode 100644 index 00000000..23cf76c1 --- /dev/null +++ b/ai_oca_native_base/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/ai_oca_native_base/__manifest__.py b/ai_oca_native_base/__manifest__.py new file mode 100644 index 00000000..a420f2a3 --- /dev/null +++ b/ai_oca_native_base/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "AI Native Base", + "summary": "Base AI configuration root menu & security groups", + "version": "19.0.1.0.0", + "category": "Services/AI", + "author": "Pierre Verkest, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "license": "AGPL-3", + "depends": [ + "base", + ], + "data": [ + "security/ai_security.xml", + "views/ai_menus.xml", + ], + "installable": True, + "application": False, +} diff --git a/ai_oca_native_base/pyproject.toml b/ai_oca_native_base/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_oca_native_base/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_oca_native_base/readme/DESCRIPTION.md b/ai_oca_native_base/readme/DESCRIPTION.md new file mode 100644 index 00000000..8df7da44 --- /dev/null +++ b/ai_oca_native_base/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Base AI configuration root menu & security groups. diff --git a/ai_oca_native_base/security/ai_security.xml b/ai_oca_native_base/security/ai_security.xml new file mode 100644 index 00000000..ddf8d84f --- /dev/null +++ b/ai_oca_native_base/security/ai_security.xml @@ -0,0 +1,25 @@ + + + + + AI User + + + Standard AI user capable of interacting with AI agents and threads. + + + + AI Administrator + + + Administrator with full control over AI prompts, personas, agents, and settings. + + diff --git a/ai_oca_native_base/static/description/ai_brain.svg b/ai_oca_native_base/static/description/ai_brain.svg new file mode 100644 index 00000000..a54ddac1 --- /dev/null +++ b/ai_oca_native_base/static/description/ai_brain.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ai_oca_native_base/static/description/icon.png b/ai_oca_native_base/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..91ba641b280a6ef0e7e3e3e57a4ca3b6dcb2233d GIT binary patch literal 3808 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 NxTR+buhwzE{2$TU5F!8o literal 0 HcmV?d00001 diff --git a/ai_oca_native_base/static/description/index.html b/ai_oca_native_base/static/description/index.html new file mode 100644 index 00000000..454f5d14 --- /dev/null +++ b/ai_oca_native_base/static/description/index.html @@ -0,0 +1,422 @@ + + + + + +AI Native Base + + + +
+ + + +Odoo Community Association + +
+

AI Native Base

+ +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

Base AI configuration root menu & security groups.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Pierre Verkest
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/ai_oca_native_base/tests/__init__.py b/ai_oca_native_base/tests/__init__.py new file mode 100644 index 00000000..5ea5eb88 --- /dev/null +++ b/ai_oca_native_base/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import test_base diff --git a/ai_oca_native_base/tests/test_base.py b/ai_oca_native_base/tests/test_base.py new file mode 100644 index 00000000..cb94ce8d --- /dev/null +++ b/ai_oca_native_base/tests/test_base.py @@ -0,0 +1,11 @@ +# Copyright 2026 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.tests.common import TransactionCase + + +class TestAiBase(TransactionCase): + def test_ai_base_groups_exist(self): + user_group = self.env.ref("ai_oca_native_base.group_ai_user") + admin_group = self.env.ref("ai_oca_native_base.group_ai_admin") + self.assertTrue(user_group.exists()) + self.assertTrue(admin_group.exists()) diff --git a/ai_oca_native_base/views/ai_menus.xml b/ai_oca_native_base/views/ai_menus.xml new file mode 100644 index 00000000..fae6f2b3 --- /dev/null +++ b/ai_oca_native_base/views/ai_menus.xml @@ -0,0 +1,12 @@ + + + + + From 0de3d12cf7863530dd36a6b112b98e0cd5d78e71 Mon Sep 17 00:00:00 2001 From: Pierre Verkest Date: Tue, 1 Sep 2026 14:45:23 +0200 Subject: [PATCH 2/9] [ADD] ai_oca_native_llm: AI LLM Integration Assisted-by: Gemini 3.x Flash --- ai_oca_native_llm/README.rst | 77 ++++ ai_oca_native_llm/__init__.py | 1 + ai_oca_native_llm/__manifest__.py | 19 + ai_oca_native_llm/models/__init__.py | 2 + ai_oca_native_llm/models/ai_llm_client.py | 101 +++++ .../models/res_config_settings.py | 32 ++ ai_oca_native_llm/pyproject.toml | 3 + ai_oca_native_llm/readme/DESCRIPTION.md | 2 + ai_oca_native_llm/readme/ROADMAP.md | 2 + .../static/description/index.html | 424 ++++++++++++++++++ ai_oca_native_llm/tests/__init__.py | 1 + ai_oca_native_llm/tests/common.py | 9 + ai_oca_native_llm/tests/fake_models.py | 7 + ai_oca_native_llm/tests/test_ai_llm_client.py | 182 ++++++++ .../views/res_config_settings_views.xml | 78 ++++ requirements.txt | 2 + 16 files changed, 942 insertions(+) create mode 100644 ai_oca_native_llm/README.rst create mode 100644 ai_oca_native_llm/__init__.py create mode 100644 ai_oca_native_llm/__manifest__.py create mode 100644 ai_oca_native_llm/models/__init__.py create mode 100644 ai_oca_native_llm/models/ai_llm_client.py create mode 100644 ai_oca_native_llm/models/res_config_settings.py create mode 100644 ai_oca_native_llm/pyproject.toml create mode 100644 ai_oca_native_llm/readme/DESCRIPTION.md create mode 100644 ai_oca_native_llm/readme/ROADMAP.md create mode 100644 ai_oca_native_llm/static/description/index.html create mode 100644 ai_oca_native_llm/tests/__init__.py create mode 100644 ai_oca_native_llm/tests/common.py create mode 100644 ai_oca_native_llm/tests/fake_models.py create mode 100644 ai_oca_native_llm/tests/test_ai_llm_client.py create mode 100644 ai_oca_native_llm/views/res_config_settings_views.xml create mode 100644 requirements.txt diff --git a/ai_oca_native_llm/README.rst b/ai_oca_native_llm/README.rst new file mode 100644 index 00000000..9a40ef11 --- /dev/null +++ b/ai_oca_native_llm/README.rst @@ -0,0 +1,77 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================= +Native AI LLM Integration +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c6d57c5b0f7a15f580843211db4c2ea0935e0b89601a4ba5af44a7dba8c489eb + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/19.0/ai_oca_native_llm + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-19-0/ai-19-0-ai_oca_native_llm + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Provides a basic Python client wrapper to communicate with a +local/remote Ollama instance. + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +- Transform this module as base module that provide abstraction to chat + with any llm provider + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_oca_native_llm/__init__.py b/ai_oca_native_llm/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/ai_oca_native_llm/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/ai_oca_native_llm/__manifest__.py b/ai_oca_native_llm/__manifest__.py new file mode 100644 index 00000000..70c62edd --- /dev/null +++ b/ai_oca_native_llm/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Native AI LLM Integration", + "version": "19.0.1.0.0", + "category": "AI", + "summary": "Core LLM wrapper using OpenAI client", + "author": "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "license": "AGPL-3", + "depends": ["base", "ai_oca_native_base"], + "external_dependencies": { + "python": ["openai"], + }, + "data": [ + "views/res_config_settings_views.xml", + ], + "installable": True, +} diff --git a/ai_oca_native_llm/models/__init__.py b/ai_oca_native_llm/models/__init__.py new file mode 100644 index 00000000..4fc5a44c --- /dev/null +++ b/ai_oca_native_llm/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_config_settings +from . import ai_llm_client diff --git a/ai_oca_native_llm/models/ai_llm_client.py b/ai_oca_native_llm/models/ai_llm_client.py new file mode 100644 index 00000000..e2e39ad3 --- /dev/null +++ b/ai_oca_native_llm/models/ai_llm_client.py @@ -0,0 +1,101 @@ +# Copyright 2025 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from openai import LengthFinishReasonError, OpenAI + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class AiLlmClient(models.AbstractModel): + """ + Abstract model to provide a simple Python client setup for an Open LLM connection. + It resolves configuration dynamically and performs the completions calls. + """ + + _name = "ai.llm.client" + _description = "AI LLM Client Wrapper" + + @api.model + def _get_client(self): + url = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("ai_llm.base_url", "http://localhost:11434/v1") + ) + api_key = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("ai_oca_native_llm.api_key") + ) + return OpenAI(base_url=url, api_key=api_key) + + @api.model + def _get_default_models_type(self): + return { + "fast": "llama3.2", + "reasoning": "llama3", + } + + @api.model + def _get_model(self, model_type: str): + return ( + self.env["ir.config_parameter"] + .sudo() + .get_param( + f"ai_llm.{model_type}_model", + self._get_default_models_type().get(model_type, "llama3.2"), + ) + ) + + @api.model + def chat( + self, + messages, + model_type="fast", + model: str = None, + options=None, + response_model=None, + ): + """ + Sends a chat request via OpenAI-compatible endpoint. + :param messages: list of dicts [{'role': 'user', 'content': 'hello'}, ...] + :param model_type: 'reasoning' or 'fast' + :param model: LLM model to use. + :param options: dict of optional parameters (e.g. temperature, response_format) + :param response_model: optional Pydantic Base Model class to parse + the response into + :return: string response content from the LLM, or None if request fails + """ + client = self._get_client() + if not model: + model = self._get_model(model_type) + + if not options: + options = {} + + try: + _logger.info("LLM context and question: %s", messages) + if response_model: + response = client.beta.chat.completions.parse( + model=model, + messages=messages, + response_format=response_model, + **options, + ) + else: + response = client.chat.completions.create( + model=model, messages=messages, **options + ) + _logger.info("LLM Response: %s", response.choices[0].message.content) + return response.choices[0].message.content + except LengthFinishReasonError as err: + _logger.warning( + "LLM completion truncated (context length limit reached): %s", err + ) + return None + except Exception as err: + _logger.exception("LLM API completion failed: %s", err) + return None diff --git a/ai_oca_native_llm/models/res_config_settings.py b/ai_oca_native_llm/models/res_config_settings.py new file mode 100644 index 00000000..9b7b345d --- /dev/null +++ b/ai_oca_native_llm/models/res_config_settings.py @@ -0,0 +1,32 @@ +# Copyright 2025 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + ai_llm_base_url = fields.Char( + string="LLM Base URL", + config_parameter="ai_llm.base_url", + default="http://localhost:11434/v1", + help="The URL of the OpenAI compatible API server.", + ) + ai_llm_api_key = fields.Char( + string="API Key", + config_parameter="ai_oca_native_llm.api_key", + default="", + help="The API provider's access key (can be dummy value for local instances).", + ) + ai_llm_reasoning_model = fields.Char( + string="Reasoning Model", + config_parameter="ai_llm.reasoning_model", + default="llama3", + help="The model to use for complex AI reasoning (e.g., llama3, gpt-4o).", + ) + ai_llm_fast_model = fields.Char( + string="Fast Model", + config_parameter="ai_llm.fast_model", + default="llama3.2", + help="The model to use for rapid extractions (e.g., llama3.2, gpt-4o-mini).", + ) diff --git a/ai_oca_native_llm/pyproject.toml b/ai_oca_native_llm/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_oca_native_llm/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_oca_native_llm/readme/DESCRIPTION.md b/ai_oca_native_llm/readme/DESCRIPTION.md new file mode 100644 index 00000000..e977fb71 --- /dev/null +++ b/ai_oca_native_llm/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +Provides a basic Python client wrapper to communicate with +a local/remote Ollama instance. diff --git a/ai_oca_native_llm/readme/ROADMAP.md b/ai_oca_native_llm/readme/ROADMAP.md new file mode 100644 index 00000000..c3eec639 --- /dev/null +++ b/ai_oca_native_llm/readme/ROADMAP.md @@ -0,0 +1,2 @@ +* Transform this module as base module that provide + abstraction to chat with any llm provider diff --git a/ai_oca_native_llm/static/description/index.html b/ai_oca_native_llm/static/description/index.html new file mode 100644 index 00000000..a18489a5 --- /dev/null +++ b/ai_oca_native_llm/static/description/index.html @@ -0,0 +1,424 @@ + + + + + +Native AI LLM Integration + + + +
+ + + +Odoo Community Association + +
+

Native AI LLM Integration

+ +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

Provides a basic Python client wrapper to communicate with a +local/remote Ollama instance.

+

Table of contents

+ +
+

Known issues / Roadmap

+
    +
  • Transform this module as base module that provide abstraction to chat +with any llm provider
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/ai_oca_native_llm/tests/__init__.py b/ai_oca_native_llm/tests/__init__.py new file mode 100644 index 00000000..197d4449 --- /dev/null +++ b/ai_oca_native_llm/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ai_llm_client diff --git a/ai_oca_native_llm/tests/common.py b/ai_oca_native_llm/tests/common.py new file mode 100644 index 00000000..9531b26a --- /dev/null +++ b/ai_oca_native_llm/tests/common.py @@ -0,0 +1,9 @@ +class OpenAIMockMessage: + def __init__(self, content): + self.message = type("MockMsg", (), {"content": content})() + + +class OpenAIMockResponse: + def __init__(self, message_content): + self.choices = [OpenAIMockMessage(message_content)] + self.usage = None diff --git a/ai_oca_native_llm/tests/fake_models.py b/ai_oca_native_llm/tests/fake_models.py new file mode 100644 index 00000000..0e7d4c42 --- /dev/null +++ b/ai_oca_native_llm/tests/fake_models.py @@ -0,0 +1,7 @@ +from odoo import models + + +class FakeModel(models.AbstractModel): + _name = "fake.model" + _inherit = "ai.llm.client" + _description = "Fake Model for AI LLM Client testing" diff --git a/ai_oca_native_llm/tests/test_ai_llm_client.py b/ai_oca_native_llm/tests/test_ai_llm_client.py new file mode 100644 index 00000000..2d15f7e7 --- /dev/null +++ b/ai_oca_native_llm/tests/test_ai_llm_client.py @@ -0,0 +1,182 @@ +from unittest.mock import patch + +from odoo.orm.model_classes import add_to_registry +from odoo.tests.common import TransactionCase + +from odoo.addons.ai_oca_native_llm.tests.common import OpenAIMockResponse + + +class TestAiLlmClient(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + from .fake_models import FakeModel + + add_to_registry(cls.registry, FakeModel) + cls.addClassCleanup(cls.registry.__delitem__, "fake.model") + cls.registry._setup_models__(cls.env.cr, ["fake.model"]) + cls.registry.init_models(cls.env.cr, ["fake.model"], {"models_to_check": True}) + cls.env["ir.config_parameter"].sudo().set_param( + "ai_llm.base_url", "http://test-url:11434/v1" + ) + cls.env["ir.config_parameter"].sudo().set_param( + "ai_oca_native_llm.api_key", "test-key" + ) + cls.env["ir.config_parameter"].sudo().set_param( + "ai_llm.reasoning_model", "test-model" + ) + cls.env["ir.config_parameter"].sudo().set_param( + "ai_llm.fast_model", "other-model" + ) + + def setUp(self): + super().setUp() + + @patch("odoo.addons.ai_oca_native_llm.models.ai_llm_client.OpenAI") + def test_chat_success(self, mock_client_class): + # Mock the openai.OpenAI and its chat method + mock_instance = mock_client_class.return_value + mock_instance.chat.completions.create.return_value = OpenAIMockResponse( + "Hello from mock" + ) + + messages = [{"role": "user", "content": "Hi"}] + response = self.env["fake.model"].chat(messages, options={"temperature": 0.7}) + + # Assertions + mock_client_class.assert_called_once_with( + base_url="http://test-url:11434/v1", api_key="test-key" + ) + mock_instance.chat.completions.create.assert_called_once_with( + model="other-model", + messages=messages, + temperature=0.7, + ) + self.assertEqual(response, "Hello from mock") + + @patch("odoo.addons.ai_oca_native_llm.models.ai_llm_client.OpenAI") + def test_chat_success_with_specific_model_type(self, mock_client_class): + # Mock the openai.OpenAI and its chat method + mock_instance = mock_client_class.return_value + mock_instance.chat.completions.create.return_value = OpenAIMockResponse( + "Hello from mock" + ) + + messages = [{"role": "user", "content": "Hi"}] + response = self.env["fake.model"].chat( + messages, model_type="reasoning", options={"temperature": 0.7} + ) + + # Assertions + mock_client_class.assert_called_once_with( + base_url="http://test-url:11434/v1", api_key="test-key" + ) + mock_instance.chat.completions.create.assert_called_once_with( + model="test-model", + messages=messages, + temperature=0.7, + ) + self.assertEqual(response, "Hello from mock") + + @patch("odoo.addons.ai_oca_native_llm.models.ai_llm_client.OpenAI") + def test_chat_success_with_explicit_model(self, mock_client_class): + mock_instance = mock_client_class.return_value + mock_instance.chat.completions.create.return_value = OpenAIMockResponse( + "Hello from mock" + ) + + messages = [{"role": "user", "content": "Hi"}] + response = self.env["fake.model"].chat( + messages, model="custom-llama", options={"temperature": 0.7} + ) + + # Assertions + mock_instance.chat.completions.create.assert_called_once_with( + model="custom-llama", + messages=messages, + temperature=0.7, + ) + self.assertEqual(response, "Hello from mock") + + @patch("odoo.addons.ai_oca_native_llm.models.ai_llm_client.OpenAI") + def test_chat_success_without_options(self, mock_client_class): + mock_instance = mock_client_class.return_value + mock_instance.chat.completions.create.return_value = OpenAIMockResponse( + "Hello from mock without options" + ) + + messages = [{"role": "user", "content": "Hi"}] + response = self.env["fake.model"].chat(messages) + + # Assertions + mock_instance.chat.completions.create.assert_called_once_with( + model="other-model", + messages=messages, + ) + self.assertEqual(response, "Hello from mock without options") + + @patch("odoo.addons.ai_oca_native_llm.models.ai_llm_client.OpenAI") + def test_chat_with_response_model(self, mock_client_class): + from pydantic import BaseModel + + class MockModel(BaseModel): + id: int + + mock_instance = mock_client_class.return_value + mock_instance.beta.chat.completions.parse.return_value = OpenAIMockResponse( + '{"id": 4}' + ) + + messages = [{"role": "user", "content": "Hi"}] + response = self.env["fake.model"].chat( + messages, response_model=MockModel, options={"temperature": 0.7} + ) + + mock_instance.beta.chat.completions.parse.assert_called_once_with( + model="other-model", + messages=messages, + response_format=MockModel, + temperature=0.7, + ) + self.assertEqual(response, '{"id": 4}') + + def test_get_model_fallback(self): + # Clear the config parameters to test the fallback to default + self.env["ir.config_parameter"].sudo().search( + [("key", "in", ["ai_llm.fast_model", "ai_llm.reasoning_model"])] + ).unlink() + + # Test default fallback for fast model + fast_model = self.env["fake.model"]._get_model("fast") + self.assertEqual(fast_model, "llama3.2") + + # Test default fallback for reasoning model + reasoning_model = self.env["fake.model"]._get_model("reasoning") + self.assertEqual(reasoning_model, "llama3") + + # Test default fallback for an unknown model type + unknown_model = self.env["fake.model"]._get_model("unknown") + self.assertEqual(unknown_model, "llama3.2") + + @patch("odoo.addons.ai_oca_native_llm.models.ai_llm_client.OpenAI") + def test_chat_length_finish_reason_error(self, mock_client_class): + from openai import LengthFinishReasonError + + mock_instance = mock_client_class.return_value + mock_completion = OpenAIMockResponse("truncated content") + mock_instance.beta.chat.completions.parse.side_effect = LengthFinishReasonError( + completion=mock_completion + ) + + messages = [{"role": "user", "content": "Hi"}] + response = self.env["fake.model"].chat(messages, response_model=dict) + self.assertIsNone(response) + + @patch("odoo.addons.ai_oca_native_llm.models.ai_llm_client.OpenAI") + def test_chat_api_error(self, mock_client_class): + mock_instance = mock_client_class.return_value + mock_instance.chat.completions.create.side_effect = Exception("API error") + + messages = [{"role": "user", "content": "Hi"}] + response = self.env["fake.model"].chat(messages) + self.assertIsNone(response) diff --git a/ai_oca_native_llm/views/res_config_settings_views.xml b/ai_oca_native_llm/views/res_config_settings_views.xml new file mode 100644 index 00000000..afd07599 --- /dev/null +++ b/ai_oca_native_llm/views/res_config_settings_views.xml @@ -0,0 +1,78 @@ + + + + + res.config.settings.view.form.inherit.ai.llm + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + + + + Settings + res.config.settings + form + {'module' : 'ai_oca_native_llm', 'bin_size': False} + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..983f3ac6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +openai From a8cfb499967a919017cd8d646e6654fe9696cb44 Mon Sep 17 00:00:00 2001 From: Pierre Verkest Date: Tue, 1 Sep 2026 14:47:50 +0200 Subject: [PATCH 3/9] [ADD] ai_oca_native_thread: AI Thread Assisted-by: Gemini 3.x Flash --- ai_oca_native_thread/README.rst | 73 +++ ai_oca_native_thread/__init__.py | 1 + ai_oca_native_thread/__manifest__.py | 31 ++ ai_oca_native_thread/models/__init__.py | 4 + ai_oca_native_thread/models/ai_message.py | 89 +++ .../models/ai_task_message.py | 25 + ai_oca_native_thread/models/ai_task_thread.py | 28 + ai_oca_native_thread/models/ai_thread.py | 318 +++++++++++ ai_oca_native_thread/pyproject.toml | 3 + ai_oca_native_thread/readme/DESCRIPTION.md | 3 + .../security/ir.model.access.csv | 5 + ai_oca_native_thread/security/security.xml | 20 + .../static/description/index.html | 409 ++++++++++++++ .../static/src/components/ai_icon.scss | 32 ++ .../static/src/components/ai_thread.esm.js | 241 ++++++++ .../static/src/components/ai_thread.xml | 170 ++++++ .../src/components/chatter_patch.esm.js | 87 +++ .../static/src/components/chatter_patch.xml | 70 +++ .../static/src/img/ai_brain.svg | 19 + .../static/tests/tours/ai_thread_tour.test.js | 82 +++ ai_oca_native_thread/tests/__init__.py | 3 + ai_oca_native_thread/tests/test_ai_thread.py | 524 ++++++++++++++++++ .../tests/test_ai_thread_context.py | 235 ++++++++ .../tests/test_ai_thread_tour.py | 44 ++ .../views/ai_thread_views.xml | 4 + 25 files changed, 2520 insertions(+) create mode 100644 ai_oca_native_thread/README.rst create mode 100644 ai_oca_native_thread/__init__.py create mode 100644 ai_oca_native_thread/__manifest__.py create mode 100644 ai_oca_native_thread/models/__init__.py create mode 100644 ai_oca_native_thread/models/ai_message.py create mode 100644 ai_oca_native_thread/models/ai_task_message.py create mode 100644 ai_oca_native_thread/models/ai_task_thread.py create mode 100644 ai_oca_native_thread/models/ai_thread.py create mode 100644 ai_oca_native_thread/pyproject.toml create mode 100644 ai_oca_native_thread/readme/DESCRIPTION.md create mode 100644 ai_oca_native_thread/security/ir.model.access.csv create mode 100644 ai_oca_native_thread/security/security.xml create mode 100644 ai_oca_native_thread/static/description/index.html create mode 100644 ai_oca_native_thread/static/src/components/ai_icon.scss create mode 100644 ai_oca_native_thread/static/src/components/ai_thread.esm.js create mode 100644 ai_oca_native_thread/static/src/components/ai_thread.xml create mode 100644 ai_oca_native_thread/static/src/components/chatter_patch.esm.js create mode 100644 ai_oca_native_thread/static/src/components/chatter_patch.xml create mode 100644 ai_oca_native_thread/static/src/img/ai_brain.svg create mode 100644 ai_oca_native_thread/static/tests/tours/ai_thread_tour.test.js create mode 100644 ai_oca_native_thread/tests/__init__.py create mode 100644 ai_oca_native_thread/tests/test_ai_thread.py create mode 100644 ai_oca_native_thread/tests/test_ai_thread_context.py create mode 100644 ai_oca_native_thread/tests/test_ai_thread_tour.py create mode 100644 ai_oca_native_thread/views/ai_thread_views.xml diff --git a/ai_oca_native_thread/README.rst b/ai_oca_native_thread/README.rst new file mode 100644 index 00000000..3f33c30e --- /dev/null +++ b/ai_oca_native_thread/README.rst @@ -0,0 +1,73 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============================= +Native AI Thread (Chatter UI) +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3b5e674b24d188b9d3a277c1d19ed16ca99c120bd3e6a941eee92c24385db93d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/19.0/ai_oca_native_thread + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-19-0/ai-19-0-ai_oca_native_thread + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +AI Thread +========= + +Add LLM thread discussion with chabot in chatter + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_oca_native_thread/__init__.py b/ai_oca_native_thread/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/ai_oca_native_thread/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/ai_oca_native_thread/__manifest__.py b/ai_oca_native_thread/__manifest__.py new file mode 100644 index 00000000..04a5b7c9 --- /dev/null +++ b/ai_oca_native_thread/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2025 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Native AI Thread (Chatter UI)", + "version": "19.0.1.0.0", + "category": "AI", + "summary": "AI Thread history and Chatter UI integration", + "author": "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "license": "AGPL-3", + "depends": [ + "mail", + "ai_oca_native_base", + "ai_oca_native_llm", + "queue_job", + ], + "data": [ + "security/ir.model.access.csv", + "security/security.xml", + "views/ai_thread_views.xml", + ], + "assets": { + "web.assets_backend": [ + "ai_oca_native_thread/static/src/components/**/*", + ], + "web.assets_tests": [ + "ai_oca_native_thread/static/tests/tours/**/*", + ], + }, + "installable": True, +} diff --git a/ai_oca_native_thread/models/__init__.py b/ai_oca_native_thread/models/__init__.py new file mode 100644 index 00000000..120cbf40 --- /dev/null +++ b/ai_oca_native_thread/models/__init__.py @@ -0,0 +1,4 @@ +from . import ai_thread +from . import ai_message +from . import ai_task_thread +from . import ai_task_message diff --git a/ai_oca_native_thread/models/ai_message.py b/ai_oca_native_thread/models/ai_message.py new file mode 100644 index 00000000..5e31b227 --- /dev/null +++ b/ai_oca_native_thread/models/ai_message.py @@ -0,0 +1,89 @@ +# Copyright 2025 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class AiMessage(models.Model): + _name = "ai.message" + _inherit = "ai.llm.client" + _description = "AI Thread Message" + _order = "create_date asc" + + thread_id = fields.Many2one( + "ai.thread", required=True, ondelete="cascade", index=True + ) + role = fields.Selection( + [ + ("system", "System"), + ("user", "User"), + ("assistant", "Assistant"), + ("tool", "Tool"), + ], + required=True, + default="user", + ) + content = fields.Text() + status = fields.Selection( + [ + ("processing", "Processing"), + ("done", "Done"), + ("cancel", "Cancelled"), + ], + default="processing", + required=True, + ) + task_thread_ids = fields.One2many( + "ai.task.thread", "message_id", string="task Threads" + ) + + queue_job_channel_name = fields.Char(compute="_compute_queue_job_channel_name") + + def _compute_queue_job_channel_name(self): + for record in self: + record.queue_job_channel_name = ( + f"{record.queue_job_channel_name}_{record.id}" + ) + + def _action_extract_intent(self): + """Entry point called asynchronously by queue_job when a user sends a message. + Delegates to _process_assistant_message() hook. + """ + self.ensure_one() + if not self.exists() or self.status == "cancel": + return + + return self._process_assistant_message() + + def _process_assistant_message(self): + """Extension hook for assistant message processing. + Base implementation provides a direct completion using ai.llm.client. + Overridden by ai_oca_native_orchestrator for multi-agent planning. + """ + self.ensure_one() + llm_messages = ( + self.thread_id._get_system_prompt() + self.thread_id._get_thread_messages() + ) + ai_content = self.chat(llm_messages, model_type="fast") + + if not ai_content: + self.write( + {"status": "cancel", "content": "Failed to get a response from LLM."} + ) + return + + self.write({"content": ai_content, "status": "done"}) + + user_partner = self.thread_id.user_id.partner_id + payload = { + "thread_id": self.thread_id.id, + "thread_name": self.thread_id.name, + "res_model": self.thread_id.res_model, + "res_id": self.thread_id.res_id, + "status": "success", + "message": self.thread_id.get_full_messages(self.thread_id.id)[-1], + } + self.env["bus.bus"]._sendone(user_partner, "ai_thread_update", payload) diff --git a/ai_oca_native_thread/models/ai_task_message.py b/ai_oca_native_thread/models/ai_task_message.py new file mode 100644 index 00000000..02e6cce2 --- /dev/null +++ b/ai_oca_native_thread/models/ai_task_message.py @@ -0,0 +1,25 @@ +# Copyright 2025 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class AITaskMessage(models.Model): + _name = "ai.task.message" + _description = "AI Task Message" + _order = "create_date asc" + + task_thread_id = fields.Many2one( + "ai.task.thread", required=True, ondelete="cascade", index=True + ) + role = fields.Selection( + [ + ("system", "System"), + ("user", "User"), + ("assistant", "Assistant"), + ("tool", "Tool"), + ], + required=True, + default="user", + ) + content = fields.Text() + payload = fields.Json() diff --git a/ai_oca_native_thread/models/ai_task_thread.py b/ai_oca_native_thread/models/ai_task_thread.py new file mode 100644 index 00000000..f1d5128c --- /dev/null +++ b/ai_oca_native_thread/models/ai_task_thread.py @@ -0,0 +1,28 @@ +# Copyright 2025 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class AITaskThread(models.Model): + _name = "ai.task.thread" + _description = "AI Task Thread" + _order = "create_date asc" + + message_id = fields.Many2one( + "ai.message", required=True, ondelete="cascade", index=True + ) + name = fields.Char(required=True) + task_message_ids = fields.One2many( + "ai.task.message", "task_thread_id", string="Task Messages" + ) + payload = fields.Json() + + def _add_message(self, content, role="user", payload=None): + vals = { + "task_thread_id": self.id, + "role": role, + "content": content, + } + if payload is not None: + vals["payload"] = payload + return self.env["ai.task.message"].create(vals) diff --git a/ai_oca_native_thread/models/ai_thread.py b/ai_oca_native_thread/models/ai_thread.py new file mode 100644 index 00000000..274974d9 --- /dev/null +++ b/ai_oca_native_thread/models/ai_thread.py @@ -0,0 +1,318 @@ +# Copyright 2025 Pierre Verkest +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import json +import logging +from textwrap import shorten + +from odoo import api, fields, models +from odoo.fields import Domain +from odoo.tools import html2plaintext, json_default + +_logger = logging.getLogger(__name__) + +# todo move that to xml configuration +AI_THREAD_CHANNEL = "root.ai.thread" + + +class AiThread(models.Model): + _name = "ai.thread" + _description = "AI Conversation Thread" + _order = "create_date desc" + + name = fields.Char( + string="Reference", required=True, default=lambda self: self.env._("New Thread") + ) + res_model = fields.Char(string="Related Document Model", required=True, index=True) + res_id = fields.Many2oneReference( + string="Related Document", + model_field="res_model", + required=True, + index=True, + ) + user_id = fields.Many2one( + "res.users", + default=lambda self: self.env.user, + required=True, + index=True, + ) + message_ids = fields.One2many("ai.message", "thread_id", string="Messages") + queue_job_channel_name = fields.Char(compute="_compute_queue_job_channel_name") + pending_job_count = fields.Integer(compute="_compute_pending_job_count") + + @property + def record(self): + """Return the related active Odoo recordset or None.""" + self.ensure_one() + if self.res_model and self.res_id and self.res_model in self.env: + rec = self.env[self.res_model].browse(self.res_id) + return rec if rec.exists() else None + return None + + @api.model + def _get_field_type_to_ignore(self): + return ("binary", "image") + + @api.model + def _get_field_names_to_ignore(self): + """Fields to ignore when extracting record context. + We especially ignore mail.thread fields to put them in a separate block. + """ + return ( + "message_ids", + "message_follower_ids", + "activity_ids", + "message_partner_ids", + ) + + @api.model + def _get_record_context(self, record): + """Extract user-accessible fields into a JSON serializable dict.""" + context_data = {} + if not record or not record.exists(): + return context_data + + try: + # check access rights on the record + record.check_access("read") + # Using read()[0] returns primitive data ready for json + # except for Many2one, One2many, Many2many, dates etc. + raw_data = record.read()[0] + except Exception: + # If the user doesn't have read access to the record, we ignore. + return context_data + + for field_name, value in raw_data.items(): + if not value: + continue + + field = record._fields[field_name] + # Skip massive fields or non-contextual fields + if field.type in self._get_field_type_to_ignore(): + continue + if field_name in self._get_field_names_to_ignore(): + continue + + if field.type == "many2one": + # read() returns a tuple (id, display_name) for many2one + context_data[field_name] = ( + value[1] if isinstance(value, tuple) else value + ) + elif field.type in ("one2many", "many2many"): + # read() returns a list of IDs for x2many + if isinstance(value, list): + related_records = self.env[field.comodel_name].browse(value) + context_data[field_name] = related_records.mapped("display_name") + else: + context_data[field_name] = value + + return context_data + + def _add_message(self, content, role="user", status="processing"): + return self.env["ai.message"].create( + { + "thread_id": self.id, + "role": role, + "content": content, + "status": status, + } + ) + + @api.model + def _get_chatter_history_content(self, record): + """Extract and format the chatter history to send to the LLM.""" + if not record or not record.exists() or "message_ids" not in record._fields: + return "" + + messages = record.message_ids.filtered(lambda m: m.body or m.subject) + if not messages: + return "" + + history = [] + # Sort oldest to newest for chronological reading by LLM + for msg in messages.sorted("id"): + author = msg.author_id.name or msg.email_from or "System" + date = msg.date.strftime("%Y-%m-%d %H:%M:%S") if msg.date else "" + body = html2plaintext(msg.body) if msg.body else "" + subject = msg.subject or "" + + msg_block = f"[{date}] {author}:" + if subject: + msg_block += f"\nSubject: {subject}" + if body: + msg_block += f"\n{body.strip()}" + + history.append(msg_block) + + return "\n---\n".join(history) + + def _get_system_prompt(self) -> list[dict[str, str]]: + # Inject basic system prompt with record context + record = self.record + system_content = ( + "You are a helpful Odoo Assistant. " + f"The user: {self.env.user.name}, is currently looking at " + f"a record of type {self.res_model}." + f"The user speak {self.env.user.partner_id.lang} which should be" + "the prefered language for the responses specify." + ) + if record: + system_content += ( + f"\nThe contextual record name is '{record.display_name}'." + ) + context_data = self._get_record_context(record) + if context_data: + context_str = json.dumps(context_data, default=json_default, indent=2) + system_content += ( + "\nHere is the data associated with this record in " + f"JSON format:\n{context_str}\n" + ) + + if "message_ids" in record._fields: + chatter_content = self._get_chatter_history_content(record) + if chatter_content: + system_content += ( + "\nHere is the chatter history of the record:\n" + f"{chatter_content}\n" + ) + return [{"role": "system", "content": system_content}] + + def _get_thread_messages(self) -> list[dict[str, str]]: + return self.message_ids.sorted("create_date").mapped( + lambda m: {"role": m.role, "content": m.content} + ) + + def _compute_queue_job_channel_name(self): + for record in self: + record.queue_job_channel_name = f"{AI_THREAD_CHANNEL}.id_{record.id}" + + def _action_generate_thread_title(self): + self.ensure_one() + if self.name and self.name != self.env._("New Thread"): + return + user_msgs = self.message_ids.filtered(lambda m: m.role == "user") + if not user_msgs: + return + first_msg = user_msgs.sorted("create_date")[0].content or "" + if first_msg: + self.name = shorten(first_msg, width=30, placeholder="...") + + def action_send_message(self, content): + self.ensure_one() + + # 1. Create User Message + self._add_message(content, role="user", status="done") + + # 2. Update thread title synchronously if needed + if not self.name or self.name == self.env._("New Thread"): + self._action_generate_thread_title() + + # 3. Create placeholder Assistant Message + assistant_msg = self._add_message("", role="assistant", status="processing") + + # 4. Call LLM asynchronously using queue_job + assistant_msg.with_delay( + channel=assistant_msg.queue_job_channel_name, + )._action_extract_intent() + + return { + "status": "pending", + "thread_name": self.name, + "assistant_message": { + "id": assistant_msg.id, + "role": assistant_msg.role, + "content": assistant_msg.content, + "status": assistant_msg.status, + "task_threads": [], + }, + } + + @api.model + def get_full_messages(self, thread_id): + thread = self.browse(thread_id) + if not thread.exists(): + return [] + return thread._get_full_messages() + + def _get_full_messages(self): + res = [] + for msg in self.message_ids.sorted("create_date"): + msg_data = { + "id": msg.id, + "role": msg.role, + "content": msg.content, + "status": msg.status, + "task_threads": [], + } + for log_th in msg.task_thread_ids.sorted("create_date"): + th_data = {"id": log_th.id, "name": log_th.name, "messages": []} + for log_msg in log_th.task_message_ids.sorted("create_date"): + th_data["messages"].append( + { + "id": log_msg.id, + "role": log_msg.role, + "content": log_msg.content, + } + ) + msg_data["task_threads"].append(th_data) + res.append(msg_data) + return res + + @api.model + def get_pending_job_count(self, thread_id): + return self.browse(thread_id).pending_job_count + + def action_cancel_jobs(self): + self.ensure_one() + thread_jobs = self._get_related_jobs( + Domain("state", "in", ["pending", "enqueued", "started"]) + ) + if thread_jobs: + thread_jobs.button_cancelled() + + processing_messages = self.message_ids.filtered( + lambda m: m.status == "processing" + ) + if processing_messages: + processing_messages.write( + {"status": "cancel", "content": "Cancelled by user."} + ) + + user_partner = self.user_id.partner_id + payload = { + "thread_id": self.id, + "thread_name": self.name, + "res_model": self.res_model, + "res_id": self.res_id, + "status": "cancelled", + } + self.env["bus.bus"]._sendone(user_partner, "ai_thread_update", payload) + return True + + def _compute_pending_job_count(self): + for record in self: + record.pending_job_count = len( + record._get_related_jobs( + Domain("state", "in", ["pending", "enqueued", "started"]) + ) + ) + + def _get_thread_queue_job_domain(self): + self.ensure_one() + return Domain( + [ + ("model_name", "=", "ai.message"), + ("method_name", "=", "_action_extract_intent"), + ("channel", "like", f"{self.queue_job_channel_name}%"), + ] + ) + + def _get_related_jobs(self, domain: Domain): + """Return job recordset for the current thread + AND the provided domain + """ + self.ensure_one() + return ( + self.env["queue.job"] + .sudo() + .search(self._get_thread_queue_job_domain() & domain) + ) diff --git a/ai_oca_native_thread/pyproject.toml b/ai_oca_native_thread/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_oca_native_thread/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_oca_native_thread/readme/DESCRIPTION.md b/ai_oca_native_thread/readme/DESCRIPTION.md new file mode 100644 index 00000000..ec7758c0 --- /dev/null +++ b/ai_oca_native_thread/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +# AI Thread + +Add LLM thread discussion with chabot in chatter diff --git a/ai_oca_native_thread/security/ir.model.access.csv b/ai_oca_native_thread/security/ir.model.access.csv new file mode 100644 index 00000000..4752f1eb --- /dev/null +++ b/ai_oca_native_thread/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_thread_user,ai.thread.user,model_ai_thread,base.group_user,1,1,1,1 +access_ai_message_user,ai.message.user,model_ai_message,base.group_user,1,1,1,1 +access_ai_task_thread_user,ai.task.thread.user,model_ai_task_thread,base.group_user,1,1,1,1 +access_ai_task_message_user,ai.task.message.user,model_ai_task_message,base.group_user,1,1,1,1 diff --git a/ai_oca_native_thread/security/security.xml b/ai_oca_native_thread/security/security.xml new file mode 100644 index 00000000..d1d1531b --- /dev/null +++ b/ai_oca_native_thread/security/security.xml @@ -0,0 +1,20 @@ + + + + + AI Thread: User only + + [('user_id', '=', user.id)] + + + + + AI Message: User only + + [('thread_id.user_id', '=', user.id)] + + + diff --git a/ai_oca_native_thread/static/description/index.html b/ai_oca_native_thread/static/description/index.html new file mode 100644 index 00000000..f36a8e78 --- /dev/null +++ b/ai_oca_native_thread/static/description/index.html @@ -0,0 +1,409 @@ + + + + + +Native AI Thread (Chatter UI) + + + +
+ + + +Odoo Community Association + +
+

Native AI Thread (Chatter UI)

+ +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+
+

AI Thread

+

Add LLM thread discussion with chabot in chatter

+

Table of contents

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/ai_oca_native_thread/static/src/components/ai_icon.scss b/ai_oca_native_thread/static/src/components/ai_icon.scss new file mode 100644 index 00000000..20a52727 --- /dev/null +++ b/ai_oca_native_thread/static/src/components/ai_icon.scss @@ -0,0 +1,32 @@ +/* Copyright 2026 Pierre Verkest + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +.o_ai_icon, +.fa.fa-brain-ai, +i.fa-brain-ai { + display: inline-block; + width: 1.25em; + height: 1.25em; + background-color: currentColor; + mask: url(/ai_oca_native_thread/static/src/img/ai_brain.svg) no-repeat center / + contain; + -webkit-mask: url(/ai_oca_native_thread/static/src/img/ai_brain.svg) no-repeat + center / contain; + vertical-align: middle; +} + +.fa.fa-brain-ai.fa-lg, +i.fa-brain-ai.fa-lg { + width: 1.45em; + height: 1.45em; +} + +/* Ensure bright white icon rendering in top main navbar */ +.o_main_navbar, +.o_navbar { + .o_ai_icon, + .fa-brain-ai { + background-color: #ffffff !important; + color: #ffffff !important; + } +} diff --git a/ai_oca_native_thread/static/src/components/ai_thread.esm.js b/ai_oca_native_thread/static/src/components/ai_thread.esm.js new file mode 100644 index 00000000..2ceb0b86 --- /dev/null +++ b/ai_oca_native_thread/static/src/components/ai_thread.esm.js @@ -0,0 +1,241 @@ +/* Copyright 2026 Pierre Verkest + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ +import {Component, onWillStart, onWillUpdateProps, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; + +export class AiThread extends Component { + setup() { + this.orm = useService("orm"); + this.busService = useService("bus_service"); + + this.busService.subscribe("ai_thread_update", (payload) => { + if (payload.thread_id !== this.state.threadId) { + return; + } + if (payload.status === "cancelled") { + this.state.pendingJobs = 0; + this.state.isLoading = false; + return; + } + this._handleBusMessageUpdate(payload); + }); + + this.state = useState({ + messages: [], + prompt: "", + isLoading: false, + pendingJobs: 0, + threads: [], + threadId: null, + }); + + onWillStart(async () => { + await this.initConversations( + this.props.threadModel, + this.props.recordId, + this.props.activeThreadId + ); + }); + + onWillUpdateProps(async (nextProps) => { + if ( + nextProps.recordId !== this.props.recordId || + nextProps.threadModel !== this.props.threadModel || + nextProps.activeThreadId !== this.props.activeThreadId + ) { + this.state.prompt = ""; + this.state.messages = []; + await this.initConversations( + nextProps.threadModel, + nextProps.recordId, + nextProps.activeThreadId + ); + } + }); + } + + _handleBusMessageUpdate(payload) { + this.state.pendingJobs = Math.max(0, this.state.pendingJobs - 1); + if (this.state.pendingJobs === 0) { + this.state.isLoading = false; + } + if (payload.thread_name) { + const threadItem = this.state.threads.find( + (t) => t.id === payload.thread_id + ); + if (threadItem) { + threadItem.name = payload.thread_name; + } else { + this.state.threads.unshift({ + id: payload.thread_id, + name: payload.thread_name, + }); + } + } + if (payload.message) { + const msgIndex = this.state.messages.findIndex( + (m) => m.id === payload.message.id + ); + if (msgIndex >= 0) { + this.state.messages[msgIndex] = payload.message; + } else { + this.state.messages.push(payload.message); + } + } else if (payload.status === "error") { + this.state.messages.push({ + role: "system", + content: `Error: ${payload.content}`, + }); + } + } + + async fetchThreads(threadModel, recordId, threadId = null) { + this.state.threads = await this.orm.searchRead( + "ai.thread", + [ + ["res_model", "=", threadModel], + ["res_id", "=", recordId], + ], + ["id", "name"], + {order: "create_date desc"} + ); + if (threadId) { + this.state.threadId = parseInt(threadId, 10); + } + } + + async initConversations(threadModel, recordId, activeThreadId = null) { + this.state.threadId = null; + this.state.messages = []; + const targetId = activeThreadId || this.props.activeThreadId || null; + await this.fetchThreads(threadModel, recordId, targetId); + if (this.state.threadId) { + await this.loadMessages(); + } + } + + async onThreadChange(ev) { + const selectedId = ev.target.value; + if (selectedId === "new" || !selectedId) { + this.state.threadId = null; + this.state.messages = []; + } else { + this.state.threadId = parseInt(selectedId, 10); + await this.loadMessages(); + } + } + + async loadMessages() { + if (!this.state.threadId) return; + this.busService.addChannel(`ai_thread_${this.state.threadId}`); + const messages = await this.orm.call("ai.thread", "get_full_messages", [ + this.state.threadId, + ]); + this.state.messages = messages; + this.state.pendingJobs = await this.orm.call( + "ai.thread", + "get_pending_job_count", + [this.state.threadId] + ); + } + + async deleteCurrentThread() { + if (!this.state.threadId) return; + await this.orm.unlink("ai.thread", [this.state.threadId]); + await this.initConversations(this.props.threadModel, this.props.recordId); + } + + onKeydown(e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + } + + async sendMessage() { + if (!this.state.prompt.trim()) return; + + const content = this.state.prompt; + this.state.prompt = ""; + this.state.isLoading = true; + this.state.pendingJobs += 1; + + // Optimistically add user message + this.state.messages.push({role: "user", content: content, status: "done"}); + + try { + let isNew = false; + let threadId = this.state.threadId; + if (!threadId) { + isNew = true; + // Create thread if not exists + const threadIds = await this.orm.create("ai.thread", [ + { + res_model: this.props.threadModel, + res_id: this.props.recordId, + }, + ]); + threadId = threadIds[0]; + } + + const response = await this.orm.call("ai.thread", "action_send_message", [ + threadId, + content, + ]); + + if (response.status === "pending") { + this.busService.addChannel(`ai_thread_${threadId}`); + if (response.assistant_message) { + this.state.messages.push(response.assistant_message); + } + if (response.thread_name) { + const threadItem = this.state.threads.find( + (t) => t.id === threadId + ); + if (threadItem) { + threadItem.name = response.thread_name; + } + } + if (isNew) { + await this.fetchThreads( + this.props.threadModel, + this.props.recordId, + threadId + ); + } + } else { + this.state.isLoading = false; + this.state.pendingJobs = Math.max(0, this.state.pendingJobs - 1); + this.state.messages.push({ + role: "system", + content: `Error: ${response.content || "Failed to start generation"}`, + }); + } + } catch (error) { + console.error( + "Failed to send AI message", + error, + error.message, + error.data ? JSON.stringify(error.data) : "" + ); + this.state.isLoading = false; + this.state.pendingJobs = Math.max(0, this.state.pendingJobs - 1); + this.state.messages.push({ + role: "system", + content: `Network or Server Error.`, + }); + } + } + + async stopAi() { + if (!this.state.threadId) return; + await this.orm.call("ai.thread", "action_cancel_jobs", [this.state.threadId]); + } +} + +AiThread.template = "ai_oca_native_thread.AiThread"; +AiThread.props = { + threadModel: String, + recordId: Number, + activeThreadId: {optional: true}, +}; diff --git a/ai_oca_native_thread/static/src/components/ai_thread.xml b/ai_oca_native_thread/static/src/components/ai_thread.xml new file mode 100644 index 00000000..2e5b24c3 --- /dev/null +++ b/ai_oca_native_thread/static/src/components/ai_thread.xml @@ -0,0 +1,170 @@ + + + + +
+ +
+ + +
+ + +
+ +
No AI history for this new threadthread. Say hi!
+
+ +
+
+ : +
+ + + Analyzing... + + + + + + + + + + +
+ + + +
+ +
+ + + + +
+ +
+ : +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + AI is thinking ( jobs pending)... + +
+
+
+
+ + +
+