initial commit of chrome extension

This commit is contained in:
David White 2025-10-10 13:49:14 -04:00
parent 5566cc499e
commit de82c720fc
8 changed files with 3880 additions and 0 deletions

161
extension/chrome/README.md Normal file
View file

@ -0,0 +1,161 @@
# LibreChat Assistant Chrome Extension
A Chrome extension that enhances your LibreChat experience by providing context from your browser tabs. Ask questions about any webpage you're viewing, and the assistant will use the page content to provide informed responses.
## Features
- 🔍 Extract readable content from any webpage
- 💬 Ask questions about the current page in a side panel
- 🔐 Secure authentication with your LibreChat instance
- 📝 Maintains conversation history during your session
- ⚡ Streaming responses for real-time interaction
## Prerequisites
- A running LibreChat instance (self-hosted or provided by your organization)
- Google Chrome or Chromium-based browser
- Access credentials for your LibreChat instance
## Installation
### 1. Configure Your LibreChat Domain
Before loading the extension, you need to replace the placeholder domain with your actual LibreChat instance URL.
**Files to update:**
1. **manifest.json** (lines 14 and 23)
- Replace `https://REPLACE_ME.com/` with your LibreChat domain
- Example: `https://chat.example.com/`
2. **background.js** (line 1)
- Replace `https://REPLACE_ME.com` with your LibreChat domain (no trailing slash)
- Example: `https://chat.example.com`
**Example:**
```javascript
// Before
const LIBRECHAT_URL = 'https://REPLACE_ME.com';
// After
const LIBRECHAT_URL = 'https://chat.example.com';
```
### 2. Load the Extension in Chrome
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in the top-right corner)
3. Click "Load unpacked"
4. Select the folder containing this extension
5. The LibreChat Assistant icon should appear in your Chrome toolbar
## Usage
### First Time Setup
1. Navigate to your LibreChat instance in Chrome
2. Sign in with your credentials
3. Click the LibreChat Assistant icon in your Chrome toolbar
### Using the Assistant
1. **Open the side panel**: Click the LibreChat Assistant icon in your toolbar
2. **Navigate to any webpage**: The extension works on any page
3. **Ask questions**: Type your question in the text box and press Enter or click "Ask"
- Example: "Summarize this page for me"
- Example: "What are the main points in this article?"
- Example: "Explain this concept in simple terms"
4. **Start new conversations**: Click "New Chat" to clear the conversation history
### Authentication
- The extension automatically manages authentication with your LibreChat instance
- If you see a "Reconnect to LibreChat" button, click it to refresh your session
- You'll need to be signed into LibreChat in a browser tab for the extension to work
## How It Works
1. **Content Extraction**: Uses Mozilla's Readability library to extract clean, readable content from web pages
2. **Context Provision**: Sends the page content (up to 10,000 characters) along with your question to LibreChat
3. **Streaming Responses**: Displays the AI response in real-time as it's generated
4. **Secure Communication**: Uses your LibreChat access token for authenticated API requests
## Permissions Explained
The extension requires the following permissions:
- **storage**: To cache your authentication token securely
- **cookies**: To manage session authentication with LibreChat
- **sidePanel**: To display the chat interface
- **scripting**: To extract content from web pages
- **activeTab**: To access the current page's content
- **host_permissions**: To communicate with your LibreChat instance
## Troubleshooting
### "Open your librechat site in a new tab to refresh your session"
- **Solution**: Open your LibreChat instance in a new tab and sign in
### "Token request failed. Log back into your librechat site"
- **Solution**: Navigate to your LibreChat instance and sign in again
### Extension not loading
- **Check**: Verify you've replaced all instances of `REPLACE_ME.com` with your actual domain
- **Check**: Ensure your LibreChat instance is accessible and running
- **Check**: Look for errors in Chrome DevTools (Extensions page > "Errors" button)
### "Could not read page content"
- Some pages may have restrictions that prevent content extraction
- Try refreshing the page and attempting again
- Chrome system pages (chrome://) cannot be accessed by extensions
## Privacy & Security
- ✅ No hardcoded credentials or API keys
- ✅ Authentication tokens stored securely in session storage
- ✅ All communication with your LibreChat instance uses HTTPS
- ✅ Page content is only sent when you explicitly ask a question
- ✅ No data is sent to third parties
## Development
### File Structure
```
.
├── manifest.json # Extension configuration
├── background.js # Service worker for API calls and auth
├── contentScript.js # Handles token refresh from LibreChat page
├── sidepanel.html # Side panel UI structure
├── sidepanel.css # Side panel styling
├── sidepanel.js # Side panel interaction logic
├── Readability.js # Mozilla's content extraction library
└── README.md # This file
```
### Key Components
- **background.js**: Manages authentication, token caching, and API communication
- **contentScript.js**: Injected into LibreChat pages to handle token refresh
- **sidepanel.js**: Handles user interactions and displays streaming responses
## License
This extension uses Mozilla's Readability library (https://github.com/mozilla/readability), which is licensed under the Apache License 2.0.
## Support
If you encounter issues:
1. Check the troubleshooting section above
2. Verify your LibreChat instance is accessible
3. Check the browser console for error messages
4. Ensure you're using a compatible version of LibreChat
---
**Note**: This extension requires a LibreChat instance to function. It does not work with other chat services.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,287 @@
const LIBRECHAT_URL = 'https://REPLACE_ME.com';
const TOKEN_STORAGE_KEY = 'librechat.accessToken';
const TOKEN_DEFAULT_TTL = 55 * 1000;
function safeSendRuntimeMessage(message) {
try {
chrome.runtime.sendMessage(message);
} catch (error) {
if (error?.message?.includes('Extension context invalidated')) {
console.debug('Skipping runtime message, context invalidated');
} else {
console.warn('Failed to send runtime message', error);
}
}
}
async function getAccessToken() {
const cached = await loadCachedToken();
if (cached) {
return cached;
}
return requestAccessTokenFromPage();
}
async function loadCachedToken() {
const stored = await chrome.storage.session.get(TOKEN_STORAGE_KEY);
const entry = stored[TOKEN_STORAGE_KEY];
if (!entry || !entry.token) {
return null;
}
if (entry.expiresAt && Date.now() >= entry.expiresAt) {
await chrome.storage.session.remove(TOKEN_STORAGE_KEY);
return null;
}
return entry.token;
}
async function cacheToken(token, metadata = {}) {
const expiresAt = deriveExpiry(metadata);
await chrome.storage.session.set({
[TOKEN_STORAGE_KEY]: {
token,
expiresAt
}
});
}
function deriveExpiry({ expires, expiresIn }) {
if (expires) {
const ts = Date.parse(expires);
if (!Number.isNaN(ts)) {
return ts;
}
}
if (expiresIn && Number.isFinite(expiresIn)) {
return Date.now() + expiresIn * 1000;
}
return Date.now() + TOKEN_DEFAULT_TTL;
}
async function clearCachedToken() {
await chrome.storage.session.remove(TOKEN_STORAGE_KEY);
}
async function ensureLibreChatTab() {
const tabs = await chrome.tabs.query({ url: `${LIBRECHAT_URL}/*` });
if (!tabs || tabs.length === 0) {
const error = new Error('Open your librechat site in a new tab to refresh your session.');
error.requiresAuth = true;
throw error;
}
return tabs[0];
}
async function requestAccessTokenFromPage() {
const tab = await ensureLibreChatTab();
let response;
try {
response = await sendAccessTokenRequest(tab.id);
} catch (error) {
const wrapped = new Error(error?.message || 'Token request failed. Log back into your librechat site.');
wrapped.requiresAuth = true;
throw wrapped;
}
if (!response || !response.success) {
const error = new Error(response?.error || 'Token request failed. Log back into your librechat site');
error.requiresAuth = true;
throw error;
}
await cacheToken(response.token, { expires: response.expires, expiresIn: response.expiresIn });
return response.token;
}
async function sendAccessTokenRequest(tabId) {
try {
return await chrome.tabs.sendMessage(tabId, { type: 'requestAccessToken' });
} catch (initialError) {
if (initialError && initialError.message && initialError.message.includes('Could not establish connection')) {
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['contentScript.js'] });
return await chrome.tabs.sendMessage(tabId, { type: 'requestAccessToken' });
} catch (retryError) {
throw retryError;
}
}
throw initialError;
}
}
async function initiateAuthHandshake(forceNewTab = false) {
try {
if (forceNewTab) {
await chrome.tabs.create({ url: `${LIBRECHAT_URL}/` });
return;
}
const tab = await ensureLibreChatTab();
await chrome.tabs.update(tab.id, { active: true });
} catch (error) {
if (error.requiresAuth || forceNewTab) {
await chrome.tabs.create({ url: `${LIBRECHAT_URL}/` });
} else {
throw error;
}
}
}
chrome.action.onClicked.addListener((tab) => {
chrome.sidePanel.open({ windowId: tab.windowId });
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'searchWithContext') {
handleSearchRequest(message.query);
} else if (message.type === 'initiateAuthHandshake') {
initiateAuthHandshake(Boolean(message.forceNewTab))
.then(() => sendResponse({ success: true }))
.catch((error) => sendResponse({ success: false, error: error.message }));
return true;
} else if (message.type === 'checkAuthStatus') {
getAccessToken()
.then((token) => {
safeSendRuntimeMessage({ type: 'authStatus', authenticated: true });
sendResponse({ success: true, authenticated: true, token });
})
.catch((error) => {
safeSendRuntimeMessage({ type: 'authStatus', authenticated: false, requiresAuth: Boolean(error.requiresAuth), error: error.message });
sendResponse({ success: false, authenticated: false, error: error.message, requiresAuth: Boolean(error.requiresAuth) });
});
return true;
}
});
async function handleSearchRequest(query) {
let accessToken;
try {
// 1. Get a fresh, valid access token for this request.
accessToken = await getAccessToken();
safeSendRuntimeMessage({ type: 'authStatus', authenticated: true });
} catch (error) {
safeSendRuntimeMessage({ type: 'authStatus', authenticated: false, requiresAuth: Boolean(error.requiresAuth), error: error.message });
safeSendRuntimeMessage({ type: 'streamError', error: error.message, requiresAuth: Boolean(error.requiresAuth) });
return;
}
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab || !tab.id || !tab.url || tab.url.startsWith('chrome://')) { return; }
let pageContent;
try {
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['Readability.js'] });
const res = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => new Readability(document.cloneNode(true)).parse().textContent });
pageContent = res[0].result;
} catch (err) {
safeSendRuntimeMessage({ type: 'streamError', error: "Could not read page content." });
return;
}
if (!pageContent || !pageContent.trim()) {
safeSendRuntimeMessage({ type: 'streamError', error: "Page has no readable content." });
return;
}
const fullPrompt = `Based on the following context...\n\n---CONTEXT---\n${pageContent.substring(0, 10000)}\n\n---QUERY---\n${query}`;
try {
const response = await fetch(`${LIBRECHAT_URL}/api/agents/chat/openAI`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}` // 2. Use the accessToken here.
},
body: JSON.stringify({
conversationId: null,
parentMessageId: null,
endpoint: "openAI",
model: 'gpt-4o',
text: fullPrompt,
isContinued: false,
isCreatedByUser: true,
sender: "User"
})
});
if (!response.ok) {
if (response.status === 401) {
await clearCachedToken();
}
const errorBody = await response.text();
const apiError = new Error(`API request failed: ${response.status} ${errorBody}`);
if (response.status === 401 || response.status === 403) {
apiError.requiresAuth = true;
}
throw apiError;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let streamBuffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
streamBuffer += decoder.decode(value, { stream: true });
const segments = streamBuffer.split('\n');
streamBuffer = segments.pop() || '';
processStreamSegments(segments);
}
if (streamBuffer.trim()) {
processStreamSegments([streamBuffer]);
}
} catch (error) {
console.error("Error during API call:", error);
safeSendRuntimeMessage({ type: 'streamError', error: error.message, requiresAuth: Boolean(error.requiresAuth) });
if (error.requiresAuth) {
safeSendRuntimeMessage({ type: 'authStatus', authenticated: false, requiresAuth: true, error: error.message });
}
} finally {
safeSendRuntimeMessage({ type: 'streamEnd' });
}
}
function processStreamSegments(segments) {
for (const rawSegment of segments) {
const trimmed = rawSegment.trim();
if (!trimmed) {
continue;
}
let payloadString = trimmed;
if (payloadString.startsWith('data:')) {
payloadString = payloadString.slice(5).trim();
}
if (!payloadString || payloadString === '[DONE]') {
continue;
}
try {
const payload = JSON.parse(payloadString);
const text = extractTextFromPayload(payload);
if (text) {
safeSendRuntimeMessage({ type: 'streamChunk', chunk: text });
}
} catch (error) {
console.debug('Failed to parse stream segment', payloadString, error);
}
}
}
function extractTextFromPayload(payload) {
if (!payload) {
return '';
}
if (typeof payload === 'string') {
return payload;
}
if (payload.text) {
return payload.text;
}
if (payload.data && payload.data.delta && Array.isArray(payload.data.delta.content)) {
return payload.data.delta.content
.filter((item) => item && item.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('');
}
if (payload.delta && Array.isArray(payload.delta.content)) {
return payload.delta.content
.filter((item) => item && item.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('');
}
return '';
}

View file

@ -0,0 +1,115 @@
const ORIGIN = window.location.origin;
const REFRESH_ENDPOINT = `${ORIGIN}/api/auth/refresh`;
function safeSendResponse(sendResponse, payload) {
if (typeof sendResponse !== 'function') {
return;
}
if (!isRuntimeAvailable()) {
console.debug('Skipping token response because the extension runtime is unavailable');
return;
}
try {
sendResponse(payload);
} catch (error) {
if (error && error.message && error.message.includes('Extension context invalidated')) {
console.debug('Token response skipped due to invalidated context');
} else {
console.warn('Failed to deliver token response', error);
}
}
}
function isRuntimeAvailable() {
return Boolean(chrome.runtime && chrome.runtime.id);
}
let tokenListenerCleanup = null;
let runtimeWatcherInstalled = false;
function registerTokenListener() {
if (window.__librechatTokenListenerRegistered || !isRuntimeAvailable()) {
return;
}
const handler = (message, sender, sendResponse) => {
if (!isRuntimeAvailable()) {
safeSendResponse(sendResponse, { success: false, error: 'Extension runtime unavailable' });
return false;
}
if (!message || message.type !== 'requestAccessToken') {
return;
}
(async () => {
try {
const response = await fetch(REFRESH_ENDPOINT, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Refresh failed: ${response.status} ${body}`);
}
const data = await response.json();
if (!data || !data.token) {
throw new Error('Refresh response missing access token');
}
safeSendResponse(sendResponse, { success: true, token: data.token, expires: data.expires ?? data.expiresAt ?? null, expiresIn: data.expiresIn ?? null });
} catch (error) {
safeSendResponse(sendResponse, { success: false, error: error.message });
}
})();
return true;
};
try {
chrome.runtime.onMessage.addListener(handler);
} catch (error) {
if (error && error.message && error.message.includes('Extension context invalidated')) {
console.debug('Skipping token listener registration due to invalidated context');
} else {
console.warn('Failed to register token listener', error);
}
return;
}
window.__librechatTokenListenerRegistered = true;
tokenListenerCleanup = () => {
if (!window.__librechatTokenListenerRegistered) {
return;
}
if (chrome.runtime?.onMessage?.removeListener) {
try {
chrome.runtime.onMessage.removeListener(handler);
} catch (error) {
console.debug('Failed to remove token listener', error);
}
}
window.__librechatTokenListenerRegistered = false;
tokenListenerCleanup = null;
};
const teardownOptions = { once: true };
window.addEventListener('pagehide', tokenListenerCleanup, teardownOptions);
window.addEventListener('unload', tokenListenerCleanup, teardownOptions);
}
registerTokenListener();
if (!runtimeWatcherInstalled) {
runtimeWatcherInstalled = true;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !window.__librechatTokenListenerRegistered) {
registerTokenListener();
}
});
}

View file

@ -0,0 +1,42 @@
{
"manifest_version": 3,
"name": "LibreChat Assistant",
"version": "1.0.0",
"description": "Enhances LibreChat by providing context from your browser tabs.",
"permissions": [
"storage",
"cookies",
"sidePanel",
"scripting",
"activeTab"
],
"host_permissions": [
"https://REPLACE_ME.com/",
"<all_urls>"
],
"_comment": "IMPORTANT: Replace 'REPLACE_ME.com' with your actual LibreChat domain in host_permissions and content_scripts matches before loading this extension. Also update LIBRECHAT_URL in background.js with the same domain.",
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": [
"https://REPLACE_ME.com/*"
],
"js": ["contentScript.js"],
"run_at": "document_idle"
}
],
"action": {
"default_title": "LibreChat Assistant"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"web_accessible_resources": [
{
"resources": ["Readability.js"],
"matches": ["<all_urls>"]
}
]
}

View file

@ -0,0 +1,128 @@
body {
font-family: sans-serif;
padding: 10px;
margin: 0;
color: #333;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px;
gap: 8px;
}
h3 {
margin: 0;
}
#new-chat-button {
padding: 6px 12px;
background-color: #6c757d;
color: #fff;
border: none;
border-radius: 16px;
cursor: pointer;
font-size: 0.85rem;
}
#new-chat-button:disabled {
background-color: #b5b8bc;
cursor: not-allowed;
}
.conversation-container {
flex-grow: 1;
overflow-y: auto;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.placeholder {
color: #888;
}
#query-form {
display: flex;
flex-direction: column;
}
#query-input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
resize: vertical;
margin-bottom: 5px;
}
#submit-button {
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#submit-button:disabled {
background-color: #aaa;
cursor: not-allowed;
}
#reconnect-button {
margin-top: 10px;
padding: 10px;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
width: 100%;
}
#reconnect-button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.message {
display: flex;
}
.message.user {
justify-content: flex-end;
}
.message.assistant {
justify-content: flex-start;
}
.message .bubble {
max-width: 80%;
padding: 8px 12px;
border-radius: 18px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
}
.message.user .bubble {
background-color: #007bff;
color: #fff;
border-bottom-right-radius: 4px;
}
.message.assistant .bubble {
background-color: #f1f3f5;
color: #333;
border-bottom-left-radius: 4px;
}
.blinking-cursor {
display: inline-block;
width: 8px;
height: 1.2em;
background-color: #333;
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
@keyframes blink {
50% {
background-color: transparent;
}
}

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="sidepanel.css">
</head>
<body>
<div class="container">
<header class="panel-header">
<h3>LibreChat Assistant</h3>
<button type="button" id="new-chat-button">New Chat</button>
</header>
<div id="conversation-container" class="conversation-container">
<p class="placeholder">Ask a question about the current page...</p>
</div>
<form id="query-form">
<textarea id="query-input" placeholder="e.g., Summarize this page for me" rows="3"></textarea>
<button type="submit" id="submit-button">Ask</button>
</form>
</div>
<script src="sidepanel.js"></script>
</body>
</html>

View file

@ -0,0 +1,312 @@
if (!window.__librechatSidepanelInitialized) {
window.__librechatSidepanelInitialized = true;
const form = document.getElementById('query-form');
const input = document.getElementById('query-input');
const conversationContainer = document.getElementById('conversation-container');
const submitButton = document.getElementById('submit-button');
const newChatButton = document.getElementById('new-chat-button');
let conversation = [];
let currentAssistantBubble = null;
let reconnectButton = null;
let responseBuffer = '';
let isAuthenticated = false;
ensurePlaceholder();
updateAuthStateFromStorage();
listenForTokenChanges();
if (newChatButton) {
newChatButton.addEventListener('click', startNewChat);
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const query = input.value.trim();
if (query) {
removeReconnectButton();
submitButton.disabled = true;
input.disabled = true;
if (newChatButton) {
newChatButton.disabled = true;
}
responseBuffer = '';
appendUserMessage(query);
beginAssistantResponse();
console.log(`DEBUG (SP): Sending query to background: "${query}"`);
chrome.runtime.sendMessage({ type: 'searchWithContext', query });
}
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (!submitButton.disabled) {
form.requestSubmit();
}
}
});
chrome.runtime.onMessage.addListener((message) => {
console.log('DEBUG (SP): Message received from background:', message);
if (message.type === 'authStatus') {
isAuthenticated = Boolean(message.authenticated);
if (isAuthenticated) {
removeReconnectButton();
ensurePlaceholder();
}
return;
}
if (message.type === 'streamChunk' && message.chunk) {
isAuthenticated = true;
removeReconnectButton();
responseBuffer += message.chunk;
updateAssistantBubble(responseBuffer, true);
return;
}
if (message.type === 'streamError') {
const errorText = message.error || 'An unknown error occurred.';
if (message.requiresAuth) {
isAuthenticated = false;
updateAssistantBubble(`${errorText} Please sign in to LibreChat and try again.`, false);
showReconnectButton();
} else {
updateAssistantBubble(`Error: ${errorText}`, false);
}
currentAssistantBubble = null;
responseBuffer = '';
resetUI();
return;
}
if (message.type === 'streamEnd') {
finalizeAssistantBubble();
resetUI();
}
});
function resetUI() {
console.log('DEBUG (SP): Resetting UI.');
submitButton.disabled = false;
input.disabled = false;
input.value = '';
input.focus();
if (newChatButton) {
newChatButton.disabled = false;
}
}
function removeReconnectButton() {
if (reconnectButton) {
reconnectButton.remove();
reconnectButton = null;
ensurePlaceholder();
}
}
function showReconnectButton() {
if (reconnectButton) {
return;
}
reconnectButton = document.createElement('button');
reconnectButton.type = 'button';
reconnectButton.id = 'reconnect-button';
reconnectButton.textContent = 'Reconnect to LibreChat';
reconnectButton.addEventListener('click', handleReconnectClick);
if (conversationContainer) {
removePlaceholder();
conversationContainer.appendChild(reconnectButton);
scrollConversation();
}
}
async function handleReconnectClick() {
if (!reconnectButton) {
return;
}
reconnectButton.disabled = true;
const originalText = 'Reconnect to LibreChat';
reconnectButton.textContent = 'Opening LibreChat…';
try {
const result = await chrome.runtime.sendMessage({ type: 'initiateAuthHandshake', forceNewTab: true });
if (!result || !result.success) {
throw new Error(result?.error || 'Unknown error');
}
reconnectButton.textContent = 'LibreChat opened. Sign in, then try again.';
await delay(1500);
await updateAuthStateFromStorage();
} catch (error) {
reconnectButton.textContent = `Reconnect failed: ${error.message}`;
} finally {
reconnectButton.disabled = false;
setTimeout(() => {
if (reconnectButton) {
reconnectButton.textContent = originalText;
}
}, 4000);
}
}
function listenForTokenChanges() {
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== 'session' || !changes['librechat.accessToken']) {
return;
}
const newValue = changes['librechat.accessToken'].newValue;
isAuthenticated = Boolean(newValue && newValue.token);
if (isAuthenticated) {
removeReconnectButton();
ensurePlaceholder();
}
});
}
function startNewChat() {
conversation = [];
currentAssistantBubble = null;
responseBuffer = '';
removeReconnectButton();
if (conversationContainer) {
conversationContainer.innerHTML = '';
}
ensurePlaceholder();
resetUI();
}
function appendUserMessage(text) {
const bubble = createMessageBubble('user', text);
if (bubble) {
conversation.push({ role: 'user', content: text });
}
}
function beginAssistantResponse() {
responseBuffer = '';
currentAssistantBubble = null;
updateAssistantBubble('', true);
}
function updateAssistantBubble(text, showCursor) {
const assistant = ensureAssistantBubble();
if (!assistant) {
return;
}
if (assistant.cursor) {
assistant.cursor.remove();
assistant.cursor = null;
}
assistant.bubble.textContent = text;
if (showCursor) {
assistant.cursor = appendCursor(assistant.bubble);
}
if (conversation.length && conversation[conversation.length - 1].role === 'assistant') {
conversation[conversation.length - 1].content = text;
}
scrollConversation();
}
function finalizeAssistantBubble() {
updateAssistantBubble(responseBuffer, false);
currentAssistantBubble = null;
responseBuffer = '';
}
function ensureAssistantBubble() {
if (currentAssistantBubble && currentAssistantBubble.bubble?.isConnected) {
return currentAssistantBubble;
}
const bubble = createMessageBubble('assistant', '');
if (!bubble) {
return null;
}
currentAssistantBubble = { bubble, cursor: null };
conversation.push({ role: 'assistant', content: '' });
return currentAssistantBubble;
}
function createMessageBubble(role, text) {
if (!conversationContainer) {
return null;
}
removePlaceholder();
const row = document.createElement('div');
row.className = `message ${role}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = text;
row.appendChild(bubble);
conversationContainer.appendChild(row);
scrollConversation();
return bubble;
}
function appendCursor(bubble) {
if (!bubble) {
return null;
}
const cursor = document.createElement('span');
cursor.className = 'blinking-cursor';
bubble.appendChild(cursor);
return cursor;
}
function scrollConversation() {
if (conversationContainer) {
conversationContainer.scrollTop = conversationContainer.scrollHeight;
}
}
function removePlaceholder() {
if (!conversationContainer) {
return;
}
const placeholder = conversationContainer.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
}
function ensurePlaceholder() {
if (!conversationContainer) {
return;
}
if (conversation.length > 0 || reconnectButton) {
removePlaceholder();
return;
}
if (!conversationContainer.querySelector('.placeholder')) {
const placeholder = document.createElement('p');
placeholder.className = 'placeholder';
placeholder.textContent = 'Ask a question about the current page...';
conversationContainer.appendChild(placeholder);
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function updateAuthStateFromStorage() {
try {
const stored = await chrome.storage.session.get('librechat.accessToken');
const entry = stored?.['librechat.accessToken'];
isAuthenticated = Boolean(entry && entry.token);
if (isAuthenticated) {
removeReconnectButton();
}
ensurePlaceholder();
return isAuthenticated;
} catch (error) {
console.debug('Failed to read cached token', error);
isAuthenticated = false;
ensurePlaceholder();
return false;
}
}
}