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.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
!api-relay-server/relay.settings
# IDE and editor files # IDE and editor files
.idea/ .idea/

View file

@ -12,6 +12,7 @@
"api-relay-server": "file:", "api-relay-server": "file:",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^5.1.0", "express": "^5.1.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"ws": "^8.18.2" "ws": "^8.18.2"
@ -430,6 +431,18 @@
"node": ">= 0.8" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View file

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

View file

@ -1,127 +1,248 @@
<!-- <!--
Chat Relay: Relay for AI Chat Interfaces Chat Relay: Relay for AI Chat Interfaces
Copyright (C) 2025 Jamison Moore Copyright (C) 2025 Jamison Moore
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version. License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/. along with this program. If not, see https://www.gnu.org/licenses/.
--> -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Relay Admin</title> <title>Chat Relay Admin</title>
<style> <style>
body { 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; margin: 0;
padding: 0; padding: 0;
background-color: #f4f4f4; background-color: #f0f2f5; /* Lighter gray */
color: #333; color: #1c1e21; /* Darker text for better contrast */
line-height: 1.6;
} }
header { header {
background-color: #333; background-color: #1877f2; /* Facebook blue */
color: #fff; color: #fff;
padding: 1em; padding: 1em 1.5em;
text-align: center; text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
header h1 {
margin: 0;
font-size: 1.8em;
} }
nav { nav {
background-color: #444; background-color: #fff; /* White background for nav */
padding: 0.5em; 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 { nav ul {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
text-align: center; text-align: center;
} }
nav ul li { nav ul li {
display: inline; display: inline;
margin-right: 20px; margin-right: 25px;
} }
nav ul li a { nav ul li a {
color: #fff; color: #1877f2; /* Blue links */
text-decoration: none; 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 { nav ul li a.active {
text-decoration: underline; color: #1c1e21; /* Darker color for active tab */
border-bottom: 3px solid #1877f2;
} }
.container { .container {
padding: 1em; padding: 1.5em;
max-width: 100%;
box-sizing: border-box;
} }
.tab-content { display: none; }
.tab-content { .tab-content.active { display: block; }
display: none;
}
.tab-content.active {
display: block;
}
h2 { h2 {
border-bottom: 2px solid #333; border-bottom: 2px solid #dddfe2;
padding-bottom: 0.5em; padding-bottom: 0.5em;
color: #1c1e21;
font-size: 1.5em;
margin-top: 0;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; 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 {
th, border: 1px solid #dddfe2;
td { padding: 12px 10px; /* Increased padding */
border: 1px solid #ddd;
padding: 8px;
text-align: left; text-align: left;
vertical-align: top; /* Align content to the top */
word-wrap: break-word; /* Prevent long words from breaking layout */
} }
th { th {
background-color: #555; background-color: #f5f6f7; /* Lighter header background */
color: white; 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 { .log-window {
background-color: #222; background-color: #1e1e1e; /* Darker background for log */
color: #0f0; color: #d4d4d4; /* Lighter text for log */
font-family: 'Courier New', Courier, monospace; font-family: 'Consolas', 'Courier New', Courier, monospace;
padding: 10px; padding: 15px;
height: 200px; height: 250px; /* Slightly taller */
overflow-y: scroll; overflow-y: scroll;
border: 1px solid #444; border: 1px solid #333;
margin-top: 1em; margin-top: 1.5em;
border-radius: 4px;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
} }
.log-entry { .log-entry {
white-space: pre-wrap; white-space: pre-wrap;
padding: 2px 0;
} }
.collapsible-header { .collapsible-header {
background-color: #555; background-color: #6c757d; /* Bootstrap secondary color */
color: white; color: white;
padding: 0.5em; padding: 0.75em;
cursor: pointer; cursor: pointer;
text-align: center; 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> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>Chat Relay Admin Dashboard</h1> <h1>Chat Relay Admin Dashboard</h1>
@ -130,24 +251,30 @@
<ul> <ul>
<li><a href="#" class="tab-link active" data-tab="messages">Messages</a></li> <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="settings">Settings</a></li>
<li><a href="#" class="tab-link" data-tab="status">Status</a></li>
</ul> </ul>
</nav> </nav>
<div class="container"> <div class="container">
<div id="messages" class="tab-content active"> <div id="messages" class="tab-content active">
<h2>Message History <button id="refresh-messages-btn" style="font-size: 0.8em; margin-left: 10px;">Refresh <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;">
Messages</button></h2> <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> <table>
<thead> <thead>
<tr> <tr>
<th>Start Timestamp</th> <th class="col-timestamp">Start Timestamp</th>
<th>End Timestamp</th> <th class="col-timestamp">End Timestamp</th>
<th>Request ID</th> <th class="col-request-id">Request ID</th>
<th>From Client (Cline)</th> <th class="col-data">From Client <span class="collapse-btn" data-column="fromClient" title="Toggle Column">-</span></th>
<th>To Extension</th> <th class="col-data">To Extension <span class="collapse-btn" data-column="toExtension" title="Toggle Column">-</span></th>
<th>From Extension</th> <th class="col-data">From Extension <span class="collapse-btn" data-column="fromExtension" title="Toggle Column">-</span></th>
<th>To Client (Cline)</th> <th class="col-data">To Client <span class="collapse-btn" data-column="toClient" title="Toggle Column">-</span></th>
<th>Status</th> <th class="col-status">Status</th>
</tr> </tr>
</thead> </thead>
<tbody id="message-history-body"> <tbody id="message-history-body">
@ -170,8 +297,7 @@
<div style="margin-top: 0.5em;"> <div style="margin-top: 0.5em;">
<label>New Request Behavior (if extension busy):</label> <label>New Request Behavior (if extension busy):</label>
<div> <div>
<input type="radio" id="newRequestBehaviorQueue" name="newRequestBehavior" value="queue" <input type="radio" id="newRequestBehaviorQueue" name="newRequestBehavior" value="queue" checked>
checked>
<label for="newRequestBehaviorQueue">Queue</label> <label for="newRequestBehaviorQueue">Queue</label>
</div> </div>
<div> <div>
@ -179,25 +305,12 @@
<label for="newRequestBehaviorDrop">Drop</label> <label for="newRequestBehaviorDrop">Drop</label>
</div> </div>
</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> <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> <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> <p style="margin-top: 1em;">Ping Interval (ms): <span id="setting-ping-interval"></span></p>
</div> </div>
</div> </div>
<div id="status" class="tab-content"> <!-- Status tab content removed as its elements are moved to the Messages tab -->
<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>
</div> </div>
<div class="collapsible-header" onclick="toggleLogWindow()">Server Logs (click to toggle)</div> <div class="collapsible-header" onclick="toggleLogWindow()">Server Logs (click to toggle)</div>
<div class="log-window" id="log-window-content" style="display: none;"> <div class="log-window" id="log-window-content" style="display: none;">
@ -215,8 +328,13 @@
link.classList.add('active'); link.classList.add('active');
const activeTabContent = document.getElementById(link.dataset.tab); const activeTabContent = document.getElementById(link.dataset.tab);
activeTabContent.classList.add('active'); 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(); fetchAndDisplayServerInfo();
} }
}); });
@ -231,21 +349,23 @@
} }
const messageHistoryBody = document.getElementById('message-history-body'); const messageHistoryBody = document.getElementById('message-history-body');
const refreshButton = document.getElementById('refresh-messages-btn'); const refreshButton = document.getElementById('refresh-messages-btn');
function createPreCell(data) { function createPreCell(data) {
const cell = document.createElement('td'); const cell = document.createElement('td');
if (data === undefined || data === null) { if (data === undefined || data === null) {
cell.textContent = 'N/A'; cell.textContent = 'N/A';
} else { } else {
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.style.margin = '0'; // pre.style.margin = '0'; // Handled by td pre CSS
pre.style.whiteSpace = 'pre-wrap'; // pre.style.whiteSpace = 'pre-wrap'; // Handled by td pre CSS
pre.style.maxHeight = '200px'; // Added max height // pre.style.maxHeight = '200px'; // Handled by td pre CSS
pre.style.overflowY = 'auto'; // Added scrollability // pre.style.overflowY = 'auto'; // Handled by td pre CSS
pre.textContent = JSON.stringify(data, null, 2); pre.textContent = JSON.stringify(data, null, 2);
cell.appendChild(pre); cell.appendChild(pre);
} }
return cell; return cell;
} }
async function fetchAndDisplayMessageHistory() { async function fetchAndDisplayMessageHistory() {
try { try {
const response = await fetch('/v1/admin/message-history'); const response = await fetch('/v1/admin/message-history');
@ -253,7 +373,9 @@
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const messages = await response.json(); const messages = await response.json();
messageHistoryBody.innerHTML = ''; // Clear existing rows messageHistoryBody.innerHTML = ''; // Clear existing rows
if (messages.length === 0) { if (messages.length === 0) {
const row = messageHistoryBody.insertRow(); const row = messageHistoryBody.insertRow();
const cell = row.insertCell(); const cell = row.insertCell();
@ -262,6 +384,7 @@
cell.style.textAlign = 'center'; cell.style.textAlign = 'center';
return; return;
} }
// Group messages by requestId // Group messages by requestId
const groupedMessages = messages.reduce((acc, logEntry) => { const groupedMessages = messages.reduce((acc, logEntry) => {
const id = logEntry.requestId; const id = logEntry.requestId;
@ -277,6 +400,7 @@
status: "Unknown" status: "Unknown"
}; };
} }
// Update fields based on log type // Update fields based on log type
switch (logEntry.type) { switch (logEntry.type) {
case 'CHAT_REQUEST_RECEIVED': case 'CHAT_REQUEST_RECEIVED':
@ -304,29 +428,35 @@
} }
return acc; return acc;
}, {}); }, {});
// Convert grouped messages object to an array and sort by timestamp (most recent first) // Convert grouped messages object to an array and sort by timestamp (most recent first)
const consolidatedMessages = Object.values(groupedMessages).sort((a, b) => { const consolidatedMessages = Object.values(groupedMessages).sort((a, b) => {
// Sort by startTimestamp, most recent first // Sort by startTimestamp, most recent first
return new Date(b.startTimestamp) - new Date(a.startTimestamp); return new Date(b.startTimestamp) - new Date(a.startTimestamp);
}); });
if (consolidatedMessages.length === 0) { if (consolidatedMessages.length === 0) {
const row = messageHistoryBody.insertRow(); const row = messageHistoryBody.insertRow();
const cell = row.insertCell(); const cell = row.insertCell();
cell.colSpan = 8; // Adjusted for new column cell.colSpan = 8; // Adjusted for new column
cell.textContent = 'No consolidated message history to display.'; cell.textContent = 'No consolidated message history to display.';
cell.style.textAlign = 'center'; cell.style.textAlign = 'center';
return; return;
} }
consolidatedMessages.forEach(msg => { consolidatedMessages.forEach(msg => {
const row = messageHistoryBody.insertRow(); const row = messageHistoryBody.insertRow();
row.insertCell().textContent = new Date(msg.startTimestamp).toLocaleString(); let cell;
row.insertCell().textContent = msg.endTimestamp ? new Date(msg.endTimestamp).toLocaleString() : (msg.status === "Request In Progress" ? "In Progress" : "N/A"); cell = row.insertCell(); cell.textContent = new Date(msg.startTimestamp).toLocaleString(); cell.classList.add('col-timestamp');
row.insertCell().textContent = msg.requestId; 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');
row.appendChild(createPreCell(msg.fromClient)); cell = row.insertCell(); cell.textContent = msg.requestId; cell.classList.add('col-request-id');
row.appendChild(createPreCell(msg.toExtension));
row.appendChild(createPreCell(msg.fromExtension)); cell = createPreCell(msg.fromClient); cell.classList.add('col-data', 'cell-fromClient'); row.appendChild(cell);
row.appendChild(createPreCell(msg.toClient)); cell = createPreCell(msg.toExtension); cell.classList.add('col-data', 'cell-toExtension'); row.appendChild(cell);
row.insertCell().textContent = msg.status; 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) { } catch (error) {
console.error('Error fetching message history:', error); console.error('Error fetching message history:', error);
@ -339,16 +469,19 @@
cell.style.textAlign = 'center'; cell.style.textAlign = 'center';
} }
} }
if (refreshButton) { if (refreshButton) {
refreshButton.addEventListener('click', fetchAndDisplayMessageHistory); refreshButton.addEventListener('click', fetchAndDisplayMessageHistory);
} }
fetchAndDisplayMessageHistory(); // Initial load for messages fetchAndDisplayMessageHistory(); // Initial load for messages
fetchAndDisplayServerInfo(); // Initial load for server info (status) on the messages tab
// Elements for settings and status // Elements for settings and status
const portInputEl = document.getElementById('port-input'); // Corrected ID const portInputEl = document.getElementById('port-input'); // Corrected ID
const requestTimeoutInputEl = document.getElementById('request-timeout-input'); const requestTimeoutInputEl = document.getElementById('request-timeout-input');
const newRequestBehaviorQueueEl = document.getElementById('newRequestBehaviorQueue'); const newRequestBehaviorQueueEl = document.getElementById('newRequestBehaviorQueue');
const newRequestBehaviorDropEl = document.getElementById('newRequestBehaviorDrop'); const newRequestBehaviorDropEl = document.getElementById('newRequestBehaviorDrop');
const autoKillPortInputEl = document.getElementById('auto-kill-port-input');
const saveSettingsBtn = document.getElementById('save-settings-btn'); const saveSettingsBtn = document.getElementById('save-settings-btn');
const updateStatusMsgEl = document.getElementById('update-status-msg'); const updateStatusMsgEl = document.getElementById('update-status-msg');
const settingPingIntervalEl = document.getElementById('setting-ping-interval'); const settingPingIntervalEl = document.getElementById('setting-ping-interval');
@ -356,6 +489,7 @@
const statusUptimeEl = document.getElementById('status-uptime'); const statusUptimeEl = document.getElementById('status-uptime');
const statusConnectedExtensionsEl = document.getElementById('status-connected-extensions'); const statusConnectedExtensionsEl = document.getElementById('status-connected-extensions');
const restartServerBtn = document.getElementById('restart-server-btn'); const restartServerBtn = document.getElementById('restart-server-btn');
function formatUptime(totalSeconds) { function formatUptime(totalSeconds) {
if (totalSeconds === null || totalSeconds === undefined) return 'N/A'; if (totalSeconds === null || totalSeconds === undefined) return 'N/A';
const days = Math.floor(totalSeconds / (3600 * 24)); const days = Math.floor(totalSeconds / (3600 * 24));
@ -364,6 +498,7 @@
totalSeconds %= 3600; totalSeconds %= 3600;
const minutes = Math.floor(totalSeconds / 60); const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
let uptimeString = ''; let uptimeString = '';
if (days > 0) uptimeString += `${days}d `; if (days > 0) uptimeString += `${days}d `;
if (hours > 0) uptimeString += `${hours}h `; if (hours > 0) uptimeString += `${hours}h `;
@ -371,83 +506,123 @@
uptimeString += `${seconds}s`; uptimeString += `${seconds}s`;
return uptimeString.trim() || '0s'; return uptimeString.trim() || '0s';
} }
async function fetchAndDisplayServerInfo() { async function fetchAndDisplayServerInfo() {
try { 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) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const serverInfo = await response.json(); const currentSettings = await response.json();
// Populate Settings
if (portInputEl) portInputEl.value = serverInfo.port || ''; // Use portInputEl // Populate Settings using the new response structure
if (requestTimeoutInputEl) requestTimeoutInputEl.value = serverInfo.requestTimeoutMs !== null ? serverInfo.requestTimeoutMs : ''; if(portInputEl) portInputEl.value = currentSettings.serverPort || '';
if (settingPingIntervalEl) settingPingIntervalEl.textContent = serverInfo.pingIntervalMs !== null ? `${serverInfo.pingIntervalMs} ms` : 'N/A (Not Implemented)'; if(requestTimeoutInputEl) requestTimeoutInputEl.value = currentSettings.requestTimeout !== null ? currentSettings.requestTimeout : '';
if (serverInfo.newRequestBehavior === 'drop') { // Ping interval is not part of /admin/settings, assuming it's static or handled elsewhere if needed
if (newRequestBehaviorDropEl) newRequestBehaviorDropEl.checked = true; // if(settingPingIntervalEl) settingPingIntervalEl.textContent = currentSettings.pingIntervalMs !== null ? `${currentSettings.pingIntervalMs} ms` : 'N/A (Not Implemented)';
if (currentSettings.messageSendStrategy === 'drop') {
if(newRequestBehaviorDropEl) newRequestBehaviorDropEl.checked = true;
} else { } 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 // Populate Status - Assuming server-info might still be used for uptime/connections or these are separate
if (statusUptimeEl) statusUptimeEl.textContent = formatUptime(serverInfo.uptimeSeconds); // For now, focusing on settings. If status also needs to come from /admin/settings, that's another change.
if (statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = serverInfo.connectedExtensionsCount !== null ? serverInfo.connectedExtensionsCount : 'N/A'; // Let's assume uptime and connectedExtensionsCount are fetched separately or are less critical for this immediate fix.
} catch (error) { // For simplicity, I'll comment out the status population from this specific fetch if it was tied to the old server-info structure.
console.error('Error fetching server info:', error); // If your /admin/settings also returns uptime and connectedExtensionsCount, uncomment and adjust field names.
if (portInputEl) portInputEl.value = 'Error'; /*
if (requestTimeoutInputEl) requestTimeoutInputEl.value = 'Error'; if(statusUptimeEl) statusUptimeEl.textContent = formatUptime(currentSettings.uptimeSeconds);
if (settingPingIntervalEl) settingPingIntervalEl.textContent = 'Error'; if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = currentSettings.connectedExtensionsCount !== null ? currentSettings.connectedExtensionsCount : 'N/A';
if (newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default on error */
if (autoKillPortInputEl) autoKillPortInputEl.checked = false; // Default on error // If you still have a /v1/admin/server-info for uptime and connections, that part can remain as is.
// Ensure error handling for status elements // This function will now primarily handle settings population.
if (statusUptimeEl) statusUptimeEl.textContent = 'Error loading uptime'; // To keep status functional if it's from a different endpoint, we might need to separate concerns
if (statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = 'Error loading connections'; // 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() { 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 newTimeout = parseInt(requestTimeoutInputEl.value, 10);
const newPort = parseInt(portInputEl.value, 10); const newPort = parseInt(portInputEl.value, 10);
const selectedNewRequestBehavior = newRequestBehaviorQueueEl.checked ? 'queue' : 'drop'; const selectedNewRequestBehavior = newRequestBehaviorQueueEl.checked ? 'queue' : 'drop';
const autoKillPort = autoKillPortInputEl.checked;
let settingsToUpdate = {}; let settingsToUpdate = {};
let validationError = false; let validationError = false;
let messages = []; let messages = [];
if (requestTimeoutInputEl.value.trim() !== '') { // Only process if there's input if (requestTimeoutInputEl.value.trim() !== '') { // Only process if there's input
if (!isNaN(newTimeout) && newTimeout > 0) { if (!isNaN(newTimeout) && newTimeout > 0) {
settingsToUpdate.requestTimeoutMs = newTimeout; // UPDATED: Key name for server
settingsToUpdate.requestTimeout = newTimeout;
} else { } else {
messages.push('Invalid timeout: Must be a positive number.'); messages.push('Invalid timeout: Must be a positive number.');
validationError = true; validationError = true;
} }
} }
if (portInputEl.value.trim() !== '') { // Only process if there's input if (portInputEl.value.trim() !== '') { // Only process if there's input
if (!isNaN(newPort) && newPort > 0 && newPort <= 65535) { if (!isNaN(newPort) && newPort > 0 && newPort <= 65535) {
settingsToUpdate.port = newPort; // UPDATED: Key name for server
settingsToUpdate.serverPort = newPort;
} else { } else {
messages.push('Invalid port: Must be between 1 and 65535.'); messages.push('Invalid port: Must be between 1 and 65535.');
validationError = true; validationError = true;
} }
} }
// Always include newRequestBehavior as it's controlled by radio buttons // Always include newRequestBehavior as it's controlled by radio buttons
// No specific validation needed here as it's either 'queue' or 'drop' // No specific validation needed here as it's either 'queue' or 'drop'
settingsToUpdate.newRequestBehavior = selectedNewRequestBehavior; // UPDATED: Key name for server
settingsToUpdate.autoKillPort = autoKillPort; // Add the new setting settingsToUpdate.messageSendStrategy = selectedNewRequestBehavior;
if (validationError) { if (validationError) {
updateStatusMsgEl.textContent = messages.join(' '); updateStatusMsgEl.textContent = messages.join(' ');
updateStatusMsgEl.style.color = 'red'; updateStatusMsgEl.style.color = 'red';
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, 7000); setTimeout(() => { updateStatusMsgEl.textContent = ''; }, 7000);
return; return;
} }
if (Object.keys(settingsToUpdate).length === 0) { if (Object.keys(settingsToUpdate).length === 0) {
updateStatusMsgEl.textContent = 'No changes to save.'; updateStatusMsgEl.textContent = 'No changes to save.';
updateStatusMsgEl.style.color = 'blue'; updateStatusMsgEl.style.color = 'blue';
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, 5000); setTimeout(() => { updateStatusMsgEl.textContent = ''; }, 5000);
return; return;
} }
updateStatusMsgEl.textContent = 'Saving settings...'; updateStatusMsgEl.textContent = 'Saving settings...';
updateStatusMsgEl.style.color = 'orange'; updateStatusMsgEl.style.color = 'orange';
try { try {
const response = await fetch('/v1/admin/update-settings', { // UPDATED: Endpoint for saving settings
const response = await fetch('/admin/settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settingsToUpdate) body: JSON.stringify(settingsToUpdate)
@ -471,9 +646,11 @@
const clearTime = updateStatusMsgEl.textContent.toLowerCase().includes('restart') ? 15000 : 7000; const clearTime = updateStatusMsgEl.textContent.toLowerCase().includes('restart') ? 15000 : 7000;
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, clearTime); setTimeout(() => { updateStatusMsgEl.textContent = ''; }, clearTime);
} }
if (saveSettingsBtn) { if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', handleSaveSettings); saveSettingsBtn.addEventListener('click', handleSaveSettings);
} }
async function handleRestartServer() { async function handleRestartServer() {
if (confirm('Are you sure you want to restart the server?')) { if (confirm('Are you sure you want to restart the server?')) {
try { try {
@ -486,13 +663,61 @@
} }
} }
} }
if (restartServerBtn) { if (restartServerBtn) {
restartServerBtn.addEventListener('click', handleRestartServer); restartServerBtn.addEventListener('click', handleRestartServer);
} }
// Initial load for settings and status
fetchAndDisplayServerInfo(); // Initial load for settings is handled by tab click or initial messages tab load.
console.log("Admin UI initialized. Message history, settings, status, and restart functionality implemented."); // 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> </script>
</body> </body>
</html> </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, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true,
"allowJs": true // Add this line
}, },
"include": [ "include": [
"src/**/*" "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 !!!!! // !!!!! 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); const callback = this.pendingResponseCallbacks.get(requestId);
if (!callback) { 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)) { if (this.requestBuffers.has(requestId)) {
this.requestBuffers.delete(requestId); this.requestBuffers.delete(requestId);
} }
return; 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)) { if (!this.requestBuffers.has(requestId)) {
this.requestBuffers.set(requestId, { accumulatedText: "" }); this.requestBuffers.set(requestId, { accumulatedText: "" });
} }
@ -510,7 +520,7 @@ class ClaudeProvider {
console.log(`[${this.name}] getStreamingApiPatterns called. Capture method: ${this.captureMethod}`); console.log(`[${this.name}] getStreamingApiPatterns called. Capture method: ${this.captureMethod}`);
if (this.captureMethod === "debugger" && this.debuggerUrlPattern) { if (this.captureMethod === "debugger" && this.debuggerUrlPattern) {
console.log(`[${this.name}] Using debugger URL pattern: ${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).`); console.log(`[${this.name}] No debugger patterns to return (captureMethod is not 'debugger' or no pattern set).`);
return []; return [];

View file

@ -22,58 +22,69 @@ class GeminiProvider {
this.name = 'GeminiProvider'; // Updated this.name = 'GeminiProvider'; // Updated
this.supportedDomains = ['gemini.google.com']; 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 // 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.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'; this.sendButtonSelector = 'button[aria-label="Send message"], button.send-button, button.send-message-button';
// Response selector - updated to match the actual elements // Response selector - updated to the specific div for DOM capture
this.responseSelector = 'model-response, message-content, .model-response-text, .markdown-main-panel, .model-response, div[id^="model-response-message"]'; this.responseSelector = 'div.markdown.markdown-main-panel[id^="model-response-message-content"]';
// Thinking indicator selector // 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'; this.thinkingIndicatorSelector = '.thinking-indicator, .loading-indicator, .typing-indicator, .response-loading, .blue-circle, .stop-icon';
// Fallback selectors (NEW) // 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'; // Placeholder this.responseSelectorForDOMFallback = 'model-response, message-content, .model-response-text, .markdown-main-panel';
this.thinkingIndicatorSelectorForDOM = '.thinking-indicator, .loading-indicator, .blue-circle, .stop-icon'; // Placeholder this.thinkingIndicatorSelectorForDOM = '.thinking-indicator, .loading-indicator, .blue-circle, .stop-icon';
// Last sent message to avoid capturing it as a response // Last sent message to avoid capturing it as a response
this.lastSentMessage = ''; this.lastSentMessage = '';
// Flag to prevent double-sending - IMPORTANT: This must be false by default // Flag to prevent double-sending - IMPORTANT: This must be false by default
this.hasSentMessage = false; 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) // Send a message to the chat interface (MODIFIED)
async sendChatMessage(text) { 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 inputElement = document.querySelector(this.inputSelector);
const sendButton = document.querySelector(this.sendButtonSelector); const sendButton = document.querySelector(this.sendButtonSelector);
if (!inputElement || !sendButton) { 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; 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), inputFieldInfo: inputElement.outerHTML.substring(0,100),
sendButtonInfo: sendButton.outerHTML.substring(0,100) sendButtonInfo: sendButton.outerHTML.substring(0,100)
}); });
try { try {
this.lastSentMessage = text; 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')) { 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.focus();
inputElement.innerHTML = ''; // Clear existing content inputElement.innerHTML = ''; // Clear existing content
inputElement.textContent = text; // Set the new text content inputElement.textContent = text; // Set the new text content
inputElement.dispatchEvent(new Event('input', { bubbles: true, composed: true })); 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 } 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.value = text;
inputElement.dispatchEvent(new Event('input', { bubbles: true })); inputElement.dispatchEvent(new Event('input', { bubbles: true }));
inputElement.focus(); 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 await new Promise(resolve => setTimeout(resolve, 500)); // Preserved delay
@ -83,261 +94,355 @@ class GeminiProvider {
sendButton.classList.contains('disabled'); sendButton.classList.contains('disabled');
if (!isDisabled) { if (!isDisabled) {
console.log(`[${this.name}] Clicking send button.`); console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Clicking send button.`);
sendButton.click(); sendButton.click();
return true; return true;
} else { } 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; return false;
} }
} catch (error) { } catch (error) {
console.error(`[${this.name}] Error sending message:`, error); console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Error sending message:`, error);
return false; return false;
} }
} }
// Capture response from the chat interface (Original logic, logs updated for consistency) // Capture response from the chat interface (Original logic, logs updated for consistency)
captureResponse(element) { captureResponse(element) {
// This method is called when this.captureMethod === "dom"
// 'element' is expected to be the one matching this.responseSelector
if (!element) { 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: '' }; 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 responseText = "";
let foundResponse = false; let foundResponse = false;
try { try {
console.log(`[${this.name}] Looking for response in various elements...`); // Primarily rely on the textContent of the matched element
if (element.textContent) { if (element.textContent) {
console.log(`[${this.name}] Element has text content`);
responseText = element.textContent.trim(); 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 && if (responseText &&
responseText !== this.lastSentMessage && responseText !== this.lastSentMessage &&
!responseText.includes("Loading") && !responseText.toLowerCase().includes("loading") && // Case-insensitive for common words
!responseText.includes("Thinking") && !responseText.toLowerCase().includes("thinking") &&
!responseText.includes("You stopped this response")) { !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; foundResponse = true;
} else { } 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 { } 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...`); // Fallback: if textContent was empty but there are <p> tags inside the matched element.
if (!foundResponse && responseText === "" && element.querySelectorAll) {
const conversationContainers = document.querySelectorAll('.conversation-container'); const paragraphs = element.querySelectorAll('p');
if (conversationContainers && conversationContainers.length > 0) { if (paragraphs && paragraphs.length > 0) {
console.log(`[${this.name}] Found ${conversationContainers.length} conversation containers`); console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] textContent was empty, trying to combine ${paragraphs.length} <p> tags.`);
const lastContainer = conversationContainers[conversationContainers.length - 1]; let combinedPText = "";
console.log(`[${this.name}] Last container ID:`, lastContainer.id); paragraphs.forEach(p => {
combinedPText += p.textContent.trim() + "\n";
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";
}
}); });
if (combinedText.trim()) { responseText = combinedPText.trim();
responseText = combinedText.trim(); // Re-validate
console.log(`[${this.name}] Found response in paragraphs:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : "")); if (responseText &&
foundResponse = true; 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 { } 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) { } 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) { if (foundResponse && responseText) {
console.log(`[${this.name}] Cleaning up response text...`); responseText = responseText.replace(/\n{3,}/g, '\n\n').trim();
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 ? "..." : ""));
} }
return { return {
found: foundResponse && !!responseText.trim(), found: foundResponse && !!responseText.trim(),
text: responseText text: responseText
}; };
} }
// (NEW) Method for streaming API patterns // --- START: Methods for Debugger-based Response Capture ---
getStreamingApiPatterns() {
console.log(`[${this.name}] getStreamingApiPatterns called`); initiateResponseCapture(requestId, responseCallback) {
// TODO: DEVELOPER ACTION REQUIRED! console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] initiateResponseCapture called for requestId: ${requestId}. Current provider captureMethod: ${this.captureMethod}`);
// Use browser Network DevTools on gemini.google.com to identify the this.pendingResponseCallbacks.set(requestId, responseCallback);
// exact URL(s) that deliver the AI's streaming response when a prompt is sent. if (this.captureMethod === "debugger") {
// Replace the placeholder pattern below with the correct one(s). // Ensure accumulator is ready for this request
// Example: return [{ urlPattern: "*://gemini.google.com/api/generate*", requestStage: "Response" }]; if (!this.requestAccumulators.has(requestId)) {
return [ this.requestAccumulators.set(requestId, { text: "", isDefinitelyFinal: false });
{ urlPattern: "*://gemini.google.com/api/stream/generateContent*", requestStage: "Response" } // Placeholder - VERIFY THIS! }
]; 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() { async captureResponseDOMFallback() {
console.log(`[${this.name}] captureResponseDOMFallback called. Implement DOM observation logic here if needed as a fallback.`); 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. // TODO: Implement or verify existing DOM fallback logic for Gemini if it's to be kept.
@ -424,10 +529,11 @@ class GeminiProvider {
return null; return null;
} }
// Check if we should skip response monitoring (Original - UNCHANGED) // Check if we should skip response monitoring
shouldSkipResponseMonitoring() { shouldSkipResponseMonitoring() {
// We want to monitor for responses now that we've fixed the response capturing // If using debugger, we skip DOM-based monitoring.
return false; // console.log(`[${this.name}] shouldSkipResponseMonitoring called. Capture method: ${this.captureMethod}`);
return this.captureMethod === "debugger";
} }
} }