Skip to content
Merged
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
2 changes: 1 addition & 1 deletion webviewer-ask-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A license key is required to run WebViewer. You can obtain a trial key in our [g

## Initial setup

Before you begin, make sure the development environment includes [Node.js](https://nodejs.org/en/).
Before you begin, make sure the development environment includes [Node.js](https://nodejs.org/en/) 20 or newer.

## Install

Expand Down
39 changes: 39 additions & 0 deletions webviewer-ask-ai/__mocks__/webviewer-ask-ai.mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// This file provides mock implementations for the webviewer-ask-ai module,
// allowing developers to test and develop features without relying on actual
// API calls.

export const MOCK_RESPONSE = {
DOCUMENT_QUESTION: 'In 2011, Rosneft undertook several social responsibility initiatives, including: 1. Support for education by providing RUB 141 million to higher education institutions and extending loans totaling RUB 3.6 million to 97 workers for educational courses. 2. Charity efforts focused on socio-economic projects, healthcare, education, culture, and sports, with a total spending of RUB 2.9 billion on charity. 3. Maintenance of social infrastructure, with spending of RUB 1.0 billion aimed at optimizing facilities for employees and communities [14][15].',
SELECTED_TEXT_SUMMARY: 'The Tuapse license area covers 12,000 square km in the Black Sea and has geological similarities to the West-Kuban Trough, a historic oil production region in Russia [6]. The Tuapse Block has undergone comprehensive 2D seismic work, with the most promising areas also analyzed using 3D seismic technology [6]. Current data indicates the presence of 20 promising structures with an estimated 8.9 billion barrels of recoverable oil resources [6].',
DOCUMENT_CONTEXTUAL_QUESTIONS: '• What strategic partnerships did Rosneft establish in 2011 for offshore exploration?\n• How did Rosneft\'s resource base change in 2011 compared to previous years?\n• What social responsibility initiatives did Rosneft undertake in 2011?'
};

// Registers a Playwright route interceptor that mocks all /api/chat POST requests,
// preventing real network calls to the AI backend during tests.
export const registerApiChatMock = async (page) => {
await page.route('/api/chat', async (route) => {
const requestBody = JSON.parse(route.request().postData() || '{}');
const { promptType } = requestBody;

let response;
switch (promptType) {
case 'DOCUMENT_CONTEXTUAL_QUESTIONS':
response = MOCK_RESPONSE.DOCUMENT_CONTEXTUAL_QUESTIONS;
break;
case 'DOCUMENT_QUESTION':
response = MOCK_RESPONSE.DOCUMENT_QUESTION;
break;
case 'SELECTED_TEXT_SUMMARY':
response = MOCK_RESPONSE.SELECTED_TEXT_SUMMARY;
break;
default:
response = '';
}

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ response }),
});
});
};
131 changes: 131 additions & 0 deletions webviewer-ask-ai/__tests__/e2e/webviewer-ask-ai.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// This file contains end-to-end tests for the WebViewer Ask AI sample application,
// using the Playwright testing framework.

import { test, expect } from '@playwright/test';
import { MOCK_RESPONSE, registerApiChatMock } from '../../__mocks__/webviewer-ask-ai.mock.js';

const question = 'What social responsibility initiatives did Rosneft undertake in 2011?';

// Register the API chat mock before each test
// to ensure consistent and predictable responses
// from the chatbot during testing.
test.beforeEach(async ({ page }) => {
await registerApiChatMock(page);
});

// Validate the chatbot toggle button visibility.
test('Chatbot toggle button visibility', async ({ page }) => {
await page.goto('/client/index.html');

const toggle = page.locator('button[data-element="askWebSDKPanelToggle"]');
await toggle.waitFor({ state: 'visible' });
await expect(toggle).toBeVisible();
});

// Validate the chatbot panel visibility.
test('Chatbot panel visibility', async ({ page }) => {
await page.goto('/client/index.html');

const panel = page.locator('div.ModularPanel[data-element="askWebSDKPanel"]');
await panel.waitFor({ state: 'visible' });
await expect(panel).toBeVisible();
});

// Validate the summarizing selection button visibility.
test('Summarizing selection button visibility', async ({ page }) => {
await page.goto('/client/index.html');
await simulateDocumentTextSelection(page, {
pageNumber: 2,
start: { x: 56.69320848, y: 32.40185332 },
end: { x: 105.11567193, y: 40.06439572 },
});

const popupButton = page.locator('button[data-element="askWebSDKButton"]');
await popupButton.waitFor({ state: 'visible' });
await expect(popupButton).toBeVisible();
});

// Simulates user asking free question within the chatbot panel
test('Ask free question', async ({ page }) => {
await page.goto('/client/index.html');

// Locate the question input field, enter a question, and submit it by pressing 'Enter'
const questionInput = page.locator('#askWebSDKQuestionInput');
await questionInput.fill(question);
await questionInput.press('Enter');

// Validate that the assistant's response contains the expected text from the mock response
const assistantResponse = page.locator('.askWebSDKAssistantMessageClass').last();
await expect(assistantResponse).toContainText(MOCK_RESPONSE.DOCUMENT_QUESTION);
});

// Simulates user selecting text on the document and asking the chatbot to summarize it
test('Summarize selected text', async ({ page }) => {
await page.goto('/client/index.html');
await simulateDocumentTextSelection(page, {
pageNumber: 6,
start: { x: 179.72459999999998, y: 536.4002 },
end: { x: 141.165, y: 415.9352 },
});

// Click the "Summarize selection" button in the TextPopup
const popupButton = page.locator('button[data-element="askWebSDKButton"]');
await popupButton.waitFor({ state: 'visible' });
await popupButton.click();

// Validate that the assistant's response contains the expected text from the mock response
const assistantResponse = page.locator('.askWebSDKAssistantMessageClass').last();
await expect(assistantResponse).toContainText(MOCK_RESPONSE.SELECTED_TEXT_SUMMARY);
});

// Simulates user hiding and showing the chatbot panel
// via clicking the toggle button in the header.
// Chatbot panel should maintain the visibility of the conversation.
test('Hide/Show chatbot panel', async ({ page }) => {
await page.goto('/client/index.html');

// Locate the question input field, enter a question, and submit it by pressing 'Enter'
const questionInput = page.locator('#askWebSDKQuestionInput');
await questionInput.fill(question);
await questionInput.press('Enter');

// Locate the toggle button and chatbot panel
const toggle = page.locator('button[data-element="askWebSDKPanelToggle"]');
const panel = page.locator('div.ModularPanel[data-element="askWebSDKPanel"]');

// Hide the chatbot panel
await toggle.click();
await expect(panel).not.toBeVisible();

// Show the chatbot panel again
await toggle.click();
await expect(panel).toBeVisible();
});

// Helper function to select document text and trigger the TextPopup.
const simulateDocumentTextSelection = async (page, { pageNumber, start, end }) => {
await page.locator('div.ModularPanel[data-element="askWebSDKPanel"]').waitFor({ state: 'visible' });

await page.evaluate(({ pageNumber, start, end }) => {
const instance = globalThis.WebViewer.getInstance();
const core = instance.Core;
const UI = instance.UI;
const documentViewer = core.documentViewer;
const textSelectTool = documentViewer.getTool(core.Tools.ToolNames.TEXT_SELECT);

documentViewer.setCurrentPage(pageNumber);
UI.setToolMode(core.Tools.ToolNames.TEXT_SELECT);
textSelectTool.select({ pageNumber, ...start }, { pageNumber, ...end });
}, { pageNumber, start, end });

await page.waitForFunction(() => {
const instance = globalThis.WebViewer.getInstance();
const selectedText = instance?.Core?.documentViewer?.getSelectedText?.();
return typeof selectedText === 'string' && selectedText.trim().length > 0;
});

await page.evaluate(() => {
const instance = globalThis.WebViewer.getInstance();
instance.UI.openElements(['textPopup']);
});
};
116 changes: 54 additions & 62 deletions webviewer-ask-ai/client/chatbot/chatbot.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,8 @@ class Chatbot {
const message = messagesHistory[i];
let messageTokenCount;

try {
// Use simple character-based estimation for token counting
messageTokenCount = Math.ceil(message.content.length / 4);
} catch (error) {
// Fallback to estimation
messageTokenCount = Math.ceil(message.content.length / 4);
}
// Use simple character-based estimation for token counting
messageTokenCount = Math.ceil(message.content.length / 4);

if (tokenCount + messageTokenCount <= maxTokens) {
trimmedHistory.unshift(message);
Expand All @@ -48,57 +43,53 @@ class Chatbot {
}

async sendMessage(promptLine, message) {
try {
// For document-level operations, use increased token limit to preserve messages history
// Adjust token limits based on prompt type to balance document content and messages history
let maxTokens = this.messagesHistoryOptions.maxTokens || 8000;

// For document questions, we need more room for messages history since we're sending full document
if (promptLine.includes('DOCUMENT_'))
maxTokens = Math.max(maxTokens, 8000); // Ensure minimum 8000 tokens for document questions
const messagesHistoryToSend = this.messagesHistoryOptions.useEmpty ? [] : await this.trimHistoryForTokenLimit(this.messagesHistory, maxTokens);

const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message,
promptType: promptLine,
history: messagesHistoryToSend
})
});

if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);

const data = await response.json();

// Update messages history only if not explicitly disabled
if (!this.messagesHistoryOptions.skipUpdate) {
// For document queries, extract only the question part to avoid storing redundant document content
let historyMessage = message;
if (promptLine.includes('DOCUMENT_')) {
// Extract question from document queries to avoid token waste
const questionMatch = message.match(/(?:Human Question|Question): (.+?)\n\nDocument Content:/);
if (questionMatch)
historyMessage = questionMatch[1];
else
// Fallback: use first 200 chars if pattern not found, avoiding full document
historyMessage = message.length > 200 ? message.substring(0, 200) + '... [document content excluded from history]' : message;
}
// For document-level operations, use increased token limit to preserve messages history
// Adjust token limits based on prompt type to balance document content and messages history
let maxTokens = this.messagesHistoryOptions.maxTokens || 8000;

// For document questions, we need more room for messages history since we're sending full document
if (promptLine.includes('DOCUMENT_'))
maxTokens = Math.max(maxTokens, 8000); // Ensure minimum 8000 tokens for document questions
const messagesHistoryToSend = this.messagesHistoryOptions.useEmpty ? [] : await this.trimHistoryForTokenLimit(this.messagesHistory, maxTokens);

const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message,
promptType: promptLine,
history: messagesHistoryToSend
})
});

this.messagesHistory.push(
{ role: 'human', content: `${promptLine}: ${historyMessage}` },
{ role: 'assistant', content: data.response }
);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);

const data = await response.json();

// Update messages history only if not explicitly disabled
if (!this.messagesHistoryOptions.skipUpdate) {
// For document queries, extract only the question part to avoid storing redundant document content
let historyMessage = message;
if (promptLine.includes('DOCUMENT_')) {
// Extract question from document queries to avoid token waste
const questionMatch = message.match(/(?:Human Question|Question): (.+?)\n\nDocument Content:/);
if (questionMatch)
historyMessage = questionMatch[1];
else
// Fallback: use first 200 chars if pattern not found, avoiding full document
historyMessage = message.length > 200 ? message.substring(0, 200) + '... [document content excluded from history]' : message;
}

return data.response;
} catch (error) {
throw error;
this.messagesHistory.push(
{ role: 'human', content: `${promptLine}: ${historyMessage}` },
{ role: 'assistant', content: data.response }
);
}

return data.response;
}

// Prepare a message, considering contextual question or history question
Expand Down Expand Up @@ -151,7 +142,7 @@ class Chatbot {

askQuestionByPrompt(prompt, question = null) {
// Start spinning on main div
spinner.spin(askWebSDKMainDiv);
spinner.spin(globalThis.askWebSDKMainDiv);

// Create a wrapper callback that stops the spinner after bubble is called
const callbackWrapper = (...args) => {
Expand All @@ -169,7 +160,7 @@ class Chatbot {
// DOCUMENT_QUESTION
async summarizeTextByPrompt(prompt, text) {
// Start spinning on main div
spinner.spin(askWebSDKMainDiv);
spinner.spin(globalThis.askWebSDKMainDiv);

// Combine into single container for all bubble responses
let responseText = '';
Expand Down Expand Up @@ -204,9 +195,11 @@ class Chatbot {
let updatedCount = 0;
questionsLIs.forEach((configAndLiTags) => {

if (configAndLiTags[0] && configAndLiTags[0].promptType === 'DOCUMENT_CONTEXTUAL_QUESTION_EXACTLY') {
if (configAndLiTags[0]?.promptType === 'DOCUMENT_CONTEXTUAL_QUESTION_EXACTLY') {

if (this.questionsContextuallySound[index] !== undefined) {
if (this.questionsContextuallySound[index] === undefined) {
console.warn(`Question ${index + 1} is undefined! Available questions:`, this.questionsContextuallySound);
} else {
let li = configAndLiTags[1];
if (li)
li.innerText = this.questionsContextuallySound[index];
Expand All @@ -218,8 +211,7 @@ class Chatbot {
configItem.content = this.questionsContextuallySound[index];

updatedCount++;
} else
console.warn(`Question ${index + 1} is undefined! Available questions:`, this.questionsContextuallySound);
}

index++;
}
Expand Down Expand Up @@ -248,8 +240,8 @@ class Chatbot {
let messageDiv = document.createElement('div');
messageDiv.className = (role === 'assistant') ? 'askWebSDKAssistantMessageClass' : 'askWebSDKHumanMessageClass';
messageDiv.innerHTML = content;
askWebSDKChattingDiv.appendChild(messageDiv);
askWebSDKChattingDiv.scrollTop = askWebSDKChattingDiv.scrollHeight;
globalThis.askWebSDKChattingDiv.appendChild(messageDiv);
globalThis.askWebSDKChattingDiv.scrollTop = globalThis.askWebSDKChattingDiv.scrollHeight;
}
};

Expand Down
3 changes: 3 additions & 0 deletions webviewer-ask-ai/client/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ let clipboard = '';

// Chatbot panel div elements
let askWebSDKMainDiv = null;
globalThis.askWebSDKMainDiv = askWebSDKMainDiv;
let askWebSDKChattingDiv = null;
globalThis.askWebSDKChattingDiv = askWebSDKChattingDiv;
let assistantContentDiv = null;
globalThis.assistantContentDiv = assistantContentDiv;

// Chatbot panel conversation log
// to keep track of assistant and human messages
Expand Down
Loading