Resolve merge conflicts: preserve local improvements while integrating PR changes

This commit is contained in:
BinaryBeastMaster 2025-07-07 19:58:52 -07:00
parent 8536e06d56
commit 13d6e82732
12 changed files with 2438 additions and 2503 deletions

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ build/
.env.development.local
.env.test.local
.env.production.local
!api-relay-server/relay.settings
# IDE and editor files
.idea/

View file

@ -12,6 +12,7 @@
"api-relay-server": "file:",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^5.1.0",
"ioredis": "^5.6.1",
"ws": "^8.18.2"
@ -430,6 +431,18 @@
"node": ">= 0.8"
}
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View file

@ -19,7 +19,8 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"ioredis": "^5.6.1",
"ws": "^8.18.2"
"ws": "^8.18.2",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/body-parser": "^1.19.5",

View file

@ -1,127 +1,248 @@
<!--
Chat Relay: Relay for AI Chat Interfaces
Copyright (C) 2025 Jamison Moore
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see https://www.gnu.org/licenses/.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Relay Admin</title>
<style>
body {
font-family: Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
margin: 0;
padding: 0;
background-color: #f4f4f4;
color: #333;
background-color: #f0f2f5; /* Lighter gray */
color: #1c1e21; /* Darker text for better contrast */
line-height: 1.6;
}
header {
background-color: #333;
background-color: #1877f2; /* Facebook blue */
color: #fff;
padding: 1em;
padding: 1em 1.5em;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
header h1 {
margin: 0;
font-size: 1.8em;
}
nav {
background-color: #444;
padding: 0.5em;
background-color: #fff; /* White background for nav */
padding: 0.75em 1.5em;
border-bottom: 1px solid #dddfe2; /* Light border */
box-shadow: 0 2px 2px -2px rgba(0,0,0,0.05);
}
nav ul {
list-style-type: none;
padding: 0;
margin: 0;
text-align: center;
}
nav ul li {
display: inline;
margin-right: 20px;
margin-right: 25px;
}
nav ul li a {
color: #fff;
color: #1877f2; /* Blue links */
text-decoration: none;
font-weight: bold;
font-weight: 600; /* Slightly bolder */
padding: 0.5em 0;
transition: color 0.2s ease;
}
nav ul li a:hover {
color: #1159bd; /* Darker blue on hover */
}
nav ul li a.active {
text-decoration: underline;
color: #1c1e21; /* Darker color for active tab */
border-bottom: 3px solid #1877f2;
}
.container {
padding: 1em;
padding: 1.5em;
max-width: 100%;
box-sizing: border-box;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
h2 {
border-bottom: 2px solid #333;
border-bottom: 2px solid #dddfe2;
padding-bottom: 0.5em;
color: #1c1e21;
font-size: 1.5em;
margin-top: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
margin-top: 1.5em;
table-layout: fixed; /* Important for controlling column widths */
background-color: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border-radius: 6px;
overflow: hidden; /* Ensures border-radius is respected by children */
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
th, td {
border: 1px solid #dddfe2;
padding: 12px 10px; /* Increased padding */
text-align: left;
vertical-align: top; /* Align content to the top */
word-wrap: break-word; /* Prevent long words from breaking layout */
}
th {
background-color: #555;
color: white;
background-color: #f5f6f7; /* Lighter header background */
color: #4b4f56; /* Darker gray for header text */
font-weight: 600;
position: relative; /* For positioning collapse buttons */
}
/* Column width styling */
th.col-timestamp, td.col-timestamp { width: 12%; } /* Start/End Timestamp */
th.col-request-id, td.col-request-id { width: 8%; } /* Request ID */
th.col-status, td.col-status { width: 8%; } /* Status */
/* The remaining 4 data columns will share the rest of the space.
(100 - 12 - 12 - 8 - 8) = 60%. So each gets 15% */
th.col-data, td.col-data { width: 15%; }
td pre { /* Style for the <pre> tags inside cells */
margin: 0;
white-space: pre-wrap;
max-height: 200px; /* Keep existing max-height */
overflow-y: auto;
background-color: #f9f9f9; /* Slight background for pre blocks */
padding: 6px;
border-radius: 4px;
font-size: 0.9em;
color: #333;
}
.log-window {
background-color: #222;
color: #0f0;
font-family: 'Courier New', Courier, monospace;
padding: 10px;
height: 200px;
background-color: #1e1e1e; /* Darker background for log */
color: #d4d4d4; /* Lighter text for log */
font-family: 'Consolas', 'Courier New', Courier, monospace;
padding: 15px;
height: 250px; /* Slightly taller */
overflow-y: scroll;
border: 1px solid #444;
margin-top: 1em;
border: 1px solid #333;
margin-top: 1.5em;
border-radius: 4px;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
}
.log-entry {
white-space: pre-wrap;
padding: 2px 0;
}
.collapsible-header {
background-color: #555;
background-color: #6c757d; /* Bootstrap secondary color */
color: white;
padding: 0.5em;
padding: 0.75em;
cursor: pointer;
text-align: center;
border-radius: 4px;
margin-top: 1em;
font-weight: 500;
transition: background-color 0.2s ease;
}
.collapsible-header:hover {
background-color: #5a6268;
}
button {
padding: 8px 15px;
font-size: 0.9em;
border-radius: 4px;
border: 1px solid transparent;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
#refresh-messages-btn {
background-color: #007bff; /* Primary blue */
color: white;
margin-left: 10px;
}
#refresh-messages-btn:hover {
background-color: #0056b3;
}
#save-settings-btn {
background-color: #28a745; /* Green for save */
color: white;
}
#save-settings-btn:hover {
background-color: #1e7e34;
}
#restart-server-btn {
background-color: #dc3545; /* Red for restart/danger */
color: white;
}
#restart-server-btn:hover {
background-color: #c82333;
}
input[type="text"], input[type="number"], select {
padding: 8px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.9em;
}
label {
font-weight: 500;
margin-right: 5px;
}
.settings-item, .status-item {
margin-bottom: 1em;
padding: 10px;
background-color: #fff;
border: 1px solid #dddfe2;
border-radius: 4px;
}
#update-status-msg {
margin-left: 10px;
font-style: italic;
}
.collapse-btn {
font-size: 0.7em;
padding: 2px 5px;
margin-left: 5px;
cursor: pointer;
border: 1px solid #ccc;
background-color: #f0f0f0;
border-radius: 3px;
display: inline-block; /* To sit next to header text */
vertical-align: middle;
}
.collapse-btn:hover {
background-color: #e0e0e0;
}
.collapsed-cell {
/* Could add specific styling for collapsed cells if needed, e.g., a placeholder */
/* For now, they will just be hidden by JavaScript */
}
.header-collapsed {
width: 1px !important; /* Minimal width to keep column in layout but visually gone */
min-width: 1px !important; /* Ensure it doesn't expand due to content */
padding-left: 0 !important;
padding-right: 0 !important;
border-left-width: 0 !important;
border-right-width: 0 !important;
overflow: hidden !important; /* Hide content that might overflow 1px */
color: transparent !important; /* Hide text by making it transparent */
font-size: 0 !important; /* Another way to hide text, affects children */
}
.header-collapsed .collapse-btn { /* Ensure button is also hidden if not already by font-size:0 */
display: none !important;
}
</style>
</head>
<body>
<header>
<h1>Chat Relay Admin Dashboard</h1>
@ -130,24 +251,30 @@
<ul>
<li><a href="#" class="tab-link active" data-tab="messages">Messages</a></li>
<li><a href="#" class="tab-link" data-tab="settings">Settings</a></li>
<li><a href="#" class="tab-link" data-tab="status">Status</a></li>
</ul>
</nav>
<div class="container">
<div id="messages" class="tab-content active">
<h2>Message History <button id="refresh-messages-btn" style="font-size: 0.8em; margin-left: 10px;">Refresh
Messages</button></h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;">
<h2>Message History</h2>
<div style="display: flex; align-items: center;">
<span style="margin-right: 15px;">Server Uptime: <span id="status-uptime">N/A</span></span>
<span style="margin-right: 15px;">Connected Extensions: <span id="status-connected-extensions">0</span></span>
<button id="refresh-messages-btn" style="font-size: 0.8em; padding: 0.5em 1em; margin-right: 10px;">Refresh Messages</button>
<button id="restart-server-btn" style="font-size: 0.8em; padding: 0.5em 1em; background-color: #d9534f; color: white; border: none; cursor: pointer;">Restart Server</button>
</div>
</div>
<table>
<thead>
<tr>
<th>Start Timestamp</th>
<th>End Timestamp</th>
<th>Request ID</th>
<th>From Client (Cline)</th>
<th>To Extension</th>
<th>From Extension</th>
<th>To Client (Cline)</th>
<th>Status</th>
<th class="col-timestamp">Start Timestamp</th>
<th class="col-timestamp">End Timestamp</th>
<th class="col-request-id">Request ID</th>
<th class="col-data">From Client <span class="collapse-btn" data-column="fromClient" title="Toggle Column">-</span></th>
<th class="col-data">To Extension <span class="collapse-btn" data-column="toExtension" title="Toggle Column">-</span></th>
<th class="col-data">From Extension <span class="collapse-btn" data-column="fromExtension" title="Toggle Column">-</span></th>
<th class="col-data">To Client <span class="collapse-btn" data-column="toClient" title="Toggle Column">-</span></th>
<th class="col-status">Status</th>
</tr>
</thead>
<tbody id="message-history-body">
@ -170,8 +297,7 @@
<div style="margin-top: 0.5em;">
<label>New Request Behavior (if extension busy):</label>
<div>
<input type="radio" id="newRequestBehaviorQueue" name="newRequestBehavior" value="queue"
checked>
<input type="radio" id="newRequestBehaviorQueue" name="newRequestBehavior" value="queue" checked>
<label for="newRequestBehaviorQueue">Queue</label>
</div>
<div>
@ -179,25 +305,12 @@
<label for="newRequestBehaviorDrop">Drop</label>
</div>
</div>
<div style="margin-top: 0.5em;">
<label for="auto-kill-port-input">Auto-kill conflicting port 3003 process on startup: </label>
<input type="checkbox" id="auto-kill-port-input">
</div>
<button id="save-settings-btn" style="margin-top: 1em; margin-bottom: 0.5em;">Save Settings</button>
<span id="update-status-msg" style="margin-left: 10px; font-style: italic;"></span>
<p style="margin-top: 1em;">Ping Interval (ms): <span id="setting-ping-interval"></span></p>
</div>
</div>
<div id="status" class="tab-content">
<h2>Server Status</h2>
<div id="status-content">
<p>Server Uptime: <span id="status-uptime">N/A</span></p>
<p>Connected Extensions: <span id="status-connected-extensions">0</span></p>
<button id="restart-server-btn"
style="margin-top: 1em; padding: 0.5em 1em; background-color: #d9534f; color: white; border: none; cursor: pointer;">Restart
Server</button>
</div>
</div>
<!-- Status tab content removed as its elements are moved to the Messages tab -->
</div>
<div class="collapsible-header" onclick="toggleLogWindow()">Server Logs (click to toggle)</div>
<div class="log-window" id="log-window-content" style="display: none;">
@ -215,8 +328,13 @@
link.classList.add('active');
const activeTabContent = document.getElementById(link.dataset.tab);
activeTabContent.classList.add('active');
// If settings or status tab is activated, refresh their content
if (link.dataset.tab === 'settings' || link.dataset.tab === 'status') {
// If settings tab is activated, refresh its content
if (link.dataset.tab === 'settings') {
fetchAndDisplayServerInfo(); // This function now also updates status on the messages tab
}
// Always ensure server info (which includes status) is fresh when messages tab is active
if (link.dataset.tab === 'messages') {
fetchAndDisplayServerInfo();
}
});
@ -231,21 +349,23 @@
}
const messageHistoryBody = document.getElementById('message-history-body');
const refreshButton = document.getElementById('refresh-messages-btn');
function createPreCell(data) {
const cell = document.createElement('td');
if (data === undefined || data === null) {
cell.textContent = 'N/A';
} else {
const pre = document.createElement('pre');
pre.style.margin = '0';
pre.style.whiteSpace = 'pre-wrap';
pre.style.maxHeight = '200px'; // Added max height
pre.style.overflowY = 'auto'; // Added scrollability
// pre.style.margin = '0'; // Handled by td pre CSS
// pre.style.whiteSpace = 'pre-wrap'; // Handled by td pre CSS
// pre.style.maxHeight = '200px'; // Handled by td pre CSS
// pre.style.overflowY = 'auto'; // Handled by td pre CSS
pre.textContent = JSON.stringify(data, null, 2);
cell.appendChild(pre);
}
return cell;
}
async function fetchAndDisplayMessageHistory() {
try {
const response = await fetch('/v1/admin/message-history');
@ -253,7 +373,9 @@
throw new Error(`HTTP error! status: ${response.status}`);
}
const messages = await response.json();
messageHistoryBody.innerHTML = ''; // Clear existing rows
if (messages.length === 0) {
const row = messageHistoryBody.insertRow();
const cell = row.insertCell();
@ -262,6 +384,7 @@
cell.style.textAlign = 'center';
return;
}
// Group messages by requestId
const groupedMessages = messages.reduce((acc, logEntry) => {
const id = logEntry.requestId;
@ -277,6 +400,7 @@
status: "Unknown"
};
}
// Update fields based on log type
switch (logEntry.type) {
case 'CHAT_REQUEST_RECEIVED':
@ -304,29 +428,35 @@
}
return acc;
}, {});
// Convert grouped messages object to an array and sort by timestamp (most recent first)
const consolidatedMessages = Object.values(groupedMessages).sort((a, b) => {
// Sort by startTimestamp, most recent first
return new Date(b.startTimestamp) - new Date(a.startTimestamp);
});
if (consolidatedMessages.length === 0) {
const row = messageHistoryBody.insertRow();
const row = messageHistoryBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 8; // Adjusted for new column
cell.textContent = 'No consolidated message history to display.';
cell.style.textAlign = 'center';
return;
}
consolidatedMessages.forEach(msg => {
const row = messageHistoryBody.insertRow();
row.insertCell().textContent = new Date(msg.startTimestamp).toLocaleString();
row.insertCell().textContent = msg.endTimestamp ? new Date(msg.endTimestamp).toLocaleString() : (msg.status === "Request In Progress" ? "In Progress" : "N/A");
row.insertCell().textContent = msg.requestId;
row.appendChild(createPreCell(msg.fromClient));
row.appendChild(createPreCell(msg.toExtension));
row.appendChild(createPreCell(msg.fromExtension));
row.appendChild(createPreCell(msg.toClient));
row.insertCell().textContent = msg.status;
let cell;
cell = row.insertCell(); cell.textContent = new Date(msg.startTimestamp).toLocaleString(); cell.classList.add('col-timestamp');
cell = row.insertCell(); cell.textContent = msg.endTimestamp ? new Date(msg.endTimestamp).toLocaleString() : (msg.status === "Request In Progress" ? "In Progress" : "N/A"); cell.classList.add('col-timestamp');
cell = row.insertCell(); cell.textContent = msg.requestId; cell.classList.add('col-request-id');
cell = createPreCell(msg.fromClient); cell.classList.add('col-data', 'cell-fromClient'); row.appendChild(cell);
cell = createPreCell(msg.toExtension); cell.classList.add('col-data', 'cell-toExtension'); row.appendChild(cell);
cell = createPreCell(msg.fromExtension); cell.classList.add('col-data', 'cell-fromExtension'); row.appendChild(cell);
cell = createPreCell(msg.toClient); cell.classList.add('col-data', 'cell-toClient'); row.appendChild(cell);
cell = row.insertCell(); cell.textContent = msg.status; cell.classList.add('col-status');
});
} catch (error) {
console.error('Error fetching message history:', error);
@ -339,16 +469,19 @@
cell.style.textAlign = 'center';
}
}
if (refreshButton) {
refreshButton.addEventListener('click', fetchAndDisplayMessageHistory);
}
fetchAndDisplayMessageHistory(); // Initial load for messages
fetchAndDisplayServerInfo(); // Initial load for server info (status) on the messages tab
// Elements for settings and status
const portInputEl = document.getElementById('port-input'); // Corrected ID
const requestTimeoutInputEl = document.getElementById('request-timeout-input');
const newRequestBehaviorQueueEl = document.getElementById('newRequestBehaviorQueue');
const newRequestBehaviorDropEl = document.getElementById('newRequestBehaviorDrop');
const autoKillPortInputEl = document.getElementById('auto-kill-port-input');
const saveSettingsBtn = document.getElementById('save-settings-btn');
const updateStatusMsgEl = document.getElementById('update-status-msg');
const settingPingIntervalEl = document.getElementById('setting-ping-interval');
@ -356,6 +489,7 @@
const statusUptimeEl = document.getElementById('status-uptime');
const statusConnectedExtensionsEl = document.getElementById('status-connected-extensions');
const restartServerBtn = document.getElementById('restart-server-btn');
function formatUptime(totalSeconds) {
if (totalSeconds === null || totalSeconds === undefined) return 'N/A';
const days = Math.floor(totalSeconds / (3600 * 24));
@ -364,6 +498,7 @@
totalSeconds %= 3600;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
let uptimeString = '';
if (days > 0) uptimeString += `${days}d `;
if (hours > 0) uptimeString += `${hours}h `;
@ -371,83 +506,123 @@
uptimeString += `${seconds}s`;
return uptimeString.trim() || '0s';
}
async function fetchAndDisplayServerInfo() {
try {
const response = await fetch('/v1/admin/server-info');
// UPDATED: Fetch from the new consolidated settings endpoint
const response = await fetch('/admin/settings');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const serverInfo = await response.json();
// Populate Settings
if (portInputEl) portInputEl.value = serverInfo.port || ''; // Use portInputEl
if (requestTimeoutInputEl) requestTimeoutInputEl.value = serverInfo.requestTimeoutMs !== null ? serverInfo.requestTimeoutMs : '';
if (settingPingIntervalEl) settingPingIntervalEl.textContent = serverInfo.pingIntervalMs !== null ? `${serverInfo.pingIntervalMs} ms` : 'N/A (Not Implemented)';
if (serverInfo.newRequestBehavior === 'drop') {
if (newRequestBehaviorDropEl) newRequestBehaviorDropEl.checked = true;
const currentSettings = await response.json();
// Populate Settings using the new response structure
if(portInputEl) portInputEl.value = currentSettings.serverPort || '';
if(requestTimeoutInputEl) requestTimeoutInputEl.value = currentSettings.requestTimeout !== null ? currentSettings.requestTimeout : '';
// Ping interval is not part of /admin/settings, assuming it's static or handled elsewhere if needed
// if(settingPingIntervalEl) settingPingIntervalEl.textContent = currentSettings.pingIntervalMs !== null ? `${currentSettings.pingIntervalMs} ms` : 'N/A (Not Implemented)';
if (currentSettings.messageSendStrategy === 'drop') {
if(newRequestBehaviorDropEl) newRequestBehaviorDropEl.checked = true;
} else {
if (newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default to queue
if(newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default to queue
}
if (autoKillPortInputEl) autoKillPortInputEl.checked = serverInfo.autoKillPort || false;
// Populate Status
if (statusUptimeEl) statusUptimeEl.textContent = formatUptime(serverInfo.uptimeSeconds);
if (statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = serverInfo.connectedExtensionsCount !== null ? serverInfo.connectedExtensionsCount : 'N/A';
} catch (error) {
console.error('Error fetching server info:', error);
if (portInputEl) portInputEl.value = 'Error';
if (requestTimeoutInputEl) requestTimeoutInputEl.value = 'Error';
if (settingPingIntervalEl) settingPingIntervalEl.textContent = 'Error';
if (newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default on error
if (autoKillPortInputEl) autoKillPortInputEl.checked = false; // Default on error
// Ensure error handling for status elements
if (statusUptimeEl) statusUptimeEl.textContent = 'Error loading uptime';
if (statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = 'Error loading connections';
// Populate Status - Assuming server-info might still be used for uptime/connections or these are separate
// For now, focusing on settings. If status also needs to come from /admin/settings, that's another change.
// Let's assume uptime and connectedExtensionsCount are fetched separately or are less critical for this immediate fix.
// For simplicity, I'll comment out the status population from this specific fetch if it was tied to the old server-info structure.
// If your /admin/settings also returns uptime and connectedExtensionsCount, uncomment and adjust field names.
/*
if(statusUptimeEl) statusUptimeEl.textContent = formatUptime(currentSettings.uptimeSeconds);
if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = currentSettings.connectedExtensionsCount !== null ? currentSettings.connectedExtensionsCount : 'N/A';
*/
// If you still have a /v1/admin/server-info for uptime and connections, that part can remain as is.
// This function will now primarily handle settings population.
// To keep status functional if it's from a different endpoint, we might need to separate concerns
// or ensure /admin/settings returns everything. For now, focusing on fixing settings load.
// Fetch separate status info if needed (example if /v1/admin/server-info is still used for status)
const statusResponse = await fetch('/v1/admin/server-info');
if (statusResponse.ok) {
const serverInfoForStatus = await statusResponse.json();
if(statusUptimeEl) statusUptimeEl.textContent = formatUptime(serverInfoForStatus.uptimeSeconds);
if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = serverInfoForStatus.connectedExtensionsCount !== null ? serverInfoForStatus.connectedExtensionsCount : 'N/A';
if(settingPingIntervalEl && serverInfoForStatus.pingIntervalMs !== undefined) settingPingIntervalEl.textContent = serverInfoForStatus.pingIntervalMs !== null ? `${serverInfoForStatus.pingIntervalMs} ms` : 'N/A (Not Implemented)';
} else {
// Handle case where statusResponse itself is not ok
console.warn(`Failed to fetch status from /v1/admin/server-info: ${statusResponse.status}`);
if(statusUptimeEl) statusUptimeEl.textContent = 'Status N/A';
if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = 'Status N/A';
if(settingPingIntervalEl) settingPingIntervalEl.textContent = 'Status N/A';
}
} catch (error) { // This is the single catch block for the try starting at line 310
console.error('Error in fetchAndDisplayServerInfo:', error);
// Set all relevant fields to an error state
if(portInputEl) portInputEl.value = 'Error';
if(requestTimeoutInputEl) requestTimeoutInputEl.value = 'Error';
if(settingPingIntervalEl) settingPingIntervalEl.textContent = 'Error';
if(newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default on error
if(statusUptimeEl) statusUptimeEl.textContent = 'Error loading';
if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = 'Error loading';
}
}
async function handleSaveSettings() {
if (!requestTimeoutInputEl || !portInputEl || !updateStatusMsgEl || !newRequestBehaviorQueueEl || !newRequestBehaviorDropEl || !autoKillPortInputEl) return;
if (!requestTimeoutInputEl || !portInputEl || !updateStatusMsgEl || !newRequestBehaviorQueueEl || !newRequestBehaviorDropEl) return;
const newTimeout = parseInt(requestTimeoutInputEl.value, 10);
const newPort = parseInt(portInputEl.value, 10);
const selectedNewRequestBehavior = newRequestBehaviorQueueEl.checked ? 'queue' : 'drop';
const autoKillPort = autoKillPortInputEl.checked;
let settingsToUpdate = {};
let validationError = false;
let messages = [];
if (requestTimeoutInputEl.value.trim() !== '') { // Only process if there's input
if (!isNaN(newTimeout) && newTimeout > 0) {
settingsToUpdate.requestTimeoutMs = newTimeout;
// UPDATED: Key name for server
settingsToUpdate.requestTimeout = newTimeout;
} else {
messages.push('Invalid timeout: Must be a positive number.');
validationError = true;
}
}
if (portInputEl.value.trim() !== '') { // Only process if there's input
if (!isNaN(newPort) && newPort > 0 && newPort <= 65535) {
settingsToUpdate.port = newPort;
if (!isNaN(newPort) && newPort > 0 && newPort <= 65535) {
// UPDATED: Key name for server
settingsToUpdate.serverPort = newPort;
} else {
messages.push('Invalid port: Must be between 1 and 65535.');
validationError = true;
}
}
// Always include newRequestBehavior as it's controlled by radio buttons
// No specific validation needed here as it's either 'queue' or 'drop'
settingsToUpdate.newRequestBehavior = selectedNewRequestBehavior;
settingsToUpdate.autoKillPort = autoKillPort; // Add the new setting
// UPDATED: Key name for server
settingsToUpdate.messageSendStrategy = selectedNewRequestBehavior;
if (validationError) {
updateStatusMsgEl.textContent = messages.join(' ');
updateStatusMsgEl.style.color = 'red';
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, 7000);
return;
}
if (Object.keys(settingsToUpdate).length === 0) {
updateStatusMsgEl.textContent = 'No changes to save.';
updateStatusMsgEl.style.color = 'blue';
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, 5000);
return;
}
updateStatusMsgEl.textContent = 'Saving settings...';
updateStatusMsgEl.style.color = 'orange';
try {
const response = await fetch('/v1/admin/update-settings', {
// UPDATED: Endpoint for saving settings
const response = await fetch('/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settingsToUpdate)
@ -471,9 +646,11 @@
const clearTime = updateStatusMsgEl.textContent.toLowerCase().includes('restart') ? 15000 : 7000;
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, clearTime);
}
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', handleSaveSettings);
}
async function handleRestartServer() {
if (confirm('Are you sure you want to restart the server?')) {
try {
@ -486,13 +663,61 @@
}
}
}
if (restartServerBtn) {
restartServerBtn.addEventListener('click', handleRestartServer);
}
// Initial load for settings and status
fetchAndDisplayServerInfo();
console.log("Admin UI initialized. Message history, settings, status, and restart functionality implemented.");
// Initial load for settings is handled by tab click or initial messages tab load.
// fetchAndDisplayServerInfo(); // This is called when messages tab is active or settings tab is clicked.
// Collapsible column logic
const columnStates = { // To store the collapsed state
fromClient: false,
toExtension: false,
fromExtension: false,
toClient: false
};
function toggleColumn(columnKey) {
columnStates[columnKey] = !columnStates[columnKey];
const isCollapsed = columnStates[columnKey];
// Update button text/indicator
const button = document.querySelector(`.collapse-btn[data-column="${columnKey}"]`);
if (button) {
button.textContent = isCollapsed ? '+' : '-';
button.title = isCollapsed ? 'Expand Column' : 'Collapse Column';
}
// Toggle header visibility (optional, if you want to hide header text too)
// const headerCell = document.querySelector(`th.col-data[data-column-key="${columnKey}"]`); // Need to add data-column-key to th if this is desired
// Toggle data cell visibility (these are the <td> elements)
const cellsToToggle = document.querySelectorAll(`#message-history-body td.cell-${columnKey}`);
cellsToToggle.forEach(cell => {
cell.style.display = isCollapsed ? 'none' : '';
});
// Special handling for the header cell (<th>) due to table-layout: fixed
const headerTh = document.querySelector(`th[data-column="${columnKey}"]`);
if (headerTh) {
if (isCollapsed) {
headerTh.classList.add('header-collapsed');
} else {
headerTh.classList.remove('header-collapsed');
}
}
}
document.querySelectorAll('.collapse-btn').forEach(button => {
button.addEventListener('click', function() {
const columnKey = this.dataset.column;
toggleColumn(columnKey);
});
});
console.log("Admin UI initialized. Message history, settings, status, restart, and collapsible columns functionality implemented.");
</script>
</body>
</html>

View file

@ -1,19 +0,0 @@
/*
* Chat Relay: Relay for AI Chat Interfaces
* Copyright (C) 2025 Jamison Moore
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
// Import the server
require('./server');

View file

@ -1,473 +0,0 @@
/*
* Chat Relay: Relay for AI Chat Interfaces
* Copyright (C) 2025 Jamison Moore
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const { WebSocketServer } = require('ws');
const http = require('http');
// Create Express app
const app = express();
app.use(cors());
app.use(bodyParser.json({ limit: '50mb' }));
// Health check endpoint
app.get('/health', (req, res) => {
const aliveConnections = activeConnections.filter(conn => conn.isAlive);
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
activeBrowserConnections: aliveConnections.length,
totalTrackedBrowserConnections: activeConnections.length,
webSocketServerState: wss.options.server.listening ? 'listening' : 'not_listening' // wss.readyState is not standard for server
});
});
// Create HTTP server
const server = http.createServer(app);
// Create WebSocket server for browser extension communication
const wss = new WebSocketServer({ server });
// Global variables
let activeConnections = [];
const pendingRequests = new Map();
let requestCounter = 0;
// Connection health check interval (in milliseconds)
const PING_INTERVAL = 30000; // 30 seconds
const CONNECTION_TIMEOUT = 45000; // 45 seconds
// Handle WebSocket connections from browser extensions
wss.on('connection', (ws, req) => { // Added req to log client IP
const clientIp = req.socket.remoteAddress;
console.log(`SERVER: Browser extension connected from IP: ${clientIp}`);
// Initialize connection state
ws.isAlive = true;
ws.pendingPing = false;
ws.lastActivity = Date.now();
// Add to active connections
activeConnections.push(ws);
// Set up ping interval for this connection
const pingInterval = setInterval(() => {
// Check if connection is still alive
if (!ws.isAlive) {
console.log('Browser extension connection timed out, terminating');
clearInterval(pingInterval);
ws.terminate();
return;
}
// If we're still waiting for a pong from the last ping, mark as not alive
if (ws.pendingPing) {
console.log('Browser extension not responding to ping, marking as inactive');
ws.isAlive = false;
return;
}
// Check if there's been activity recently
const inactiveTime = Date.now() - ws.lastActivity;
if (inactiveTime > CONNECTION_TIMEOUT) {
console.log(`Browser extension inactive for ${inactiveTime}ms, sending ping`);
// Send a ping to check if still alive
ws.pendingPing = true;
try {
ws.ping();
} catch (error) {
console.error('Error sending ping:', error);
ws.isAlive = false;
}
}
}, PING_INTERVAL);
// Handle pong messages (response to ping)
ws.on('pong', () => {
ws.isAlive = true;
ws.pendingPing = false;
ws.lastActivity = Date.now();
console.log('Browser extension responded to ping');
});
// Handle messages from browser extension
ws.on('message', (messageBuffer) => {
const rawMessage = messageBuffer.toString();
console.log(`SERVER: Received raw message from extension (IP: ${clientIp}): ${rawMessage.substring(0, 500)}${rawMessage.length > 500 ? '...' : ''}`);
try {
// Update last activity timestamp
ws.lastActivity = Date.now();
const data = JSON.parse(rawMessage);
console.log(`SERVER: Parsed message data from extension (IP: ${clientIp}):`, data);
const { requestId, type } = data;
if (requestId === undefined) {
console.warn(`SERVER: Received message without requestId from IP ${clientIp}:`, data);
// Handle other non-request-specific messages if any (e.g., status pings initiated by extension)
if (type === 'EXTENSION_STATUS') {
console.log(`SERVER: Browser extension status from IP ${clientIp}: ${data.status}`);
}
return;
}
// Log based on new message types from background.js
if (type === 'CHAT_RESPONSE_CHUNK') {
const chunkContent = data.chunk ? data.chunk.substring(0, 200) + (data.chunk.length > 200 ? '...' : '') : '[empty chunk]';
console.log(`SERVER: Received CHAT_RESPONSE_CHUNK for requestId: ${requestId} from IP ${clientIp}. Chunk (first 200): ${chunkContent}. IsFinal: ${data.isFinal}`);
const pendingRequest = pendingRequests.get(requestId);
if (pendingRequest) {
console.log(`SERVER: Processing CHAT_RESPONSE_CHUNK for pending request ${requestId} from IP ${clientIp}. IsFinal: ${data.isFinal}, Chunk (first 200): ${chunkContent}`);
// Initialize accumulatedChunks if it doesn't exist (should be set on creation)
if (typeof pendingRequest.accumulatedChunks === 'undefined') {
pendingRequest.accumulatedChunks = '';
}
if (data.chunk) { // Ensure chunk is not null or undefined
pendingRequest.accumulatedChunks += data.chunk;
}
if (data.isFinal) {
console.log(`SERVER: Request ${requestId} (IP: ${clientIp}) received final CHAT_RESPONSE_CHUNK. Attempting to resolve promise.`);
if (pendingRequest.timeoutId) {
clearTimeout(pendingRequest.timeoutId);
console.log(`SERVER: Request ${requestId} (IP: ${clientIp}) timeout cleared.`);
}
pendingRequest.resolve(pendingRequest.accumulatedChunks);
pendingRequests.delete(requestId);
console.log(`SERVER: Request ${requestId} (IP: ${clientIp}) promise resolved and removed from pending. Total length: ${pendingRequest.accumulatedChunks.length}`);
} else {
console.log(`SERVER: Accumulated chunk for requestId ${requestId} (IP: ${clientIp}). Current total length: ${pendingRequest.accumulatedChunks.length}`);
}
} else {
console.log(`SERVER: Received CHAT_RESPONSE_CHUNK for request ${requestId} (IP: ${clientIp}, isFinal: ${data.isFinal}), but no pending request found.`);
}
} else if (type === 'CHAT_RESPONSE_STREAM_ENDED') {
const pendingRequestStream = pendingRequests.get(requestId);
if (pendingRequestStream) {
console.log(`SERVER: Processing CHAT_RESPONSE_STREAM_ENDED for pending request ${requestId} (IP: ${clientIp}).`);
// This message type now primarily signals the end. The actual data comes in CHAT_RESPONSE_CHUNK.
// If a request is still pending and we haven't resolved it with a final chunk,
// it might indicate an issue or a stream that ended without complete data.
if (!pendingRequestStream.resolved) {
console.warn(`SERVER: Stream ended for requestId ${requestId} (IP: ${clientIp}), but request was not fully resolved with data. This might be an issue.`);
}
} else {
console.log(`SERVER: Received CHAT_RESPONSE_STREAM_ENDED for request ${requestId} (IP: ${clientIp}), but no pending request found.`);
}
} else if (type === 'CHAT_RESPONSE_ERROR') {
const errorMsg = data.error || "Unknown error from extension.";
console.error(`SERVER: Received CHAT_RESPONSE_ERROR for requestId: ${requestId} (IP: ${clientIp}). Error: ${errorMsg}`);
const pendingRequestError = pendingRequests.get(requestId);
if (pendingRequestError) {
console.log(`SERVER: Processing CHAT_RESPONSE_ERROR for pending request ${requestId} (IP: ${clientIp}).`);
if (pendingRequestError.timeoutId) {
clearTimeout(pendingRequestError.timeoutId);
console.log(`SERVER: Request ${requestId} (IP: ${clientIp}) timeout cleared due to error.`);
}
pendingRequestError.reject(new Error(`Extension reported error for request ${requestId}: ${errorMsg}`));
pendingRequests.delete(requestId);
console.log(`SERVER: Request ${requestId} (IP: ${clientIp}) rejected due to CHAT_RESPONSE_ERROR and removed from pending.`);
} else {
console.log(`SERVER: Received CHAT_RESPONSE_ERROR for request ${requestId} (IP: ${clientIp}), but no pending request found.`);
}
} else if (type === 'CHAT_RESPONSE') { // Keep old CHAT_RESPONSE for compatibility if content script DOM fallback sends it
const { response } = data;
console.log(`SERVER: Received (legacy) CHAT_RESPONSE for requestId: ${requestId} from IP ${clientIp}. Response (first 100): ${response ? response.substring(0,100) : '[empty]'}`);
const pendingRequest = pendingRequests.get(requestId);
if (pendingRequest) {
if (pendingRequest.timeoutId) clearTimeout(pendingRequest.timeoutId);
pendingRequest.resolve(response);
pendingRequests.delete(requestId);
console.log(`SERVER: Request ${requestId} resolved with (legacy) CHAT_RESPONSE from IP ${clientIp}.`);
} else {
console.log(`SERVER: Received (legacy) CHAT_RESPONSE for request ${requestId} from IP ${clientIp}, but no pending request found.`);
}
} else if (type === 'EXTENSION_ERROR') { // General extension error not tied to a request
console.error(`SERVER: Browser extension (IP: ${clientIp}) reported general error: ${data.error}`);
} else if (type === 'EXTENSION_STATUS') {
console.log(`SERVER: Browser extension (IP: ${clientIp}) status: ${data.status}`);
} else {
console.warn(`SERVER: Received unknown message type '${type}' from IP ${clientIp} for requestId ${requestId}:`, data);
}
} catch (error) {
console.error(`SERVER: Error processing WebSocket message from IP ${clientIp}:`, error, `Raw message: ${rawMessage}`);
}
});
// Handle disconnection
ws.on('close', (code, reason) => {
const reasonString = reason ? reason.toString() : 'No reason given';
console.log(`SERVER: Browser extension (IP: ${clientIp}) disconnected. Code: ${code}, Reason: ${reasonString}`);
clearInterval(pingInterval);
activeConnections = activeConnections.filter(conn => conn !== ws);
// Check if there are any pending requests that were using this connection
// and reject them with a connection closed error
pendingRequests.forEach((request, requestId) => {
if (request.connection === ws) {
console.log(`Rejecting request ${requestId} due to connection close`);
request.reject(new Error('Browser extension disconnected'));
pendingRequests.delete(requestId);
}
});
});
// Handle errors
ws.on('error', (error) => {
console.error(`SERVER: WebSocket error for connection from IP ${clientIp}:`, error);
ws.isAlive = false; // Mark as not alive on error
// Consider terminating and cleaning up like in 'close' if error is fatal
});
});
// Create API router
const apiRouter = express.Router();
// Configuration
const REQUEST_TIMEOUT = 300000; // 5 minutes (in milliseconds)
const MAX_RETRIES = 2; // Maximum number of retries for a failed request
// Helper function to find the best active connection
function getBestConnection() {
// Filter out connections that are not alive
const aliveConnections = activeConnections.filter(conn => conn.isAlive);
if (aliveConnections.length === 0) {
return null;
}
// Sort connections by last activity (most recent first)
aliveConnections.sort((a, b) => b.lastActivity - a.lastActivity);
return aliveConnections[0];
}
// OpenAI-compatible chat completions endpoint
apiRouter.post('/chat/completions', async (req, res) => {
try {
const { messages, model, temperature, max_tokens } = req.body;
console.log(`SERVER: Full incoming HTTP request body for request ID (to be generated):`, JSON.stringify(req.body, null, 2));
// Generate a unique request ID
const requestId = requestCounter++;
// Extract the user's message (last message in the array)
const userMessage = messages[messages.length - 1].content;
// Get the best active connection
const extension = getBestConnection();
// Check if we have any active connections
if (!extension) {
return res.status(503).json({
error: {
message: "No active browser extension connected. Please open the chat interface and ensure the extension is active.",
type: "server_error",
code: "no_extension_connected"
}
});
}
// Create a promise that will be resolved when the response is received
console.log(`SERVER: Request ${requestId} creating response promise.`);
const responsePromise = new Promise((resolve, reject) => {
const internalResolve = (value) => {
console.log(`SERVER: Request ${requestId} internal promise resolve function called.`);
resolve(value);
};
const internalReject = (reason) => {
console.log(`SERVER: Request ${requestId} internal promise reject function called.`);
reject(reason);
};
// Set a timeout to reject the promise after the configured timeout
const timeoutId = setTimeout(() => {
if (pendingRequests.has(requestId)) {
console.error(`SERVER: Request ${requestId} timed out after ${REQUEST_TIMEOUT}ms. Rejecting promise.`);
pendingRequests.delete(requestId); // Ensure cleanup
internalReject(new Error('Request timed out'));
} else {
console.warn(`SERVER: Request ${requestId} timeout triggered, but request no longer in pendingRequests. It might have resolved or errored just before timeout.`);
}
}, REQUEST_TIMEOUT);
// Store the promise resolvers and the connection being used
pendingRequests.set(requestId, {
resolve: internalResolve,
reject: internalReject,
connection: extension,
timeoutId,
retryCount: 0,
accumulatedChunks: '' // Initialize for chunk accumulation
});
console.log(`SERVER: Request ${requestId} added to pendingRequests. Timeout ID: ${timeoutId}`);
});
// Prepare the message
const message = {
type: 'SEND_CHAT_MESSAGE',
requestId,
message: userMessage,
settings: {
model,
temperature,
max_tokens
}
};
// Send the message to the browser extension
try {
console.log(`SERVER: Request ${requestId} - Sending full message to extension:`, JSON.stringify(message, null, 2));
extension.send(JSON.stringify(message));
console.log(`SERVER: Request ${requestId} (message type: ${message.type}) sent to browser extension (IP: ${extension.remoteAddress || 'unknown'}). Waiting for response...`);
// Update last activity timestamp
extension.lastActivity = Date.now();
} catch (error) {
console.error(`Error sending message to extension for request ${requestId}:`, error);
// Clean up the pending request
if (pendingRequests.has(requestId)) {
const pendingRequest = pendingRequests.get(requestId);
if (pendingRequest.timeoutId) {
clearTimeout(pendingRequest.timeoutId);
}
pendingRequests.delete(requestId);
}
return res.status(500).json({
error: {
message: "Failed to send message to browser extension",
type: "server_error",
code: "extension_communication_error"
}
});
}
// Wait for the response
const awaitStartTime = Date.now();
console.log(`SERVER: Request ${requestId} is now awaiting responsePromise (extension response). Timeout set to ${REQUEST_TIMEOUT}ms.`);
const response = await responsePromise;
const awaitEndTime = Date.now();
console.log(`SERVER: Request ${requestId} await responsePromise completed in ${awaitEndTime - awaitStartTime}ms. Received response from extension. Preparing to send to client.`);
// Format the response in OpenAI format
const formatStartTime = Date.now();
const formattedResponse = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: model || "relay-model", // model is from req.body
choices: [
{
index: 0,
message: {
role: "assistant",
content: response // response is the string from the extension
},
finish_reason: "stop"
}
],
usage: {
prompt_tokens: -1, // We don't track tokens
completion_tokens: -1,
total_tokens: -1
}
// Removed service_tier, logprobs, refusal, annotations, and detailed usage to match simpler working version
};
console.log(`SERVER: Request ${requestId} - Full outgoing HTTP response body:`, JSON.stringify(formattedResponse, null, 2));
res.json(formattedResponse);
const sendEndTime = Date.now();
console.log(`SERVER: Request ${requestId} formatted and sent response to client in ${sendEndTime - formatStartTime}ms (total after await: ${sendEndTime - awaitEndTime}ms).`);
} catch (error) {
const reqIdForLog = typeof requestId !== 'undefined' ? requestId : (error && typeof error.requestId !== 'undefined' ? error.requestId : 'UNKNOWN');
console.error(`SERVER: Error processing chat completion for request ${reqIdForLog}:`, error);
if (typeof requestId === 'undefined') {
console.error(`SERVER: CRITICAL - 'requestId' was undefined in catch block. Error object requestId: ${error && error.requestId}`);
}
// Determine the appropriate status code based on the error
let statusCode = 500;
let errorType = "server_error";
let errorCode = "internal_error";
if (error.message === 'Request timed out') {
statusCode = 504; // Gateway Timeout
errorType = "timeout_error";
errorCode = "request_timeout";
} else if (error.message === 'Browser extension disconnected') {
statusCode = 503; // Service Unavailable
errorType = "server_error";
errorCode = "extension_disconnected";
}
const errorResponsePayload = {
error: {
message: error.message,
type: errorType,
code: errorCode
}
};
console.log(`SERVER: Request ${reqIdForLog} - Sending error response to client:`, JSON.stringify(errorResponsePayload, null, 2));
res.status(statusCode).json(errorResponsePayload);
}
});
// Models endpoint
apiRouter.get('/models', (req, res) => {
res.json({
object: "list",
data: [
{
id: "gemini-pro",
object: "model",
created: 1677610602,
owned_by: "relay"
},
{
id: "chatgpt",
object: "model",
created: 1677610602,
owned_by: "relay"
},
{
id: "claude-3",
object: "model",
created: 1677610602,
owned_by: "relay"
}
]
});
});
// Mount the API router
app.use('/v1', apiRouter);
// Start the server
const PORT = process.env.PORT || 3003;
server.listen(PORT, () => {
console.log(`OpenAI-compatible relay server running on port ${PORT}`);
console.log(`WebSocket server for browser extensions running on ws://localhost:${PORT}`);
});
module.exports = server;

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,8 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"allowJs": true // Add this line
},
"include": [
"src/**/*"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -294,20 +294,30 @@ class ClaudeProvider {
}
}
handleDebuggerData(requestId, rawData, isFinalFromBackground) {
handleDebuggerData(requestId, rawData, isFinalFromBackground, errorFromBackground = null) {
// !!!!! VERY IMPORTANT ENTRY LOG !!!!!
console.log('[[ClaudeProvider]] handleDebuggerData ENTERED. RequestId: ' + requestId + ', isFinalFromBackground: ' + isFinalFromBackground + ', RawData Length: ' + (rawData ? rawData.length : 'null'));
console.log('[[ClaudeProvider]] handleDebuggerData ENTERED. RequestId: ' + requestId + ', isFinalFromBackground: ' + isFinalFromBackground + ', RawData Length: ' + (rawData ? rawData.length : 'null') + ', ErrorFromBG: ' + errorFromBackground);
const callback = this.pendingResponseCallbacks.get(requestId);
if (!callback) {
console.warn('[' + this.name + '] No pending callback for requestId: ' + requestId + '. Ignoring.');
console.warn('[' + this.name + '] No pending callback for requestId: ' + requestId + '. Ignoring debugger data/error.');
if (this.requestBuffers.has(requestId)) {
this.requestBuffers.delete(requestId);
}
return;
}
if (errorFromBackground) {
console.warn(`[${this.name}] handleDebuggerData: Propagating error for requestId ${requestId}: ${errorFromBackground}`);
callback(requestId, `[Provider Error from Background] ${errorFromBackground}`, true); // Pass error as text, mark as final
this.pendingResponseCallbacks.delete(requestId);
if (this.requestBuffers.has(requestId)) {
this.requestBuffers.delete(requestId); // Clean up buffer too
}
return; // Stop further processing
}
if (!this.requestBuffers.has(requestId)) {
this.requestBuffers.set(requestId, { accumulatedText: "" });
}
@ -510,7 +520,7 @@ class ClaudeProvider {
console.log(`[${this.name}] getStreamingApiPatterns called. Capture method: ${this.captureMethod}`);
if (this.captureMethod === "debugger" && this.debuggerUrlPattern) {
console.log(`[${this.name}] Using debugger URL pattern: ${this.debuggerUrlPattern}`);
return [{ urlPattern: this.debuggerUrlPattern, requestStage: "Response" }];
return [{ urlPattern: this.debuggerUrlPattern }];
}
console.log(`[${this.name}] No debugger patterns to return (captureMethod is not 'debugger' or no pattern set).`);
return [];

View file

@ -22,58 +22,69 @@ class GeminiProvider {
this.name = 'GeminiProvider'; // Updated
this.supportedDomains = ['gemini.google.com'];
// --- START OF CONFIGURABLE PROPERTIES (similar to other providers) ---
this.captureMethod = "dom"; // Switched to DOM capture by default
// TODO: DEVELOPER ACTION REQUIRED! Verify this URL pattern with Gemini's actual network requests.
this.debuggerUrlPattern = "*StreamGenerate*"; // Kept for potential future switch
this.includeThinkingInMessage = false; // Gemini likely doesn't have a separate "thinking" stream like some others.
// --- END OF CONFIGURABLE PROPERTIES ---
// Selectors for the Gemini interface
this.inputSelector = 'div.ql-editor, div[contenteditable="true"], textarea[placeholder="Enter a prompt here"], textarea.message-input, textarea.input-area';
this.sendButtonSelector = 'button[aria-label="Send message"], button.send-button, button.send-message-button';
// Response selector - updated to match the actual elements
this.responseSelector = 'model-response, message-content, .model-response-text, .markdown-main-panel, .model-response, div[id^="model-response-message"]';
// Thinking indicator selector
// Response selector - updated to the specific div for DOM capture
this.responseSelector = 'div.markdown.markdown-main-panel[id^="model-response-message-content"]';
// Thinking indicator selector - kept as is, assuming these are still relevant
this.thinkingIndicatorSelector = '.thinking-indicator, .loading-indicator, .typing-indicator, .response-loading, .blue-circle, .stop-icon';
// Fallback selectors (NEW)
this.responseSelectorForDOMFallback = 'model-response, message-content, .model-response-text, .markdown-main-panel'; // Placeholder
this.thinkingIndicatorSelectorForDOM = '.thinking-indicator, .loading-indicator, .blue-circle, .stop-icon'; // Placeholder
// Fallback selectors are less relevant now that DOM is primary but kept for completeness
this.responseSelectorForDOMFallback = 'model-response, message-content, .model-response-text, .markdown-main-panel';
this.thinkingIndicatorSelectorForDOM = '.thinking-indicator, .loading-indicator, .blue-circle, .stop-icon';
// Last sent message to avoid capturing it as a response
this.lastSentMessage = '';
// Flag to prevent double-sending - IMPORTANT: This must be false by default
this.hasSentMessage = false;
// For debugger-based response capture
this.pendingResponseCallbacks = new Map();
this.requestAccumulators = new Map(); // To accumulate response chunks for a given request
}
// Send a message to the chat interface (MODIFIED)
async sendChatMessage(text) {
console.log(`[${this.name}] sendChatMessage called with:`, text);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] sendChatMessage called with:`, text);
const inputElement = document.querySelector(this.inputSelector);
const sendButton = document.querySelector(this.sendButtonSelector);
if (!inputElement || !sendButton) {
console.error(`[${this.name}] Missing input field (${this.inputSelector}) or send button (${this.sendButtonSelector})`);
console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Missing input field (${this.inputSelector}) or send button (${this.sendButtonSelector})`);
return false;
}
console.log(`[${this.name}] Attempting to send message with:`, {
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Attempting to send message with:`, {
inputFieldInfo: inputElement.outerHTML.substring(0,100),
sendButtonInfo: sendButton.outerHTML.substring(0,100)
});
try {
this.lastSentMessage = text;
console.log(`[${this.name}] Stored last sent message:`, this.lastSentMessage);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Stored last sent message:`, this.lastSentMessage);
if (inputElement.tagName.toLowerCase() === 'div' && (inputElement.contentEditable === 'true' || inputElement.getAttribute('contenteditable') === 'true')) {
console.log(`[${this.name}] Input field is a contentEditable div.`);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Input field is a contentEditable div.`);
inputElement.focus();
inputElement.innerHTML = ''; // Clear existing content
inputElement.textContent = text; // Set the new text content
inputElement.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
console.log(`[${this.name}] Set text content and dispatched input event for contentEditable div.`);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Set text content and dispatched input event for contentEditable div.`);
} else { // Standard input or textarea
console.log(`[${this.name}] Input field is textarea/input.`);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Input field is textarea/input.`);
inputElement.value = text;
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
inputElement.focus();
console.log(`[${this.name}] Set value and dispatched input event for textarea/input.`);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Set value and dispatched input event for textarea/input.`);
}
await new Promise(resolve => setTimeout(resolve, 500)); // Preserved delay
@ -83,261 +94,355 @@ class GeminiProvider {
sendButton.classList.contains('disabled');
if (!isDisabled) {
console.log(`[${this.name}] Clicking send button.`);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Clicking send button.`);
sendButton.click();
return true;
} else {
console.warn(`[${this.name}] Send button is disabled. Cannot send message.`);
console.warn(`[${this.name}] [${this.captureMethod.toUpperCase()}] Send button is disabled. Cannot send message.`);
return false;
}
} catch (error) {
console.error(`[${this.name}] Error sending message:`, error);
console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Error sending message:`, error);
return false;
}
}
// Capture response from the chat interface (Original logic, logs updated for consistency)
captureResponse(element) {
// This method is called when this.captureMethod === "dom"
// 'element' is expected to be the one matching this.responseSelector
if (!element) {
console.log(`[${this.name}] No element provided to captureResponse`);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] No element provided to captureResponse (expected match for ${this.responseSelector})`);
return { found: false, text: '' };
}
console.log(`[${this.name}] Attempting to capture response from Gemini:`, element);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Attempting to capture response from Gemini element:`, element.outerHTML.substring(0, 200) + "...");
let responseText = "";
let foundResponse = false;
try {
console.log(`[${this.name}] Looking for response in various elements...`);
// Primarily rely on the textContent of the matched element
if (element.textContent) {
console.log(`[${this.name}] Element has text content`);
responseText = element.textContent.trim();
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Raw textContent from element (len ${responseText.length}): "${responseText.substring(0, 100)}..."`);
// Basic validation
if (responseText &&
responseText !== this.lastSentMessage &&
!responseText.includes("Loading") &&
!responseText.includes("Thinking") &&
!responseText.toLowerCase().includes("loading") && // Case-insensitive for common words
!responseText.toLowerCase().includes("thinking") &&
!responseText.includes("You stopped this response")) {
console.log(`[${this.name}] Found response in element:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
// HTML entities like < are automatically decoded by textContent
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Valid response found:`, responseText.substring(0, 100) + (responseText.length > 100 ? "..." : ""));
foundResponse = true;
} else {
console.log(`[${this.name}] Element text appears to be invalid:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
if (responseText === this.lastSentMessage) {
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Text content matches last sent message. Not a new response.`);
} else {
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Text content appears to be invalid or loading state: "${responseText.substring(0, 100)}..."`);
}
responseText = ""; // Clear if not a valid new response
}
} else {
console.log(`[${this.name}] Element has no text content`);
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Element has no textContent.`);
}
console.log(`[${this.name}] Trying to find the most recent conversation container...`);
const conversationContainers = document.querySelectorAll('.conversation-container');
if (conversationContainers && conversationContainers.length > 0) {
console.log(`[${this.name}] Found ${conversationContainers.length} conversation containers`);
const lastContainer = conversationContainers[conversationContainers.length - 1];
console.log(`[${this.name}] Last container ID:`, lastContainer.id);
const userQuery = lastContainer.querySelector('.user-query-container');
const userText = userQuery ? userQuery.textContent.trim() : '';
if (userText === this.lastSentMessage) {
console.log(`[${this.name}] Found container with our last sent message, looking for response`);
}
const modelResponse = lastContainer.querySelector('model-response');
if (modelResponse) {
console.log(`[${this.name}] Found model-response in last conversation container`);
const messageContent = modelResponse.querySelector('message-content.model-response-text');
if (messageContent) {
console.log(`[${this.name}] Found message-content in model-response`);
const markdownDiv = messageContent.querySelector('.markdown');
if (markdownDiv) {
console.log(`[${this.name}] Found markdown div in message-content`);
const text = markdownDiv.textContent.trim();
if (text &&
text !== this.lastSentMessage &&
!text.includes("Loading") &&
!text.includes("Thinking") &&
!text.includes("You stopped this response")) {
responseText = text;
console.log(`[${this.name}] Found response in markdown div:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
foundResponse = true;
}
}
}
}
} else {
console.log(`[${this.name}] No conversation containers found`);
}
if (!foundResponse) {
console.log(`[${this.name}] Trying to find model-response-message-content elements...`);
const responseMessages = document.querySelectorAll('div[id^="model-response-message-content"]');
if (responseMessages && responseMessages.length > 0) {
console.log(`[${this.name}] Found ${responseMessages.length} model-response-message-content elements`);
const sortedMessages = Array.from(responseMessages).sort((a, b) => {
return a.id.localeCompare(b.id);
});
const responseMessage = sortedMessages[sortedMessages.length - 1];
console.log(`[${this.name}] Last response message ID:`, responseMessage.id);
const text = responseMessage.textContent.trim();
if (text &&
text !== this.lastSentMessage &&
!text.includes("Loading") &&
!text.includes("Thinking") &&
!text.includes("You stopped this response")) {
responseText = text;
console.log(`[${this.name}] Found response in model-response-message:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
foundResponse = true;
}
} else {
console.log(`[${this.name}] No model-response-message-content elements found`);
}
}
if (!foundResponse) {
console.log(`[${this.name}] Trying to find message-content elements...`);
const messageContents = document.querySelectorAll('message-content.model-response-text');
if (messageContents && messageContents.length > 0) {
console.log(`[${this.name}] Found ${messageContents.length} message-content elements`);
const sortedContents = Array.from(messageContents).sort((a, b) => {
return (a.id || '').localeCompare(b.id || '');
});
const lastMessageContent = sortedContents[sortedContents.length - 1];
console.log(`[${this.name}] Last message content ID:`, lastMessageContent.id || 'no-id');
const markdownDiv = lastMessageContent.querySelector('.markdown');
if (markdownDiv) {
console.log(`[${this.name}] Found markdown div`);
const paragraphs = markdownDiv.querySelectorAll('p');
if (paragraphs && paragraphs.length > 0) {
console.log(`[${this.name}] Found ${paragraphs.length} paragraphs in markdown div`);
let combinedText = "";
paragraphs.forEach((p, index) => {
const text = p.textContent.trim();
console.log(`[${this.name}] Paragraph ${index} text:`, text.substring(0, 30) + (text.length > 30 ? "..." : ""));
if (text &&
text !== this.lastSentMessage &&
!text.includes("Loading") &&
!text.includes("Thinking") &&
!text.includes("You stopped this response")) {
combinedText += text + "\n";
}
// Fallback: if textContent was empty but there are <p> tags inside the matched element.
if (!foundResponse && responseText === "" && element.querySelectorAll) {
const paragraphs = element.querySelectorAll('p');
if (paragraphs && paragraphs.length > 0) {
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] textContent was empty, trying to combine ${paragraphs.length} <p> tags.`);
let combinedPText = "";
paragraphs.forEach(p => {
combinedPText += p.textContent.trim() + "\n";
});
if (combinedText.trim()) {
responseText = combinedText.trim();
console.log(`[${this.name}] Found response in paragraphs:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
foundResponse = true;
responseText = combinedPText.trim();
// Re-validate
if (responseText &&
responseText !== this.lastSentMessage &&
!responseText.toLowerCase().includes("loading") &&
!responseText.toLowerCase().includes("thinking") &&
!responseText.includes("You stopped this response")) {
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Valid response found from combined <p> tags:`, responseText.substring(0, 100) + (responseText.length > 100 ? "..." : ""));
foundResponse = true;
} else {
console.log(`[${this.name}] No valid text found in paragraphs`);
responseText = ""; // Clear if not valid after combining paragraphs
}
} else {
console.log(`[${this.name}] No paragraphs found in markdown div`);
const text = markdownDiv.textContent.trim();
if (text &&
text !== this.lastSentMessage &&
!text.includes("Loading") &&
!text.includes("Thinking") &&
!text.includes("You stopped this response")) {
responseText = text;
console.log(`[${this.name}] Found response in markdown div:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
foundResponse = true;
} else {
console.log(`[${this.name}] Markdown div text appears to be invalid:`, text.substring(0, 50) + (text.length > 50 ? "..." : ""));
}
}
} else {
console.log(`[${this.name}] No markdown div found in message-content`);
const text = lastMessageContent.textContent.trim();
if (text &&
text !== this.lastSentMessage &&
!text.includes("Loading") &&
!text.includes("Thinking") &&
!text.includes("You stopped this response")) {
responseText = text;
console.log(`[${this.name}] Found response in message-content:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
foundResponse = true;
} else {
console.log(`[${this.name}] Message-content text appears to be invalid:`, text.substring(0, 50) + (text.length > 50 ? "..." : ""));
}
}
} else {
console.log(`[${this.name}] No message-content elements found`);
}
}
if (!foundResponse) {
console.log(`[${this.name}] Trying to find paragraphs in the document...`);
const paragraphs = document.querySelectorAll('p');
if (paragraphs && paragraphs.length > 0) {
console.log(`[${this.name}] Found ${paragraphs.length} paragraphs`);
let combinedText = "";
for (let i = paragraphs.length - 1; i >= 0; i--) {
const paragraph = paragraphs[i];
const text = paragraph.textContent.trim();
const isUserQuery = paragraph.closest('.user-query-container, .user-query-bubble-container');
if (isUserQuery) {
continue;
}
if (text &&
text !== this.lastSentMessage &&
!text.includes("Loading") &&
!text.includes("Thinking") &&
!text.includes("You stopped this response")) {
combinedText = text + "\n" + combinedText;
if (text.startsWith("Hello") || text.includes("I'm doing") || text.includes("How can I assist")) {
break;
}
}
}
if (combinedText.trim()) {
responseText = combinedText.trim();
console.log(`[${this.name}] Found response in paragraphs:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
foundResponse = true;
} else {
console.log(`[${this.name}] No valid text found in paragraphs`);
}
} else {
console.log(`[${this.name}] No paragraphs found`);
}
}
if (!foundResponse) {
console.log(`[${this.name}] Response not found yet, will try again in the next polling cycle`);
}
} catch (error) {
console.error(`[${this.name}] Error capturing response from Gemini:`, error);
console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Error capturing response from Gemini:`, error);
}
// Final cleanup (mostly for newlines)
if (foundResponse && responseText) {
console.log(`[${this.name}] Cleaning up response text...`);
responseText = responseText.trim()
.replace(/^(Loading|Thinking).*/gim, '')
.replace(/You stopped this response.*/gim, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
console.log(`[${this.name}] Cleaned response text:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
responseText = responseText.replace(/\n{3,}/g, '\n\n').trim();
}
return {
found: foundResponse && !!responseText.trim(),
text: responseText
};
}
// (NEW) Method for streaming API patterns
getStreamingApiPatterns() {
console.log(`[${this.name}] getStreamingApiPatterns called`);
// TODO: DEVELOPER ACTION REQUIRED!
// Use browser Network DevTools on gemini.google.com to identify the
// exact URL(s) that deliver the AI's streaming response when a prompt is sent.
// Replace the placeholder pattern below with the correct one(s).
// Example: return [{ urlPattern: "*://gemini.google.com/api/generate*", requestStage: "Response" }];
return [
{ urlPattern: "*://gemini.google.com/api/stream/generateContent*", requestStage: "Response" } // Placeholder - VERIFY THIS!
];
// --- START: Methods for Debugger-based Response Capture ---
initiateResponseCapture(requestId, responseCallback) {
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] initiateResponseCapture called for requestId: ${requestId}. Current provider captureMethod: ${this.captureMethod}`);
this.pendingResponseCallbacks.set(requestId, responseCallback);
if (this.captureMethod === "debugger") {
// Ensure accumulator is ready for this request
if (!this.requestAccumulators.has(requestId)) {
this.requestAccumulators.set(requestId, { text: "", isDefinitelyFinal: false });
}
console.log(`[${this.name}] [DEBUGGER] Debugger capture selected. Callback stored for requestId: ${requestId}. Ensure background script is set up for '${this.debuggerUrlPattern}'.`);
} else if (this.captureMethod === "dom") {
// For DOM method, no specific initiation is needed here as polling is handled by content.js
console.log(`[${this.name}] [DOM] DOM capture selected. Response will be handled by polling in content.js using captureResponse.`);
} else {
console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Unknown capture method: ${this.captureMethod}`);
responseCallback(requestId, `[Error: Unknown capture method '${this.captureMethod}' in provider]`, true);
this.pendingResponseCallbacks.delete(requestId);
}
}
// (NEW) Optional Fallback Methods
handleDebuggerData(requestId, rawData, isFinalFromBackground) {
// This method is only relevant if this.captureMethod === "debugger"
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData ENTER - requestId: ${requestId}, isFinalFromBackground: ${isFinalFromBackground}, rawData: "${rawData ? rawData.substring(0,100) + "..." : "null/empty"}"`);
const callback = this.pendingResponseCallbacks.get(requestId);
if (!callback) {
console.warn(`[${this.name}] [DEBUGGER] handleDebuggerData - No callback for requestId: ${requestId}.`);
return;
}
let accumulator = this.requestAccumulators.get(requestId);
if (!accumulator) {
console.warn(`[${this.name}] [DEBUGGER] handleDebuggerData - No accumulator for requestId: ${requestId}. Initializing.`);
accumulator = { text: "", isDefinitelyFinal: false };
this.requestAccumulators.set(requestId, accumulator);
}
if (accumulator.isDefinitelyFinal) {
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - Accumulator for ${requestId} is already final. Skipping.`);
return;
}
if (rawData && rawData.trim() !== "") {
const parseOutput = this.parseDebuggerResponse(rawData); // Gemini-specific parsing
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - requestId: ${requestId}, parseOutput: ${JSON.stringify(parseOutput)}`);
if (parseOutput.text !== null) { // Allow empty string if it's a valid part of the response
accumulator.text = parseOutput.text; // parseOutput.text is the total text from rawData
}
if (parseOutput.isFinalResponse) { // If the parser itself detected a definitive end
accumulator.isDefinitelyFinal = true;
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - ${requestId} marked as definitelyFinal by parseOutput.`);
}
}
const isFinalForCallback = accumulator.isDefinitelyFinal || isFinalFromBackground;
if (accumulator.text || isFinalForCallback) {
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - INVOKING CALLBACK for ${requestId}. Text length: ${accumulator.text.length}, isFinal: ${isFinalForCallback}`);
callback(requestId, accumulator.text, isFinalForCallback);
}
if (isFinalForCallback) {
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - CLEANING UP for ${requestId} (isDefinitelyFinal: ${accumulator.isDefinitelyFinal}, isFinalFromBackground: ${isFinalFromBackground}).`);
this.pendingResponseCallbacks.delete(requestId);
this.requestAccumulators.delete(requestId);
}
}
// TODO: DEVELOPER ACTION REQUIRED!
// This is a placeholder parser. You MUST inspect Gemini's actual SSE
// stream format and update this parser accordingly.
parseDebuggerResponse(rawDataString) {
let accumulatedTextFromThisCall = "";
let isStreamDefinitelyFinished = false;
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse INPUT rawDataString (len ${rawDataString.length}): ${rawDataString.substring(0, 300)}`);
if (!rawDataString) {
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse: Empty rawDataString received.`);
return { text: "", isFinalResponse: false };
}
let cleanData = rawDataString;
if (cleanData.startsWith(")]}'")) {
const firstNewlineIndex = cleanData.indexOf('\n');
if (firstNewlineIndex !== -1) {
cleanData = cleanData.substring(firstNewlineIndex + 1);
} else {
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse: rawDataString starts with )]}' but no newline found.`);
cleanData = "";
}
}
cleanData = cleanData.trimStart();
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse cleanData after prefix strip (len ${cleanData.length}): ${cleanData.substring(0, 300)}`);
const chunks = [];
let currentIndex = 0;
while (currentIndex < cleanData.length) {
// Skip leading whitespace/newlines to find the start of a potential length line
while (currentIndex < cleanData.length && /\s/.test(cleanData.charAt(currentIndex))) {
currentIndex++;
}
if (currentIndex >= cleanData.length) { // Reached end after skipping whitespace
// console.log(`[${this.name}] Reached end of data after skipping initial whitespace.`);
break;
}
const nextNewline = cleanData.indexOf('\n', currentIndex);
if (nextNewline === -1) {
// Last line processing (potential trailing length or garbage)
const remainingStr = cleanData.substring(currentIndex).trim();
if (/^\d+$/.test(remainingStr) && chunks.length > 0) {
// console.log(`[${this.name}] [DEBUGGER] Trailing number found, likely length for a future (missing) chunk: ${remainingStr}`);
} else if (remainingStr.length > 0 && chunks.length > 0) { // If there's non-numeric trailing data and we have prior chunks, it might be an error or unparseable.
// console.warn(`[${this.name}] [DEBUGGER] Trailing non-numeric, non-empty data found: ${remainingStr.substring(0,100)}`);
} else if (remainingStr.length > 0 && chunks.length === 0) { // If it's the *only* data and not a number.
// console.warn(`[${this.name}] [DEBUGGER] Single line of non-numeric, non-empty data found, cannot parse as chunk: ${remainingStr.substring(0,100)}`);
}
break; // End of data or unparseable trailing data
}
const lengthLineContent = cleanData.substring(currentIndex, nextNewline).trim();
let length = NaN;
if (/^\d+$/.test(lengthLineContent)) { // Check if the line *only* contains digits
length = parseInt(lengthLineContent, 10);
}
// console.log(`[${this.name}] [DEBUGGER] Chunk parsing: Potential lengthLineContent="${lengthLineContent}", parsed length=${length}`);
if (isNaN(length) || length < 0) { // This will catch non-numeric lines or negative/invalid lengths
console.warn(`[${this.name}] [DEBUGGER] Invalid, non-positive, or non-numeric length line: "${lengthLineContent}". CurrentIndex: ${currentIndex}. Skipping to next line.`);
currentIndex = nextNewline + 1; // Advance past the problematic line
continue; // Restart loop to find next potential length line from the new currentIndex
}
const jsonStringStart = nextNewline + 1;
const jsonStringEnd = jsonStringStart + length;
if (length === 0) {
// console.log(`[${this.name}] [DEBUGGER] Encountered 0-length chunk at currentIndex ${currentIndex}. Advancing to after its conceptual position.`);
currentIndex = jsonStringEnd; // This is effectively nextNewline + 1, the start of where the empty JSON "was"
continue;
}
if (jsonStringEnd > cleanData.length) {
console.warn(`[${this.name}] [DEBUGGER] Declared length ${length} (from line "${lengthLineContent}") exceeds available data. cleanData.length: ${cleanData.length}, required end: ${jsonStringEnd}. Discarding this length and attempting to resynchronize.`);
currentIndex = nextNewline + 1; // Skip the problematic length line
continue; // Try to find the next valid length line
}
const jsonString = cleanData.substring(jsonStringStart, jsonStringEnd);
// console.log(`[${this.name}] [DEBUGGER] Extracted jsonString (len ${jsonString.length}, expected ${length}): ${jsonString.substring(0,100)}`);
try {
JSON.parse(jsonString);
chunks.push(jsonString);
currentIndex = jsonStringEnd;
} catch (parseError) {
const errorMsg = parseError.message || "";
const nonWhitespaceMatch = errorMsg.match(/Unexpected non-whitespace character after JSON at position (\d+)/);
if (nonWhitespaceMatch && nonWhitespaceMatch[1]) {
const actualEndPositionInJsonString = parseInt(nonWhitespaceMatch[1], 10);
const validJsonSubstring = jsonString.substring(0, actualEndPositionInJsonString);
try {
JSON.parse(validJsonSubstring);
chunks.push(validJsonSubstring);
currentIndex = jsonStringStart + actualEndPositionInJsonString;
console.warn(`[${this.name}] [DEBUGGER] Corrected oversized JSON chunk. Original length ${length} (from line "${lengthLineContent}") was too long. Used shorter valid part of length ${actualEndPositionInJsonString}. New currentIndex: ${currentIndex}. Original error: ${parseError.message}`);
} catch (innerParseError) {
console.warn(`[${this.name}] [DEBUGGER] Failed to parse even the corrected shorter JSON substring (original length ${length} from line "${lengthLineContent}", attempted correction to ${actualEndPositionInJsonString}). Inner Error: ${innerParseError.message}. JSON hint for corrected: "${validJsonSubstring.substring(0,100)}...". Skipping original length declaration.`);
currentIndex = nextNewline + 1;
}
} else {
console.warn(`[${this.name}] [DEBUGGER] Failed to parse JSON chunk (length ${length} from line "${lengthLineContent}", Error: ${parseError.message}). This suggests the reported length was incorrect or JSON malformed. JSON hint: "${jsonString.substring(0, 100)}...". Skipping this length declaration and attempting to resynchronize.`);
currentIndex = nextNewline + 1;
}
continue;
}
}
for (const rawChunkJson of chunks) {
try {
const chunkJsonToParse = rawChunkJson.trim();
if (!chunkJsonToParse) {
continue;
}
const outerArray = JSON.parse(chunkJsonToParse);
if (!Array.isArray(outerArray)) continue;
for (const item of outerArray) {
if (Array.isArray(item) && item.length > 0) {
if (item[0] === "wrb.fr" && item.length >= 3 && typeof item[2] === 'string') {
const nestedJsonString = item[2];
try {
const trimmedNestedJsonString = nestedJsonString.trim();
if (!trimmedNestedJsonString) {
continue;
}
const innerData = JSON.parse(trimmedNestedJsonString);
if (Array.isArray(innerData) && innerData.length > 4 && Array.isArray(innerData[4])) {
for (const contentBlock of innerData[4]) {
if (Array.isArray(contentBlock) && contentBlock.length >= 2 && typeof contentBlock[0] === 'string' && contentBlock[0].startsWith("rc_") && Array.isArray(contentBlock[1])) {
const newText = contentBlock[1].join("");
if (newText) {
accumulatedTextFromThisCall += newText;
}
}
}
}
} catch (e) {
console.warn(`[${this.name}] [DEBUGGER] Failed to parse nested JSON: "${nestedJsonString.substring(0,100)}...". Error: ${e.message}`);
}
} else if (item[0] === "e" && item.length >= 1) {
isStreamDefinitelyFinished = true;
console.log(`[${this.name}] [DEBUGGER] End-of-stream marker 'e' detected in chunk: ${JSON.stringify(item)}`);
}
}
}
} catch (e) {
console.warn(`[${this.name}] [DEBUGGER] Failed to parse chunk JSON: "${chunkJsonToParse.substring(0,100)}...". Error: ${e.message}`);
}
}
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse FINAL output - text length: ${accumulatedTextFromThisCall.length}, accumulatedText: "${accumulatedTextFromThisCall.substring(0,200)}", isFinalByParser: ${isStreamDefinitelyFinished}`);
if (accumulatedTextFromThisCall.includes("\\<") || accumulatedTextFromThisCall.includes("\\>")) {
console.log(`[${this.name}] [DEBUGGER] Unescaping escaped tool call brackets in final text.`);
accumulatedTextFromThisCall = accumulatedTextFromThisCall.replace(/\\</g, "<").replace(/\\>/g, ">");
}
if (accumulatedTextFromThisCall.includes("\\_")) {
console.log(`[${this.name}] [DEBUGGER] Unescaping escaped underscores in final text.`);
accumulatedTextFromThisCall = accumulatedTextFromThisCall.replace(/\\_/g, "_");
}
return { text: accumulatedTextFromThisCall, isFinalResponse: isStreamDefinitelyFinished };
}
// --- END: Methods for Debugger-based Response Capture ---
getStreamingApiPatterns() {
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] getStreamingApiPatterns called. Current provider captureMethod: ${this.captureMethod}`);
if (this.captureMethod === "debugger" && this.debuggerUrlPattern) {
console.log(`[${this.name}] [DEBUGGER] Using debugger URL pattern: ${this.debuggerUrlPattern}`);
return [{ urlPattern: this.debuggerUrlPattern, requestStage: "Response" }];
}
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] No debugger patterns to return (captureMethod is '${this.captureMethod}' or no pattern set).`);
return [];
}
// (NEW) Optional Fallback Methods - these are largely placeholders if debugger is primary
async captureResponseDOMFallback() {
console.log(`[${this.name}] captureResponseDOMFallback called. Implement DOM observation logic here if needed as a fallback.`);
// TODO: Implement or verify existing DOM fallback logic for Gemini if it's to be kept.
@ -424,10 +529,11 @@ class GeminiProvider {
return null;
}
// Check if we should skip response monitoring (Original - UNCHANGED)
// Check if we should skip response monitoring
shouldSkipResponseMonitoring() {
// We want to monitor for responses now that we've fixed the response capturing
return false;
// If using debugger, we skip DOM-based monitoring.
// console.log(`[${this.name}] shouldSkipResponseMonitoring called. Capture method: ${this.captureMethod}`);
return this.captureMethod === "debugger";
}
}