AI-powered summarization and next-step suggestions implemented

This commit is contained in:
2026-09-16 19:12:25 -03:00
parent 003035a3a4
commit 4421b28efe
5 changed files with 758 additions and 23 deletions

View File

@@ -89,6 +89,8 @@
let tokenRelayRequest = null;
let consumerTokenServiceRequest = null;
let accessToken = "";
let salesCommonToken = null;
let salesCommonTokenRequest = null;
let periodRequestVersion = 0;
let authStatus = AUTH_STATUS.idle;
let requestLog = [];
@@ -106,6 +108,7 @@
let productsRenderQueued = false;
let opptyProductsByNumber = new Map();
let expandedProductOptyNumbers = new Set();
const aiSummariesByOptyNumber = new Map();
let productsProgress = {
state: "idle",
completed: 0,
@@ -615,7 +618,27 @@
return consumerTokenServiceRequest;
}
async function requestTokenRelay() {
function requestTokenRelaySalesCommon() {
if (salesCommonToken && Date.now() < salesCommonToken.expiresAt) {
return Promise.resolve(salesCommonToken.access_token);
}
if (salesCommonTokenRequest) {
return salesCommonTokenRequest;
}
salesCommonTokenRequest = requestTokenRelay({
name: "requestTokenRelaySalesCommon",
url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`,
includeExpiry: true
}).then((token) => {
salesCommonToken = token;
return token.access_token;
}).finally(() => {
salesCommonTokenRequest = null;
});
return salesCommonTokenRequest;
}
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
let refreshResult = null;
let refreshAttempted = false;
@@ -624,7 +647,7 @@
if (!xsrfTokenSource.token) {
if (refreshAttempted) {
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
throw new Error("XSRF token cookie not found after automatic refresh.");
}
@@ -634,10 +657,10 @@
continue;
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
try {
const response = await fetch(TOKEN_RELAY_URL, {
const response = await fetch(url, {
method: "GET",
credentials: "include",
headers: {
@@ -698,6 +721,14 @@
access_token: maskToken(responseData.access_token)
}, null, 2)));
if (includeExpiry) {
const lifetime = Number(responseData.expires_in);
return {
access_token: responseData.access_token,
expiresAt: Number.isFinite(lifetime) && lifetime > 0
? requestEntry.startedAt.getTime() + lifetime * 1000 : 0
};
}
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
@@ -706,11 +737,11 @@
}
}
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) {
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) {
return logRequestStart({
name: "requestTokenRelay",
name,
method: "GET",
url: TOKEN_RELAY_URL,
url,
headers: {
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
},
@@ -3096,6 +3127,7 @@
expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else {
expandedProductOptyNumbers.add(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber);
}
renderOpportunitiesTable();
@@ -3103,6 +3135,98 @@
return button;
}
function requestGenerateAiSummary(optyNumber) {
const key = String(optyNumber);
const existing = aiSummariesByOptyNumber.get(key);
if (existing && existing.status === "loading") {
return existing.promise;
}
const state = { status: "loading", summaryText: "", error: "", promise: null };
aiSummariesByOptyNumber.set(key, state);
state.promise = (async () => {
let entry;
try {
let token = await requestTokenRelaySalesCommon();
if (!token) {
throw new Error("Authentication is required to generate the summary.");
}
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
let response;
for (let attempt = 0; attempt < 2; attempt += 1) {
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
entry = logRequestStart({
name: "requestGenerateAiSummary", method: "POST", url, headers,
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
});
response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (response.status !== 401 && response.status !== 403) {
break;
}
// A late failure for an old token must not discard a newly refreshed token.
if (salesCommonToken && salesCommonToken.access_token === token) {
salesCommonToken = null;
}
if (attempt === 0) {
token = await requestTokenRelaySalesCommon();
}
}
if (!response.ok) {
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
}
let data;
try {
data = await response.json();
} catch (error) {
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
}
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
throw new Error("requestGenerateAiSummary returned an empty summary.");
}
state.summaryText = data.summaryText;
state.status = "ready";
} catch (error) {
state.status = "error";
state.error = error.message || "Unable to generate the AI summary.";
if (entry) {
logRequestFailure(entry, state.error);
}
} finally {
// Update only visible summaries, without reopening rows or rebuilding the table.
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
if (alert.dataset.optyNumber === key) {
alert.replaceWith(createAiSummaryAlert(key));
}
});
}
})();
return state.promise;
}
function createAiSummaryAlert(optyNumber) {
const state = aiSummariesByOptyNumber.get(optyNumber);
const alert = document.createElement("div");
alert.className = "opportunities-extension-ai-summary";
alert.dataset.optyNumber = optyNumber;
alert.setAttribute("role", "status");
alert.setAttribute("aria-live", "polite");
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
const title = document.createElement("strong");
title.textContent = "Summary generated by AI";
const description = document.createElement("p");
description.textContent = !state || state.status === "loading"
? "Generating summary..."
: state.status === "error" ? state.error : state.summaryText;
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
alert.append(title, description);
return alert;
}
function createProductsDetailRow(opportunity) {
const row = document.createElement("tr");
const cell = document.createElement("td");
@@ -3127,6 +3251,7 @@
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
heading.append(title, summary);
panel.append(heading);
panel.append(createAiSummaryAlert(optyNumber));
if (productsResult && productsResult.error) {
const error = document.createElement("p");
@@ -5245,6 +5370,51 @@
color: #312d2a;
}
.opportunities-extension-ai-summary {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0 0 12px;
padding: 12px 16px;
border: 1px solid #dedbd7;
border-radius: 6px;
background: #ffffff;
color: #312d2a;
font-size: 13px;
line-height: 1.5;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}
.opportunities-extension-ai-summary strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.opportunities-extension-ai-summary p {
margin: 4px 0 0;
font: inherit;
white-space: pre-wrap;
}
.opportunities-extension-ai-summary-error {
color: #b42318;
border-color: #d9928b;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary {
background: #292827;
color: #f6f4f2;
border-color: #5f5a55;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error {
color: #ffb4ab;
border-color: #a45c55;
}
.opportunities-extension-products-detail-heading strong {
font-size: 13px;
}

View File

@@ -89,6 +89,8 @@
let tokenRelayRequest = null;
let consumerTokenServiceRequest = null;
let accessToken = "";
let salesCommonToken = null;
let salesCommonTokenRequest = null;
let periodRequestVersion = 0;
let authStatus = AUTH_STATUS.idle;
let requestLog = [];
@@ -106,6 +108,7 @@
let productsRenderQueued = false;
let opptyProductsByNumber = new Map();
let expandedProductOptyNumbers = new Set();
const aiSummariesByOptyNumber = new Map();
let productsProgress = {
state: "idle",
completed: 0,
@@ -615,7 +618,27 @@
return consumerTokenServiceRequest;
}
async function requestTokenRelay() {
function requestTokenRelaySalesCommon() {
if (salesCommonToken && Date.now() < salesCommonToken.expiresAt) {
return Promise.resolve(salesCommonToken.access_token);
}
if (salesCommonTokenRequest) {
return salesCommonTokenRequest;
}
salesCommonTokenRequest = requestTokenRelay({
name: "requestTokenRelaySalesCommon",
url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`,
includeExpiry: true
}).then((token) => {
salesCommonToken = token;
return token.access_token;
}).finally(() => {
salesCommonTokenRequest = null;
});
return salesCommonTokenRequest;
}
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
let refreshResult = null;
let refreshAttempted = false;
@@ -624,7 +647,7 @@
if (!xsrfTokenSource.token) {
if (refreshAttempted) {
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
throw new Error("XSRF token cookie not found after automatic refresh.");
}
@@ -634,10 +657,10 @@
continue;
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
try {
const response = await fetch(TOKEN_RELAY_URL, {
const response = await fetch(url, {
method: "GET",
credentials: "include",
headers: {
@@ -698,6 +721,14 @@
access_token: maskToken(responseData.access_token)
}, null, 2)));
if (includeExpiry) {
const lifetime = Number(responseData.expires_in);
return {
access_token: responseData.access_token,
expiresAt: Number.isFinite(lifetime) && lifetime > 0
? requestEntry.startedAt.getTime() + lifetime * 1000 : 0
};
}
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
@@ -706,11 +737,11 @@
}
}
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) {
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) {
return logRequestStart({
name: "requestTokenRelay",
name,
method: "GET",
url: TOKEN_RELAY_URL,
url,
headers: {
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
},
@@ -3096,6 +3127,7 @@
expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else {
expandedProductOptyNumbers.add(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber);
}
renderOpportunitiesTable();
@@ -3103,6 +3135,98 @@
return button;
}
function requestGenerateAiSummary(optyNumber) {
const key = String(optyNumber);
const existing = aiSummariesByOptyNumber.get(key);
if (existing && existing.status === "loading") {
return existing.promise;
}
const state = { status: "loading", summaryText: "", error: "", promise: null };
aiSummariesByOptyNumber.set(key, state);
state.promise = (async () => {
let entry;
try {
let token = await requestTokenRelaySalesCommon();
if (!token) {
throw new Error("Authentication is required to generate the summary.");
}
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
let response;
for (let attempt = 0; attempt < 2; attempt += 1) {
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
entry = logRequestStart({
name: "requestGenerateAiSummary", method: "POST", url, headers,
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
});
response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (response.status !== 401 && response.status !== 403) {
break;
}
// A late failure for an old token must not discard a newly refreshed token.
if (salesCommonToken && salesCommonToken.access_token === token) {
salesCommonToken = null;
}
if (attempt === 0) {
token = await requestTokenRelaySalesCommon();
}
}
if (!response.ok) {
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
}
let data;
try {
data = await response.json();
} catch (error) {
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
}
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
throw new Error("requestGenerateAiSummary returned an empty summary.");
}
state.summaryText = data.summaryText;
state.status = "ready";
} catch (error) {
state.status = "error";
state.error = error.message || "Unable to generate the AI summary.";
if (entry) {
logRequestFailure(entry, state.error);
}
} finally {
// Update only visible summaries, without reopening rows or rebuilding the table.
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
if (alert.dataset.optyNumber === key) {
alert.replaceWith(createAiSummaryAlert(key));
}
});
}
})();
return state.promise;
}
function createAiSummaryAlert(optyNumber) {
const state = aiSummariesByOptyNumber.get(optyNumber);
const alert = document.createElement("div");
alert.className = "opportunities-extension-ai-summary";
alert.dataset.optyNumber = optyNumber;
alert.setAttribute("role", "status");
alert.setAttribute("aria-live", "polite");
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
const title = document.createElement("strong");
title.textContent = "Summary generated by AI";
const description = document.createElement("p");
description.textContent = !state || state.status === "loading"
? "Generating summary..."
: state.status === "error" ? state.error : state.summaryText;
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
alert.append(title, description);
return alert;
}
function createProductsDetailRow(opportunity) {
const row = document.createElement("tr");
const cell = document.createElement("td");
@@ -3127,6 +3251,7 @@
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
heading.append(title, summary);
panel.append(heading);
panel.append(createAiSummaryAlert(optyNumber));
if (productsResult && productsResult.error) {
const error = document.createElement("p");
@@ -5245,6 +5370,51 @@
color: #312d2a;
}
.opportunities-extension-ai-summary {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0 0 12px;
padding: 12px 16px;
border: 1px solid #dedbd7;
border-radius: 6px;
background: #ffffff;
color: #312d2a;
font-size: 13px;
line-height: 1.5;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}
.opportunities-extension-ai-summary strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.opportunities-extension-ai-summary p {
margin: 4px 0 0;
font: inherit;
white-space: pre-wrap;
}
.opportunities-extension-ai-summary-error {
color: #b42318;
border-color: #d9928b;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary {
background: #292827;
color: #f6f4f2;
border-color: #5f5a55;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error {
color: #ffb4ab;
border-color: #a45c55;
}
.opportunities-extension-products-detail-heading strong {
font-size: 13px;
}