-
Notifications
You must be signed in to change notification settings - Fork 16
Adding webviewer-ask-ai sample #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
f468936
09dbab4
faa3d3a
df67bd5
8e1fe0e
8c8a0ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| DOTENV_CONFIG_QUIET=true | ||
|
|
||
| # OpenAI Configuration | ||
| OPENAI_API_KEY=your-openai-api-key-here | ||
| OPENAI_MODEL=your-openai-model-here | ||
| OPENAI_TEMPERATURE=your-openai-temperature-here | ||
| OPENAI_MAX_TOKENS=your-openai-max-tokens-here | ||
|
|
||
| # Server Configuration | ||
| PORT=4040 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # Misc | ||
| .DS_Store | ||
| node_modules | ||
|
|
||
| # WebViewer | ||
| client/lib | ||
| client/license-key.js |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| Copyright (c) 2025 Apryse Software Inc. All Rights Reserved. | ||
| WebViewer UI project/codebase or any derived works is only permitted in solutions with an active commercial Apryse WebViewer license. For exact licensing terms refer to the commercial WebViewer license. For licensing, pricing, or product questions, Contact [Sales](https://apryse.com/form/contact-sales). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| # WebViewer - Ask AI sample | ||
|
|
||
| [WebViewer](https://docs.apryse.com/web/guides/get-started) is a powerful JavaScript-based PDF Library that is part of the [Apryse SDK](https://apryse.com/). | ||
|
|
||
| - [WebViewer Documentation](https://docs.apryse.com/web/guides/get-started) | ||
| - [WebViewer Demo](https://showcase.apryse.com/) | ||
|
|
||
| This sample demonstrates how to utilize the artificial intelligence capabilities within the WebViewer, using a chat panel interface to ask questions about the loaded document. Also the user can select a text in the document that can be summarized. | ||
|
|
||
| ## Get your trial key | ||
|
|
||
| A license key is required to run WebViewer. You can obtain a trial key in our [get started guides](https://docs.apryse.com/web/guides/get-started), or by signing-up on our [developer portal](https://dev.apryse.com/). | ||
|
|
||
| ## Initial setup | ||
|
|
||
| Before you begin, make sure the development environment includes [Node.js](https://nodejs.org/en/). | ||
|
|
||
| ## Install | ||
|
|
||
| ``` | ||
| git clone --depth=1 https://github.com/ApryseSDK/webviewer-samples.git | ||
| cd webviewer-samples/webviewer-ask-ai | ||
| npm install | ||
| ``` | ||
|
|
||
| ## Configuration | ||
|
|
||
| This sample uses OpenAI. You can use any other artificial intelligence of your choice. | ||
|
|
||
| However, to get started with this sample rename `.env.example` file into `.env` and fill the followings: | ||
|
|
||
| ``` | ||
| OPENAI_API_KEY=your-openai-api-key-here | ||
| OPENAI_MODEL=your-openai-model-here | ||
| OPENAI_TEMPERATURE=your-openai-temperature-here | ||
| OPENAI_MAX_TOKENS=your-openai-max-tokens-here | ||
| ``` | ||
|
|
||
| ## Run | ||
|
|
||
| ``` | ||
| npm start | ||
| ``` | ||
|
|
||
| This will start a server that you can access the WebViewer client at http://localhost:4040/client/index.html, and the connection to the OpenAI will be managed on backend. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| // Browser-compatible chatbot client | ||
| class ChatbotClient { | ||
| constructor() { | ||
| this.conversationHistory = []; | ||
| } | ||
|
|
||
| // Initialize chat interface for the WebViewer panel | ||
| initialize = () => { | ||
| // You can expand this to integrate with the WebViewer panel UI | ||
| window.chatbot = this; // Make chatbot available globally for testing | ||
| }; | ||
|
|
||
| async sendMessage(promptLine, message, options = {}) { | ||
| try { | ||
| // For document-level operations, optionally use empty history to prevent token overflow | ||
| const historyToSend = options.useEmptyHistory ? [] : this.conversationHistory; | ||
| const response = await fetch('/api/chat', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ | ||
| message: message, | ||
| promptType: promptLine, | ||
| history: historyToSend | ||
| }) | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`HTTP error! status: ${response.status}`); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
|
|
||
| // Update conversation history only if not explicitly disabled | ||
| if (!options.skipHistoryUpdate) { | ||
| this.conversationHistory.push( | ||
| { role: 'human', content: `${promptLine}: ${message.substring(0, 100)}...` }, // Truncate long messages in history | ||
| { role: 'assistant', content: data.response } | ||
| ); | ||
| } | ||
|
|
||
| return data.response; | ||
| } catch (error) { | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| //Combine into single container for all bubble responses | ||
| getAllText = async (promptType, createBubble) => { | ||
| const doc = window.WebViewer.getInstance().Core.documentViewer.getDocument(); | ||
| doc.getDocumentCompletePromise().then(async () => { | ||
|
|
||
| const pageCount = doc.getPageCount(); | ||
| const pageTexts = new Array(pageCount); | ||
| let loadedPages = 0; | ||
|
|
||
| // Load all pages and store them in correct order | ||
| for (let i = 1; i <= pageCount; i++) { | ||
| try { | ||
| const text = await doc.loadPageText(i); | ||
| // Store with page break BEFORE the content | ||
| pageTexts[i - 1] = `<<PAGE_BREAK>> Page ${i}\n${text}`; | ||
| loadedPages++; | ||
|
|
||
| // When all pages are loaded, combine and send | ||
| if (loadedPages === pageCount) { | ||
| const completeText = pageTexts.join('\n\n'); | ||
|
|
||
| // Use empty history for document-level operations to prevent token overflow | ||
| this.sendMessage(promptType, completeText, { | ||
| useEmptyHistory: true, | ||
| skipHistoryUpdate: false // Still update history but with truncated content | ||
| }).then(response => { | ||
| let responseText = this.responseText(response); | ||
| responseText = this.formatText(promptType, responseText); | ||
| createBubble(responseText, 'assistant'); | ||
| }).catch(error => { | ||
| createBubble(`Error: ${error.message}`, 'assistant'); | ||
| }); | ||
| } | ||
| } catch (error) { | ||
| pageTexts[i - 1] = `<<PAGE_BREAK>> Page ${i}\n[Error loading page content]`; | ||
| loadedPages++; | ||
|
|
||
| // Still proceed if all pages are processed (even with errors) | ||
| if (loadedPages === pageCount) { | ||
| const completeText = pageTexts.join('\n\n'); | ||
|
|
||
| this.sendMessage(promptType, completeText, { | ||
| useEmptyHistory: true, | ||
| skipHistoryUpdate: false | ||
| }).then(response => { | ||
| let responseText = this.responseText(response); | ||
| responseText = this.formatText(promptType, responseText); | ||
| createBubble(responseText, 'assistant'); | ||
| }).catch(error => { | ||
| createBubble(`Error: ${error.message}`, 'assistant'); | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| // Extract text from OpenAI response via LangChain | ||
| responseText = (response) => { | ||
| // Primary: Server should send clean string content | ||
| if (typeof response === 'string') { | ||
| return response; | ||
| } | ||
|
|
||
| // Fallback: if server still sends complex object, extract properly | ||
| if (typeof response === 'object' && response !== null) { | ||
|
|
||
| // Standard LangChain approach: use .content property directly | ||
| if (response.content !== undefined) { | ||
| return response.content; | ||
| } | ||
|
|
||
| // Fallback for serialized LangChain objects | ||
| if (response.kwargs && response.kwargs.content) { | ||
| return response.kwargs.content; | ||
| } | ||
|
|
||
| return JSON.stringify(response); | ||
| } | ||
|
|
||
| return 'No response received'; | ||
| }; | ||
|
|
||
| // Format text to include cited page links | ||
| // and page breaks based on prompt type | ||
| formatText = (promptType, text) => { | ||
| switch (promptType) { | ||
| case 'DOCUMENT_SUMMARY': | ||
| case 'SELECTED_TEXT_SUMMARY': | ||
| case 'DOCUMENT_QUESTION': | ||
| // Add page breaks to page citation ends with period | ||
| text = text.replace(/(\d+\])\./g, '$1.<br/><br/>'); | ||
| break; | ||
| case 'DOCUMENT_KEYWORDS': | ||
| // Format bullet points with line breaks | ||
| let lines = text.split(/•\s*/).filter(Boolean); | ||
| text = lines.map(line => `• ${line.trim()}`).join('<br/>'); | ||
| break; | ||
| default: | ||
| break; | ||
| } | ||
|
|
||
| // Separate citations group on form [1, 2, 3] to individual [1][2][3] | ||
| text = this.separateGroupedCitations(text, /\[\d+(?:\s*,\s*\d+)+\]/g); | ||
|
|
||
| // Separate citations range on form [1-3] to individual [1][2][3] | ||
| text = this.separateGroupedCitations(text, /\[\d+(?:\s*-\s*\d+)+\]/g); | ||
|
|
||
| let matches = text.match(/\[\d+\]/g); | ||
| if (matches && matches.length > 0) { | ||
| // Element duplicate matches | ||
| matches = [...new Set(matches)]; | ||
|
|
||
| let pageNumber = 1; | ||
| // match to be turned into link | ||
| matches.forEach(match => { | ||
| pageNumber = match.match(/\d+/)[0]; | ||
|
||
| if (pageNumber > 0 && | ||
| pageNumber <= window.WebViewer.getInstance().Core.documentViewer.getDocument().getPageCount()) { | ||
| const pageLink = `<a href="#" style="color:blue;" onclick="window.WebViewer.getInstance().Core.documentViewer.setCurrentPage(${pageNumber}, true);">[${pageNumber}]</a>`; | ||
| text = text.replaceAll(match, `${pageLink}`); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| return text; | ||
| } | ||
|
|
||
| // Helper to separate grouped citations on form [1, 2, 3] or [1-3] into individual [1][2][3] | ||
| separateGroupedCitations = (text, pattern) => { | ||
| let matches = text.match(pattern); | ||
| if (matches && matches.length > 0) { | ||
| let formattedMatchNumbers = ''; | ||
| matches.forEach(match => { | ||
| let matchNumbers = match.match(/\d+/g); | ||
| matchNumbers.forEach(matchNumber => { | ||
| formattedMatchNumbers += `[${matchNumber}]`; | ||
| }); | ||
|
|
||
| text = text.replaceAll(match, formattedMatchNumbers); | ||
| formattedMatchNumbers = ''; | ||
| }); | ||
| } | ||
|
|
||
| return text; | ||
| } | ||
|
|
||
| clearHistory() { | ||
| this.conversationHistory = []; | ||
| } | ||
| } | ||
|
|
||
| // Export for use in other modules | ||
| export default function createChatbot() { | ||
| return new ChatbotClient(); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This routine is currently duplicated (see lines 127–165), which suggests it could be extracted into a shared helper.