Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions discuss_ai_search/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
17 changes: 17 additions & 0 deletions discuss_ai_search/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
'name': "Discuss AI Search",
'version': '1.0',
'category': 'Discuss',
'summary': "Categorize and summarize Discuss messages using AI",
'depends': ['mail'],
'data': [
'data/discuss_demo_data.xml'
],
'assets': {
'web.assets_backend': [
'discuss_ai_search/static/src/**/*',
],
},
'author': "Parth Sawant",
'license': 'LGPL-3',
}
4,584 changes: 4,584 additions & 0 deletions discuss_ai_search/data/discuss_demo_data.xml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions discuss_ai_search/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import discuss_ai
from . import discuss_channel
53 changes: 53 additions & 0 deletions discuss_ai_search/models/discuss_ai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import requests
from odoo.exceptions import UserError
from odoo import _

GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"


def _get_api_key(env):
api_key = env['ir.config_parameter'].sudo().get_param('ai.google_key')
if not api_key:
raise UserError(_("Please configure the Google API key in Settings."))
return api_key


def ask_ai(env, prompt):
headers = {
"x-goog-api-key": _get_api_key(env),
"Content-Type": "application/json"
}
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"responseMimeType":"application/json",
"responseSchema":{
"type":"object",
"properties":{
"answer":{"type":"string"},
"message_ids":{
"type":"array",
"items":{"type":"integer"}
}
},
"required":["answer","message_ids"]
}
}
}
response = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
return data['candidates'][0]['content']['parts'][0]['text']

def ask_ai_summarize(env, prompt):
headers = {
"x-goog-api-key": _get_api_key(env),
"Content-Type": "application/json"
}
payload = {
"contents": [{"parts": [{"text": prompt}]}]
}
response = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
return data['candidates'][0]['content']['parts'][0]['text']
69 changes: 69 additions & 0 deletions discuss_ai_search/models/discuss_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from odoo import models
from odoo.tools import html2plaintext
import json
from odoo.addons.mail.tools.discuss import Store
from . import discuss_ai


class DiscussChannel(models.Model):
_inherit = "discuss.channel"

def action_ask_ai(self, user_prompt):
self.ensure_one()

messages = self.message_ids.filtered(lambda m: m.message_type == 'comment').sorted('id')
if not messages:
return "There are no messages in this conversation yet.", {}, []

lines = messages.mapped(
lambda m: f"[{m.id}] {m.author_id.name}: {html2plaintext(m.body)}"
)

conversation = "\n".join(lines)

prompt = f"""You are answering a question about a team chat conversation.
Use ONLY the messages below. Each line starts with its message id in brackets, e.g. "[42]".
Answer in 2-4 plain sentences, written naturally for a human to read.
The "answer" field must contain ONLY natural language prose. Do NOT include any
message ids, numbers in brackets like "[42]", or a trailing list of numbers
(e.g. "...,22,23,24,25") anywhere in the answer text.
Put the ids of the messages you relied on ONLY in the separate message_ids field (at most 5).

Conversation:
{conversation}

User Prompt: {user_prompt}
"""
text = discuss_ai.ask_ai(self.env, prompt)
result = json.loads(text)
breakpoint()
answer = result['answer']
message_ids = result['message_ids']
references = self.env['mail.message'].browse(message_ids).exists()
store_data = Store().add(references).get_result()
return answer, store_data, references.ids


def action_summarize_ai(self):
self.ensure_one()

messages = self.message_ids.filtered(lambda m: m.message_type == 'comment').sorted('id')
if not messages:
return "There are no messages in this conversation yet."

lines = messages.mapped(
lambda m: f"{m.author_id.name}: {html2plaintext(m.body)}"
)
conversation = "\n".join(lines)

prompt = f"""Summarize the conversation below in a short paragraph, then add a heading
"Key points:" in bold, followed by the key decisions made as a bullet list.
Format the entire response as simple HTML using only <p>, <strong>, <ul>, and <li> tags.
Do NOT use markdown syntax (no "*", "#", "**") and do NOT wrap the output in
a code block.

Conversation:
{conversation}
"""
summary = discuss_ai.ask_ai_summarize(self.env, prompt)
return summary
53 changes: 53 additions & 0 deletions discuss_ai_search/static/src/ai_search_panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Component } from "@odoo/owl";
import { ActionPanel } from "@mail/discuss/core/common/action_panel";
import { useState } from "@odoo/owl";
import {useService} from "@web/core/utils/hooks";
import {MessageCardList} from "@mail/core/common/message_card_list";
import { markup } from "@odoo/owl";

export class AiSearchPanel extends Component {
static template = "discuss_ai_search.AiSearchPanel";
static props = ["thread"];
static components = { ActionPanel, MessageCardList };


setup() {
this.orm = useService("orm");
this.store = useService("mail.store");
this.state = useState({
user_prompt: "",
answer: "",
messageIds: [],
});

}
async ask() {
this.state.answer = ""
this.state.messageIds = []
const [answer, storeData, messageIds] = await this.orm.call(
"discuss.channel",
"action_ask_ai",
[this.props.thread.id, this.state.user_prompt]
)
this.store.insert(storeData)
this.state.answer = answer
this.state.messageIds = messageIds
}

get messages() {
return this.state.messageIds.map((id) => this.store["mail.message"].get(id)).filter(Boolean)
}

async summarize() {
this.state.user_prompt = ""
this.state.messageIds = []
this.state.answer = ""
const summarize = await this.orm.call(
"discuss.channel",
"action_summarize_ai",
[this.props.thread.id]
)
this.state.answer = markup(summarize)

}
}
47 changes: 47 additions & 0 deletions discuss_ai_search/static/src/ai_search_panel.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.o-discuss-AiSearchPanel {
&-input {
font-size: 13.5px;
border-color: #e5d4d4;

&:focus {
border-color: #a9425a;
box-shadow: 0 0 0 2px rgba(169, 66, 90, 0.12);
}
}

&-ask {
background-color: #a9425a;
color: #ffffff;
font-weight: 600;
border: none;

&:hover {
background-color: #8f3549;
color: #ffffff;
}
}

&-summarize {
background-color: #f9dfe2;
color: #7a2f3d;
font-weight: 500;
border: 1px solid #f0c4ca;

&:hover {
background-color: #f3cdd2;
}
}

&-response {
background-color: #fdf6f7;
border: 1px solid #f0c4ca;
border-radius: 6px;
padding: 10px 12px;
font-size: 13.5px;
line-height: 1.5;

p:last-child, ul:last-child {
margin-bottom: 0;
}
}
}
31 changes: 31 additions & 0 deletions discuss_ai_search/static/src/ai_search_panel.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="discuss_ai_search.AiSearchPanel">
<ActionPanel title="'Search with AI'" minWidth="250" initialWidth="350" icon="'fa fa-magic'">
<div class="o-discuss-AiSearchPanel d-flex flex-column gap-2">
<div class="d-flex align-items-stretch gap-2">
<textarea
class="o-discuss-AiSearchPanel-input form-control"
placeholder="Find the chat about quotation meeting"
t-model="state.user_prompt"
/>
<button class="o-discuss-AiSearchPanel-ask btn btn-primary" t-on-click="ask">
Ask
</button>
</div>
<button class="o-discuss-AiSearchPanel-summarize btn mt-1" t-on-click="summarize">
Summarize this conversation
</button>
<div class="o-discuss-AiSearchPanel-response" t-if="state.answer">
<t t-out="state.answer"/>
</div>
<t t-if="messages.length">
<p class="o-discuss-AiSearchPanel-count text-muted fw-bolder text-uppercase small mb-0">
<t t-esc="messages.length"/> message<t t-if="messages.length > 1">s</t> found
</p>
<MessageCardList messages="messages" mode="'search'" thread="props.thread"/>
</t>
</div>
</ActionPanel>
</t>
</templates>
13 changes: 13 additions & 0 deletions discuss_ai_search/static/src/thread_action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { registerThreadAction } from "@mail/core/common/thread_actions";
import { AiSearchPanel } from "@discuss_ai_search/ai_search_panel";
import { _t } from "@web/core/l10n/translation";

registerThreadAction("discuss_ai_search", {
actionPanelComponent: AiSearchPanel,
condition: ({ thread }) => thread?.model === "discuss.channel",
icon: "fa fa-fw fa-magic",
name: _t("Ask AI"),
sequence: 9,
sequenceGroup: 30,
toggle: true,
});