mirror of
https://github.com/BinaryBeastMaster/chat-relay.git
synced 2026-04-26 10:50:38 +00:00
Resolve merge conflicts: preserve local improvements while integrating PR changes
This commit is contained in:
parent
8536e06d56
commit
13d6e82732
12 changed files with 2438 additions and 2503 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,6 +15,7 @@ build/
|
|||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
!api-relay-server/relay.settings
|
||||
|
||||
# IDE and editor files
|
||||
.idea/
|
||||
|
|
|
|||
13
api-relay-server/package-lock.json
generated
13
api-relay-server/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"api-relay-server": "file:",
|
||||
"body-parser": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"ws": "^8.18.2"
|
||||
|
|
@ -430,6 +431,18 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@
|
|||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"ws": "^8.18.2"
|
||||
"ws": "^8.18.2",
|
||||
"dotenv": "^16.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/body-parser": "^1.19.5",
|
||||
|
|
|
|||
|
|
@ -1,127 +1,248 @@
|
|||
<!--
|
||||
Chat Relay: Relay for AI Chat Interfaces
|
||||
Copyright (C) 2025 Jamison Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat Relay Admin</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
color: #333;
|
||||
background-color: #f0f2f5; /* Lighter gray */
|
||||
color: #1c1e21; /* Darker text for better contrast */
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #333;
|
||||
background-color: #1877f2; /* Facebook blue */
|
||||
color: #fff;
|
||||
padding: 1em;
|
||||
padding: 1em 1.5em;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
nav {
|
||||
background-color: #444;
|
||||
padding: 0.5em;
|
||||
background-color: #fff; /* White background for nav */
|
||||
padding: 0.75em 1.5em;
|
||||
border-bottom: 1px solid #dddfe2; /* Light border */
|
||||
box-shadow: 0 2px 2px -2px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline;
|
||||
margin-right: 20px;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
color: #fff;
|
||||
color: #1877f2; /* Blue links */
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-weight: 600; /* Slightly bolder */
|
||||
padding: 0.5em 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
nav ul li a:hover {
|
||||
color: #1159bd; /* Darker blue on hover */
|
||||
}
|
||||
|
||||
nav ul li a.active {
|
||||
text-decoration: underline;
|
||||
color: #1c1e21; /* Darker color for active tab */
|
||||
border-bottom: 3px solid #1877f2;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1em;
|
||||
padding: 1.5em;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
h2 {
|
||||
border-bottom: 2px solid #333;
|
||||
border-bottom: 2px solid #dddfe2;
|
||||
padding-bottom: 0.5em;
|
||||
color: #1c1e21;
|
||||
font-size: 1.5em;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1em;
|
||||
margin-top: 1.5em;
|
||||
table-layout: fixed; /* Important for controlling column widths */
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
border-radius: 6px;
|
||||
overflow: hidden; /* Ensures border-radius is respected by children */
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
th, td {
|
||||
border: 1px solid #dddfe2;
|
||||
padding: 12px 10px; /* Increased padding */
|
||||
text-align: left;
|
||||
vertical-align: top; /* Align content to the top */
|
||||
word-wrap: break-word; /* Prevent long words from breaking layout */
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #555;
|
||||
color: white;
|
||||
background-color: #f5f6f7; /* Lighter header background */
|
||||
color: #4b4f56; /* Darker gray for header text */
|
||||
font-weight: 600;
|
||||
position: relative; /* For positioning collapse buttons */
|
||||
}
|
||||
/* Column width styling */
|
||||
th.col-timestamp, td.col-timestamp { width: 12%; } /* Start/End Timestamp */
|
||||
th.col-request-id, td.col-request-id { width: 8%; } /* Request ID */
|
||||
th.col-status, td.col-status { width: 8%; } /* Status */
|
||||
/* The remaining 4 data columns will share the rest of the space.
|
||||
(100 - 12 - 12 - 8 - 8) = 60%. So each gets 15% */
|
||||
th.col-data, td.col-data { width: 15%; }
|
||||
|
||||
td pre { /* Style for the <pre> tags inside cells */
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px; /* Keep existing max-height */
|
||||
overflow-y: auto;
|
||||
background-color: #f9f9f9; /* Slight background for pre blocks */
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.log-window {
|
||||
background-color: #222;
|
||||
color: #0f0;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
padding: 10px;
|
||||
height: 200px;
|
||||
background-color: #1e1e1e; /* Darker background for log */
|
||||
color: #d4d4d4; /* Lighter text for log */
|
||||
font-family: 'Consolas', 'Courier New', Courier, monospace;
|
||||
padding: 15px;
|
||||
height: 250px; /* Slightly taller */
|
||||
overflow-y: scroll;
|
||||
border: 1px solid #444;
|
||||
margin-top: 1em;
|
||||
border: 1px solid #333;
|
||||
margin-top: 1.5em;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
white-space: pre-wrap;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.collapsible-header {
|
||||
background-color: #555;
|
||||
background-color: #6c757d; /* Bootstrap secondary color */
|
||||
color: white;
|
||||
padding: 0.5em;
|
||||
padding: 0.75em;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
margin-top: 1em;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.collapsible-header:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
button {
|
||||
padding: 8px 15px;
|
||||
font-size: 0.9em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
#refresh-messages-btn {
|
||||
background-color: #007bff; /* Primary blue */
|
||||
color: white;
|
||||
margin-left: 10px;
|
||||
}
|
||||
#refresh-messages-btn:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
#save-settings-btn {
|
||||
background-color: #28a745; /* Green for save */
|
||||
color: white;
|
||||
}
|
||||
#save-settings-btn:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
#restart-server-btn {
|
||||
background-color: #dc3545; /* Red for restart/danger */
|
||||
color: white;
|
||||
}
|
||||
#restart-server-btn:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
input[type="text"], input[type="number"], select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
label {
|
||||
font-weight: 500;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.settings-item, .status-item {
|
||||
margin-bottom: 1em;
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dddfe2;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#update-status-msg {
|
||||
margin-left: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
.collapse-btn {
|
||||
font-size: 0.7em;
|
||||
padding: 2px 5px;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
display: inline-block; /* To sit next to header text */
|
||||
vertical-align: middle;
|
||||
}
|
||||
.collapse-btn:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
.collapsed-cell {
|
||||
/* Could add specific styling for collapsed cells if needed, e.g., a placeholder */
|
||||
/* For now, they will just be hidden by JavaScript */
|
||||
}
|
||||
.header-collapsed {
|
||||
width: 1px !important; /* Minimal width to keep column in layout but visually gone */
|
||||
min-width: 1px !important; /* Ensure it doesn't expand due to content */
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
border-left-width: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
overflow: hidden !important; /* Hide content that might overflow 1px */
|
||||
color: transparent !important; /* Hide text by making it transparent */
|
||||
font-size: 0 !important; /* Another way to hide text, affects children */
|
||||
}
|
||||
.header-collapsed .collapse-btn { /* Ensure button is also hidden if not already by font-size:0 */
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<h1>Chat Relay Admin Dashboard</h1>
|
||||
|
|
@ -130,24 +251,30 @@
|
|||
<ul>
|
||||
<li><a href="#" class="tab-link active" data-tab="messages">Messages</a></li>
|
||||
<li><a href="#" class="tab-link" data-tab="settings">Settings</a></li>
|
||||
<li><a href="#" class="tab-link" data-tab="status">Status</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<div id="messages" class="tab-content active">
|
||||
<h2>Message History <button id="refresh-messages-btn" style="font-size: 0.8em; margin-left: 10px;">Refresh
|
||||
Messages</button></h2>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;">
|
||||
<h2>Message History</h2>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<span style="margin-right: 15px;">Server Uptime: <span id="status-uptime">N/A</span></span>
|
||||
<span style="margin-right: 15px;">Connected Extensions: <span id="status-connected-extensions">0</span></span>
|
||||
<button id="refresh-messages-btn" style="font-size: 0.8em; padding: 0.5em 1em; margin-right: 10px;">Refresh Messages</button>
|
||||
<button id="restart-server-btn" style="font-size: 0.8em; padding: 0.5em 1em; background-color: #d9534f; color: white; border: none; cursor: pointer;">Restart Server</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Start Timestamp</th>
|
||||
<th>End Timestamp</th>
|
||||
<th>Request ID</th>
|
||||
<th>From Client (Cline)</th>
|
||||
<th>To Extension</th>
|
||||
<th>From Extension</th>
|
||||
<th>To Client (Cline)</th>
|
||||
<th>Status</th>
|
||||
<th class="col-timestamp">Start Timestamp</th>
|
||||
<th class="col-timestamp">End Timestamp</th>
|
||||
<th class="col-request-id">Request ID</th>
|
||||
<th class="col-data">From Client <span class="collapse-btn" data-column="fromClient" title="Toggle Column">-</span></th>
|
||||
<th class="col-data">To Extension <span class="collapse-btn" data-column="toExtension" title="Toggle Column">-</span></th>
|
||||
<th class="col-data">From Extension <span class="collapse-btn" data-column="fromExtension" title="Toggle Column">-</span></th>
|
||||
<th class="col-data">To Client <span class="collapse-btn" data-column="toClient" title="Toggle Column">-</span></th>
|
||||
<th class="col-status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="message-history-body">
|
||||
|
|
@ -170,8 +297,7 @@
|
|||
<div style="margin-top: 0.5em;">
|
||||
<label>New Request Behavior (if extension busy):</label>
|
||||
<div>
|
||||
<input type="radio" id="newRequestBehaviorQueue" name="newRequestBehavior" value="queue"
|
||||
checked>
|
||||
<input type="radio" id="newRequestBehaviorQueue" name="newRequestBehavior" value="queue" checked>
|
||||
<label for="newRequestBehaviorQueue">Queue</label>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -179,25 +305,12 @@
|
|||
<label for="newRequestBehaviorDrop">Drop</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 0.5em;">
|
||||
<label for="auto-kill-port-input">Auto-kill conflicting port 3003 process on startup: </label>
|
||||
<input type="checkbox" id="auto-kill-port-input">
|
||||
</div>
|
||||
<button id="save-settings-btn" style="margin-top: 1em; margin-bottom: 0.5em;">Save Settings</button>
|
||||
<span id="update-status-msg" style="margin-left: 10px; font-style: italic;"></span>
|
||||
<p style="margin-top: 1em;">Ping Interval (ms): <span id="setting-ping-interval"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status" class="tab-content">
|
||||
<h2>Server Status</h2>
|
||||
<div id="status-content">
|
||||
<p>Server Uptime: <span id="status-uptime">N/A</span></p>
|
||||
<p>Connected Extensions: <span id="status-connected-extensions">0</span></p>
|
||||
<button id="restart-server-btn"
|
||||
style="margin-top: 1em; padding: 0.5em 1em; background-color: #d9534f; color: white; border: none; cursor: pointer;">Restart
|
||||
Server</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status tab content removed as its elements are moved to the Messages tab -->
|
||||
</div>
|
||||
<div class="collapsible-header" onclick="toggleLogWindow()">Server Logs (click to toggle)</div>
|
||||
<div class="log-window" id="log-window-content" style="display: none;">
|
||||
|
|
@ -215,8 +328,13 @@
|
|||
link.classList.add('active');
|
||||
const activeTabContent = document.getElementById(link.dataset.tab);
|
||||
activeTabContent.classList.add('active');
|
||||
// If settings or status tab is activated, refresh their content
|
||||
if (link.dataset.tab === 'settings' || link.dataset.tab === 'status') {
|
||||
|
||||
// If settings tab is activated, refresh its content
|
||||
if (link.dataset.tab === 'settings') {
|
||||
fetchAndDisplayServerInfo(); // This function now also updates status on the messages tab
|
||||
}
|
||||
// Always ensure server info (which includes status) is fresh when messages tab is active
|
||||
if (link.dataset.tab === 'messages') {
|
||||
fetchAndDisplayServerInfo();
|
||||
}
|
||||
});
|
||||
|
|
@ -231,21 +349,23 @@
|
|||
}
|
||||
const messageHistoryBody = document.getElementById('message-history-body');
|
||||
const refreshButton = document.getElementById('refresh-messages-btn');
|
||||
|
||||
function createPreCell(data) {
|
||||
const cell = document.createElement('td');
|
||||
if (data === undefined || data === null) {
|
||||
cell.textContent = 'N/A';
|
||||
} else {
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.margin = '0';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.style.maxHeight = '200px'; // Added max height
|
||||
pre.style.overflowY = 'auto'; // Added scrollability
|
||||
// pre.style.margin = '0'; // Handled by td pre CSS
|
||||
// pre.style.whiteSpace = 'pre-wrap'; // Handled by td pre CSS
|
||||
// pre.style.maxHeight = '200px'; // Handled by td pre CSS
|
||||
// pre.style.overflowY = 'auto'; // Handled by td pre CSS
|
||||
pre.textContent = JSON.stringify(data, null, 2);
|
||||
cell.appendChild(pre);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
async function fetchAndDisplayMessageHistory() {
|
||||
try {
|
||||
const response = await fetch('/v1/admin/message-history');
|
||||
|
|
@ -253,7 +373,9 @@
|
|||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const messages = await response.json();
|
||||
|
||||
messageHistoryBody.innerHTML = ''; // Clear existing rows
|
||||
|
||||
if (messages.length === 0) {
|
||||
const row = messageHistoryBody.insertRow();
|
||||
const cell = row.insertCell();
|
||||
|
|
@ -262,6 +384,7 @@
|
|||
cell.style.textAlign = 'center';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group messages by requestId
|
||||
const groupedMessages = messages.reduce((acc, logEntry) => {
|
||||
const id = logEntry.requestId;
|
||||
|
|
@ -277,6 +400,7 @@
|
|||
status: "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
// Update fields based on log type
|
||||
switch (logEntry.type) {
|
||||
case 'CHAT_REQUEST_RECEIVED':
|
||||
|
|
@ -304,29 +428,35 @@
|
|||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Convert grouped messages object to an array and sort by timestamp (most recent first)
|
||||
const consolidatedMessages = Object.values(groupedMessages).sort((a, b) => {
|
||||
// Sort by startTimestamp, most recent first
|
||||
return new Date(b.startTimestamp) - new Date(a.startTimestamp);
|
||||
});
|
||||
|
||||
if (consolidatedMessages.length === 0) {
|
||||
const row = messageHistoryBody.insertRow();
|
||||
const row = messageHistoryBody.insertRow();
|
||||
const cell = row.insertCell();
|
||||
cell.colSpan = 8; // Adjusted for new column
|
||||
cell.textContent = 'No consolidated message history to display.';
|
||||
cell.style.textAlign = 'center';
|
||||
return;
|
||||
}
|
||||
|
||||
consolidatedMessages.forEach(msg => {
|
||||
const row = messageHistoryBody.insertRow();
|
||||
row.insertCell().textContent = new Date(msg.startTimestamp).toLocaleString();
|
||||
row.insertCell().textContent = msg.endTimestamp ? new Date(msg.endTimestamp).toLocaleString() : (msg.status === "Request In Progress" ? "In Progress" : "N/A");
|
||||
row.insertCell().textContent = msg.requestId;
|
||||
row.appendChild(createPreCell(msg.fromClient));
|
||||
row.appendChild(createPreCell(msg.toExtension));
|
||||
row.appendChild(createPreCell(msg.fromExtension));
|
||||
row.appendChild(createPreCell(msg.toClient));
|
||||
row.insertCell().textContent = msg.status;
|
||||
let cell;
|
||||
cell = row.insertCell(); cell.textContent = new Date(msg.startTimestamp).toLocaleString(); cell.classList.add('col-timestamp');
|
||||
cell = row.insertCell(); cell.textContent = msg.endTimestamp ? new Date(msg.endTimestamp).toLocaleString() : (msg.status === "Request In Progress" ? "In Progress" : "N/A"); cell.classList.add('col-timestamp');
|
||||
cell = row.insertCell(); cell.textContent = msg.requestId; cell.classList.add('col-request-id');
|
||||
|
||||
cell = createPreCell(msg.fromClient); cell.classList.add('col-data', 'cell-fromClient'); row.appendChild(cell);
|
||||
cell = createPreCell(msg.toExtension); cell.classList.add('col-data', 'cell-toExtension'); row.appendChild(cell);
|
||||
cell = createPreCell(msg.fromExtension); cell.classList.add('col-data', 'cell-fromExtension'); row.appendChild(cell);
|
||||
cell = createPreCell(msg.toClient); cell.classList.add('col-data', 'cell-toClient'); row.appendChild(cell);
|
||||
|
||||
cell = row.insertCell(); cell.textContent = msg.status; cell.classList.add('col-status');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching message history:', error);
|
||||
|
|
@ -339,16 +469,19 @@
|
|||
cell.style.textAlign = 'center';
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener('click', fetchAndDisplayMessageHistory);
|
||||
}
|
||||
|
||||
fetchAndDisplayMessageHistory(); // Initial load for messages
|
||||
fetchAndDisplayServerInfo(); // Initial load for server info (status) on the messages tab
|
||||
|
||||
// Elements for settings and status
|
||||
const portInputEl = document.getElementById('port-input'); // Corrected ID
|
||||
const requestTimeoutInputEl = document.getElementById('request-timeout-input');
|
||||
const newRequestBehaviorQueueEl = document.getElementById('newRequestBehaviorQueue');
|
||||
const newRequestBehaviorDropEl = document.getElementById('newRequestBehaviorDrop');
|
||||
const autoKillPortInputEl = document.getElementById('auto-kill-port-input');
|
||||
const saveSettingsBtn = document.getElementById('save-settings-btn');
|
||||
const updateStatusMsgEl = document.getElementById('update-status-msg');
|
||||
const settingPingIntervalEl = document.getElementById('setting-ping-interval');
|
||||
|
|
@ -356,6 +489,7 @@
|
|||
const statusUptimeEl = document.getElementById('status-uptime');
|
||||
const statusConnectedExtensionsEl = document.getElementById('status-connected-extensions');
|
||||
const restartServerBtn = document.getElementById('restart-server-btn');
|
||||
|
||||
function formatUptime(totalSeconds) {
|
||||
if (totalSeconds === null || totalSeconds === undefined) return 'N/A';
|
||||
const days = Math.floor(totalSeconds / (3600 * 24));
|
||||
|
|
@ -364,6 +498,7 @@
|
|||
totalSeconds %= 3600;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
let uptimeString = '';
|
||||
if (days > 0) uptimeString += `${days}d `;
|
||||
if (hours > 0) uptimeString += `${hours}h `;
|
||||
|
|
@ -371,83 +506,123 @@
|
|||
uptimeString += `${seconds}s`;
|
||||
return uptimeString.trim() || '0s';
|
||||
}
|
||||
|
||||
async function fetchAndDisplayServerInfo() {
|
||||
try {
|
||||
const response = await fetch('/v1/admin/server-info');
|
||||
// UPDATED: Fetch from the new consolidated settings endpoint
|
||||
const response = await fetch('/admin/settings');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const serverInfo = await response.json();
|
||||
// Populate Settings
|
||||
if (portInputEl) portInputEl.value = serverInfo.port || ''; // Use portInputEl
|
||||
if (requestTimeoutInputEl) requestTimeoutInputEl.value = serverInfo.requestTimeoutMs !== null ? serverInfo.requestTimeoutMs : '';
|
||||
if (settingPingIntervalEl) settingPingIntervalEl.textContent = serverInfo.pingIntervalMs !== null ? `${serverInfo.pingIntervalMs} ms` : 'N/A (Not Implemented)';
|
||||
if (serverInfo.newRequestBehavior === 'drop') {
|
||||
if (newRequestBehaviorDropEl) newRequestBehaviorDropEl.checked = true;
|
||||
const currentSettings = await response.json();
|
||||
|
||||
// Populate Settings using the new response structure
|
||||
if(portInputEl) portInputEl.value = currentSettings.serverPort || '';
|
||||
if(requestTimeoutInputEl) requestTimeoutInputEl.value = currentSettings.requestTimeout !== null ? currentSettings.requestTimeout : '';
|
||||
// Ping interval is not part of /admin/settings, assuming it's static or handled elsewhere if needed
|
||||
// if(settingPingIntervalEl) settingPingIntervalEl.textContent = currentSettings.pingIntervalMs !== null ? `${currentSettings.pingIntervalMs} ms` : 'N/A (Not Implemented)';
|
||||
|
||||
if (currentSettings.messageSendStrategy === 'drop') {
|
||||
if(newRequestBehaviorDropEl) newRequestBehaviorDropEl.checked = true;
|
||||
} else {
|
||||
if (newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default to queue
|
||||
if(newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default to queue
|
||||
}
|
||||
if (autoKillPortInputEl) autoKillPortInputEl.checked = serverInfo.autoKillPort || false;
|
||||
// Populate Status
|
||||
if (statusUptimeEl) statusUptimeEl.textContent = formatUptime(serverInfo.uptimeSeconds);
|
||||
if (statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = serverInfo.connectedExtensionsCount !== null ? serverInfo.connectedExtensionsCount : 'N/A';
|
||||
} catch (error) {
|
||||
console.error('Error fetching server info:', error);
|
||||
if (portInputEl) portInputEl.value = 'Error';
|
||||
if (requestTimeoutInputEl) requestTimeoutInputEl.value = 'Error';
|
||||
if (settingPingIntervalEl) settingPingIntervalEl.textContent = 'Error';
|
||||
if (newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default on error
|
||||
if (autoKillPortInputEl) autoKillPortInputEl.checked = false; // Default on error
|
||||
// Ensure error handling for status elements
|
||||
if (statusUptimeEl) statusUptimeEl.textContent = 'Error loading uptime';
|
||||
if (statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = 'Error loading connections';
|
||||
|
||||
// Populate Status - Assuming server-info might still be used for uptime/connections or these are separate
|
||||
// For now, focusing on settings. If status also needs to come from /admin/settings, that's another change.
|
||||
// Let's assume uptime and connectedExtensionsCount are fetched separately or are less critical for this immediate fix.
|
||||
// For simplicity, I'll comment out the status population from this specific fetch if it was tied to the old server-info structure.
|
||||
// If your /admin/settings also returns uptime and connectedExtensionsCount, uncomment and adjust field names.
|
||||
/*
|
||||
if(statusUptimeEl) statusUptimeEl.textContent = formatUptime(currentSettings.uptimeSeconds);
|
||||
if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = currentSettings.connectedExtensionsCount !== null ? currentSettings.connectedExtensionsCount : 'N/A';
|
||||
*/
|
||||
// If you still have a /v1/admin/server-info for uptime and connections, that part can remain as is.
|
||||
// This function will now primarily handle settings population.
|
||||
// To keep status functional if it's from a different endpoint, we might need to separate concerns
|
||||
// or ensure /admin/settings returns everything. For now, focusing on fixing settings load.
|
||||
|
||||
// Fetch separate status info if needed (example if /v1/admin/server-info is still used for status)
|
||||
const statusResponse = await fetch('/v1/admin/server-info');
|
||||
if (statusResponse.ok) {
|
||||
const serverInfoForStatus = await statusResponse.json();
|
||||
if(statusUptimeEl) statusUptimeEl.textContent = formatUptime(serverInfoForStatus.uptimeSeconds);
|
||||
if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = serverInfoForStatus.connectedExtensionsCount !== null ? serverInfoForStatus.connectedExtensionsCount : 'N/A';
|
||||
if(settingPingIntervalEl && serverInfoForStatus.pingIntervalMs !== undefined) settingPingIntervalEl.textContent = serverInfoForStatus.pingIntervalMs !== null ? `${serverInfoForStatus.pingIntervalMs} ms` : 'N/A (Not Implemented)';
|
||||
} else {
|
||||
// Handle case where statusResponse itself is not ok
|
||||
console.warn(`Failed to fetch status from /v1/admin/server-info: ${statusResponse.status}`);
|
||||
if(statusUptimeEl) statusUptimeEl.textContent = 'Status N/A';
|
||||
if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = 'Status N/A';
|
||||
if(settingPingIntervalEl) settingPingIntervalEl.textContent = 'Status N/A';
|
||||
}
|
||||
} catch (error) { // This is the single catch block for the try starting at line 310
|
||||
console.error('Error in fetchAndDisplayServerInfo:', error);
|
||||
// Set all relevant fields to an error state
|
||||
if(portInputEl) portInputEl.value = 'Error';
|
||||
if(requestTimeoutInputEl) requestTimeoutInputEl.value = 'Error';
|
||||
if(settingPingIntervalEl) settingPingIntervalEl.textContent = 'Error';
|
||||
if(newRequestBehaviorQueueEl) newRequestBehaviorQueueEl.checked = true; // Default on error
|
||||
if(statusUptimeEl) statusUptimeEl.textContent = 'Error loading';
|
||||
if(statusConnectedExtensionsEl) statusConnectedExtensionsEl.textContent = 'Error loading';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSettings() {
|
||||
if (!requestTimeoutInputEl || !portInputEl || !updateStatusMsgEl || !newRequestBehaviorQueueEl || !newRequestBehaviorDropEl || !autoKillPortInputEl) return;
|
||||
if (!requestTimeoutInputEl || !portInputEl || !updateStatusMsgEl || !newRequestBehaviorQueueEl || !newRequestBehaviorDropEl) return;
|
||||
|
||||
const newTimeout = parseInt(requestTimeoutInputEl.value, 10);
|
||||
const newPort = parseInt(portInputEl.value, 10);
|
||||
const selectedNewRequestBehavior = newRequestBehaviorQueueEl.checked ? 'queue' : 'drop';
|
||||
const autoKillPort = autoKillPortInputEl.checked;
|
||||
let settingsToUpdate = {};
|
||||
let validationError = false;
|
||||
let messages = [];
|
||||
|
||||
if (requestTimeoutInputEl.value.trim() !== '') { // Only process if there's input
|
||||
if (!isNaN(newTimeout) && newTimeout > 0) {
|
||||
settingsToUpdate.requestTimeoutMs = newTimeout;
|
||||
// UPDATED: Key name for server
|
||||
settingsToUpdate.requestTimeout = newTimeout;
|
||||
} else {
|
||||
messages.push('Invalid timeout: Must be a positive number.');
|
||||
validationError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (portInputEl.value.trim() !== '') { // Only process if there's input
|
||||
if (!isNaN(newPort) && newPort > 0 && newPort <= 65535) {
|
||||
settingsToUpdate.port = newPort;
|
||||
if (!isNaN(newPort) && newPort > 0 && newPort <= 65535) {
|
||||
// UPDATED: Key name for server
|
||||
settingsToUpdate.serverPort = newPort;
|
||||
} else {
|
||||
messages.push('Invalid port: Must be between 1 and 65535.');
|
||||
validationError = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Always include newRequestBehavior as it's controlled by radio buttons
|
||||
// No specific validation needed here as it's either 'queue' or 'drop'
|
||||
settingsToUpdate.newRequestBehavior = selectedNewRequestBehavior;
|
||||
settingsToUpdate.autoKillPort = autoKillPort; // Add the new setting
|
||||
// UPDATED: Key name for server
|
||||
settingsToUpdate.messageSendStrategy = selectedNewRequestBehavior;
|
||||
|
||||
if (validationError) {
|
||||
updateStatusMsgEl.textContent = messages.join(' ');
|
||||
updateStatusMsgEl.style.color = 'red';
|
||||
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, 7000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(settingsToUpdate).length === 0) {
|
||||
updateStatusMsgEl.textContent = 'No changes to save.';
|
||||
updateStatusMsgEl.style.color = 'blue';
|
||||
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusMsgEl.textContent = 'Saving settings...';
|
||||
updateStatusMsgEl.style.color = 'orange';
|
||||
|
||||
try {
|
||||
const response = await fetch('/v1/admin/update-settings', {
|
||||
// UPDATED: Endpoint for saving settings
|
||||
const response = await fetch('/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settingsToUpdate)
|
||||
|
|
@ -471,9 +646,11 @@
|
|||
const clearTime = updateStatusMsgEl.textContent.toLowerCase().includes('restart') ? 15000 : 7000;
|
||||
setTimeout(() => { updateStatusMsgEl.textContent = ''; }, clearTime);
|
||||
}
|
||||
|
||||
if (saveSettingsBtn) {
|
||||
saveSettingsBtn.addEventListener('click', handleSaveSettings);
|
||||
}
|
||||
|
||||
async function handleRestartServer() {
|
||||
if (confirm('Are you sure you want to restart the server?')) {
|
||||
try {
|
||||
|
|
@ -486,13 +663,61 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (restartServerBtn) {
|
||||
restartServerBtn.addEventListener('click', handleRestartServer);
|
||||
}
|
||||
// Initial load for settings and status
|
||||
fetchAndDisplayServerInfo();
|
||||
console.log("Admin UI initialized. Message history, settings, status, and restart functionality implemented.");
|
||||
|
||||
// Initial load for settings is handled by tab click or initial messages tab load.
|
||||
// fetchAndDisplayServerInfo(); // This is called when messages tab is active or settings tab is clicked.
|
||||
|
||||
// Collapsible column logic
|
||||
const columnStates = { // To store the collapsed state
|
||||
fromClient: false,
|
||||
toExtension: false,
|
||||
fromExtension: false,
|
||||
toClient: false
|
||||
};
|
||||
|
||||
function toggleColumn(columnKey) {
|
||||
columnStates[columnKey] = !columnStates[columnKey];
|
||||
const isCollapsed = columnStates[columnKey];
|
||||
|
||||
// Update button text/indicator
|
||||
const button = document.querySelector(`.collapse-btn[data-column="${columnKey}"]`);
|
||||
if (button) {
|
||||
button.textContent = isCollapsed ? '+' : '-';
|
||||
button.title = isCollapsed ? 'Expand Column' : 'Collapse Column';
|
||||
}
|
||||
|
||||
// Toggle header visibility (optional, if you want to hide header text too)
|
||||
// const headerCell = document.querySelector(`th.col-data[data-column-key="${columnKey}"]`); // Need to add data-column-key to th if this is desired
|
||||
|
||||
// Toggle data cell visibility (these are the <td> elements)
|
||||
const cellsToToggle = document.querySelectorAll(`#message-history-body td.cell-${columnKey}`);
|
||||
cellsToToggle.forEach(cell => {
|
||||
cell.style.display = isCollapsed ? 'none' : '';
|
||||
});
|
||||
|
||||
// Special handling for the header cell (<th>) due to table-layout: fixed
|
||||
const headerTh = document.querySelector(`th[data-column="${columnKey}"]`);
|
||||
if (headerTh) {
|
||||
if (isCollapsed) {
|
||||
headerTh.classList.add('header-collapsed');
|
||||
} else {
|
||||
headerTh.classList.remove('header-collapsed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.collapse-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const columnKey = this.dataset.column;
|
||||
toggleColumn(columnKey);
|
||||
});
|
||||
});
|
||||
|
||||
console.log("Admin UI initialized. Message history, settings, status, restart, and collapsible columns functionality implemented.");
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -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');
|
||||
|
|
@ -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
|
|
@ -8,7 +8,8 @@
|
|||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true // Add this line
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -294,20 +294,30 @@ class ClaudeProvider {
|
|||
}
|
||||
}
|
||||
|
||||
handleDebuggerData(requestId, rawData, isFinalFromBackground) {
|
||||
handleDebuggerData(requestId, rawData, isFinalFromBackground, errorFromBackground = null) {
|
||||
// !!!!! VERY IMPORTANT ENTRY LOG !!!!!
|
||||
console.log('[[ClaudeProvider]] handleDebuggerData ENTERED. RequestId: ' + requestId + ', isFinalFromBackground: ' + isFinalFromBackground + ', RawData Length: ' + (rawData ? rawData.length : 'null'));
|
||||
console.log('[[ClaudeProvider]] handleDebuggerData ENTERED. RequestId: ' + requestId + ', isFinalFromBackground: ' + isFinalFromBackground + ', RawData Length: ' + (rawData ? rawData.length : 'null') + ', ErrorFromBG: ' + errorFromBackground);
|
||||
|
||||
const callback = this.pendingResponseCallbacks.get(requestId);
|
||||
|
||||
if (!callback) {
|
||||
console.warn('[' + this.name + '] No pending callback for requestId: ' + requestId + '. Ignoring.');
|
||||
console.warn('[' + this.name + '] No pending callback for requestId: ' + requestId + '. Ignoring debugger data/error.');
|
||||
if (this.requestBuffers.has(requestId)) {
|
||||
this.requestBuffers.delete(requestId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorFromBackground) {
|
||||
console.warn(`[${this.name}] handleDebuggerData: Propagating error for requestId ${requestId}: ${errorFromBackground}`);
|
||||
callback(requestId, `[Provider Error from Background] ${errorFromBackground}`, true); // Pass error as text, mark as final
|
||||
this.pendingResponseCallbacks.delete(requestId);
|
||||
if (this.requestBuffers.has(requestId)) {
|
||||
this.requestBuffers.delete(requestId); // Clean up buffer too
|
||||
}
|
||||
return; // Stop further processing
|
||||
}
|
||||
|
||||
if (!this.requestBuffers.has(requestId)) {
|
||||
this.requestBuffers.set(requestId, { accumulatedText: "" });
|
||||
}
|
||||
|
|
@ -510,7 +520,7 @@ class ClaudeProvider {
|
|||
console.log(`[${this.name}] getStreamingApiPatterns called. Capture method: ${this.captureMethod}`);
|
||||
if (this.captureMethod === "debugger" && this.debuggerUrlPattern) {
|
||||
console.log(`[${this.name}] Using debugger URL pattern: ${this.debuggerUrlPattern}`);
|
||||
return [{ urlPattern: this.debuggerUrlPattern, requestStage: "Response" }];
|
||||
return [{ urlPattern: this.debuggerUrlPattern }];
|
||||
}
|
||||
console.log(`[${this.name}] No debugger patterns to return (captureMethod is not 'debugger' or no pattern set).`);
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -22,58 +22,69 @@ class GeminiProvider {
|
|||
this.name = 'GeminiProvider'; // Updated
|
||||
this.supportedDomains = ['gemini.google.com'];
|
||||
|
||||
// --- START OF CONFIGURABLE PROPERTIES (similar to other providers) ---
|
||||
this.captureMethod = "dom"; // Switched to DOM capture by default
|
||||
// TODO: DEVELOPER ACTION REQUIRED! Verify this URL pattern with Gemini's actual network requests.
|
||||
this.debuggerUrlPattern = "*StreamGenerate*"; // Kept for potential future switch
|
||||
this.includeThinkingInMessage = false; // Gemini likely doesn't have a separate "thinking" stream like some others.
|
||||
// --- END OF CONFIGURABLE PROPERTIES ---
|
||||
|
||||
// Selectors for the Gemini interface
|
||||
this.inputSelector = 'div.ql-editor, div[contenteditable="true"], textarea[placeholder="Enter a prompt here"], textarea.message-input, textarea.input-area';
|
||||
this.sendButtonSelector = 'button[aria-label="Send message"], button.send-button, button.send-message-button';
|
||||
|
||||
// Response selector - updated to match the actual elements
|
||||
this.responseSelector = 'model-response, message-content, .model-response-text, .markdown-main-panel, .model-response, div[id^="model-response-message"]';
|
||||
// Thinking indicator selector
|
||||
// Response selector - updated to the specific div for DOM capture
|
||||
this.responseSelector = 'div.markdown.markdown-main-panel[id^="model-response-message-content"]';
|
||||
// Thinking indicator selector - kept as is, assuming these are still relevant
|
||||
this.thinkingIndicatorSelector = '.thinking-indicator, .loading-indicator, .typing-indicator, .response-loading, .blue-circle, .stop-icon';
|
||||
|
||||
// Fallback selectors (NEW)
|
||||
this.responseSelectorForDOMFallback = 'model-response, message-content, .model-response-text, .markdown-main-panel'; // Placeholder
|
||||
this.thinkingIndicatorSelectorForDOM = '.thinking-indicator, .loading-indicator, .blue-circle, .stop-icon'; // Placeholder
|
||||
// Fallback selectors are less relevant now that DOM is primary but kept for completeness
|
||||
this.responseSelectorForDOMFallback = 'model-response, message-content, .model-response-text, .markdown-main-panel';
|
||||
this.thinkingIndicatorSelectorForDOM = '.thinking-indicator, .loading-indicator, .blue-circle, .stop-icon';
|
||||
|
||||
// Last sent message to avoid capturing it as a response
|
||||
this.lastSentMessage = '';
|
||||
// Flag to prevent double-sending - IMPORTANT: This must be false by default
|
||||
this.hasSentMessage = false;
|
||||
|
||||
// For debugger-based response capture
|
||||
this.pendingResponseCallbacks = new Map();
|
||||
this.requestAccumulators = new Map(); // To accumulate response chunks for a given request
|
||||
}
|
||||
|
||||
// Send a message to the chat interface (MODIFIED)
|
||||
async sendChatMessage(text) {
|
||||
console.log(`[${this.name}] sendChatMessage called with:`, text);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] sendChatMessage called with:`, text);
|
||||
const inputElement = document.querySelector(this.inputSelector);
|
||||
const sendButton = document.querySelector(this.sendButtonSelector);
|
||||
|
||||
if (!inputElement || !sendButton) {
|
||||
console.error(`[${this.name}] Missing input field (${this.inputSelector}) or send button (${this.sendButtonSelector})`);
|
||||
console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Missing input field (${this.inputSelector}) or send button (${this.sendButtonSelector})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[${this.name}] Attempting to send message with:`, {
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Attempting to send message with:`, {
|
||||
inputFieldInfo: inputElement.outerHTML.substring(0,100),
|
||||
sendButtonInfo: sendButton.outerHTML.substring(0,100)
|
||||
});
|
||||
|
||||
try {
|
||||
this.lastSentMessage = text;
|
||||
console.log(`[${this.name}] Stored last sent message:`, this.lastSentMessage);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Stored last sent message:`, this.lastSentMessage);
|
||||
|
||||
if (inputElement.tagName.toLowerCase() === 'div' && (inputElement.contentEditable === 'true' || inputElement.getAttribute('contenteditable') === 'true')) {
|
||||
console.log(`[${this.name}] Input field is a contentEditable div.`);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Input field is a contentEditable div.`);
|
||||
inputElement.focus();
|
||||
inputElement.innerHTML = ''; // Clear existing content
|
||||
inputElement.textContent = text; // Set the new text content
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
console.log(`[${this.name}] Set text content and dispatched input event for contentEditable div.`);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Set text content and dispatched input event for contentEditable div.`);
|
||||
} else { // Standard input or textarea
|
||||
console.log(`[${this.name}] Input field is textarea/input.`);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Input field is textarea/input.`);
|
||||
inputElement.value = text;
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
inputElement.focus();
|
||||
console.log(`[${this.name}] Set value and dispatched input event for textarea/input.`);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Set value and dispatched input event for textarea/input.`);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // Preserved delay
|
||||
|
|
@ -83,261 +94,355 @@ class GeminiProvider {
|
|||
sendButton.classList.contains('disabled');
|
||||
|
||||
if (!isDisabled) {
|
||||
console.log(`[${this.name}] Clicking send button.`);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Clicking send button.`);
|
||||
sendButton.click();
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`[${this.name}] Send button is disabled. Cannot send message.`);
|
||||
console.warn(`[${this.name}] [${this.captureMethod.toUpperCase()}] Send button is disabled. Cannot send message.`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${this.name}] Error sending message:`, error);
|
||||
console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Error sending message:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture response from the chat interface (Original logic, logs updated for consistency)
|
||||
captureResponse(element) {
|
||||
// This method is called when this.captureMethod === "dom"
|
||||
// 'element' is expected to be the one matching this.responseSelector
|
||||
if (!element) {
|
||||
console.log(`[${this.name}] No element provided to captureResponse`);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] No element provided to captureResponse (expected match for ${this.responseSelector})`);
|
||||
return { found: false, text: '' };
|
||||
}
|
||||
|
||||
console.log(`[${this.name}] Attempting to capture response from Gemini:`, element);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Attempting to capture response from Gemini element:`, element.outerHTML.substring(0, 200) + "...");
|
||||
|
||||
let responseText = "";
|
||||
let foundResponse = false;
|
||||
|
||||
try {
|
||||
console.log(`[${this.name}] Looking for response in various elements...`);
|
||||
|
||||
// Primarily rely on the textContent of the matched element
|
||||
if (element.textContent) {
|
||||
console.log(`[${this.name}] Element has text content`);
|
||||
responseText = element.textContent.trim();
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Raw textContent from element (len ${responseText.length}): "${responseText.substring(0, 100)}..."`);
|
||||
|
||||
// Basic validation
|
||||
if (responseText &&
|
||||
responseText !== this.lastSentMessage &&
|
||||
!responseText.includes("Loading") &&
|
||||
!responseText.includes("Thinking") &&
|
||||
!responseText.toLowerCase().includes("loading") && // Case-insensitive for common words
|
||||
!responseText.toLowerCase().includes("thinking") &&
|
||||
!responseText.includes("You stopped this response")) {
|
||||
console.log(`[${this.name}] Found response in element:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
|
||||
// HTML entities like < are automatically decoded by textContent
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Valid response found:`, responseText.substring(0, 100) + (responseText.length > 100 ? "..." : ""));
|
||||
foundResponse = true;
|
||||
} else {
|
||||
console.log(`[${this.name}] Element text appears to be invalid:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
if (responseText === this.lastSentMessage) {
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Text content matches last sent message. Not a new response.`);
|
||||
} else {
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Text content appears to be invalid or loading state: "${responseText.substring(0, 100)}..."`);
|
||||
}
|
||||
responseText = ""; // Clear if not a valid new response
|
||||
}
|
||||
} else {
|
||||
console.log(`[${this.name}] Element has no text content`);
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Element has no textContent.`);
|
||||
}
|
||||
|
||||
console.log(`[${this.name}] Trying to find the most recent conversation container...`);
|
||||
|
||||
const conversationContainers = document.querySelectorAll('.conversation-container');
|
||||
if (conversationContainers && conversationContainers.length > 0) {
|
||||
console.log(`[${this.name}] Found ${conversationContainers.length} conversation containers`);
|
||||
const lastContainer = conversationContainers[conversationContainers.length - 1];
|
||||
console.log(`[${this.name}] Last container ID:`, lastContainer.id);
|
||||
|
||||
const userQuery = lastContainer.querySelector('.user-query-container');
|
||||
const userText = userQuery ? userQuery.textContent.trim() : '';
|
||||
|
||||
if (userText === this.lastSentMessage) {
|
||||
console.log(`[${this.name}] Found container with our last sent message, looking for response`);
|
||||
}
|
||||
|
||||
const modelResponse = lastContainer.querySelector('model-response');
|
||||
if (modelResponse) {
|
||||
console.log(`[${this.name}] Found model-response in last conversation container`);
|
||||
const messageContent = modelResponse.querySelector('message-content.model-response-text');
|
||||
if (messageContent) {
|
||||
console.log(`[${this.name}] Found message-content in model-response`);
|
||||
const markdownDiv = messageContent.querySelector('.markdown');
|
||||
if (markdownDiv) {
|
||||
console.log(`[${this.name}] Found markdown div in message-content`);
|
||||
const text = markdownDiv.textContent.trim();
|
||||
if (text &&
|
||||
text !== this.lastSentMessage &&
|
||||
!text.includes("Loading") &&
|
||||
!text.includes("Thinking") &&
|
||||
!text.includes("You stopped this response")) {
|
||||
responseText = text;
|
||||
console.log(`[${this.name}] Found response in markdown div:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
foundResponse = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[${this.name}] No conversation containers found`);
|
||||
}
|
||||
|
||||
if (!foundResponse) {
|
||||
console.log(`[${this.name}] Trying to find model-response-message-content elements...`);
|
||||
const responseMessages = document.querySelectorAll('div[id^="model-response-message-content"]');
|
||||
if (responseMessages && responseMessages.length > 0) {
|
||||
console.log(`[${this.name}] Found ${responseMessages.length} model-response-message-content elements`);
|
||||
const sortedMessages = Array.from(responseMessages).sort((a, b) => {
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const responseMessage = sortedMessages[sortedMessages.length - 1];
|
||||
console.log(`[${this.name}] Last response message ID:`, responseMessage.id);
|
||||
const text = responseMessage.textContent.trim();
|
||||
if (text &&
|
||||
text !== this.lastSentMessage &&
|
||||
!text.includes("Loading") &&
|
||||
!text.includes("Thinking") &&
|
||||
!text.includes("You stopped this response")) {
|
||||
responseText = text;
|
||||
console.log(`[${this.name}] Found response in model-response-message:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
foundResponse = true;
|
||||
}
|
||||
} else {
|
||||
console.log(`[${this.name}] No model-response-message-content elements found`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundResponse) {
|
||||
console.log(`[${this.name}] Trying to find message-content elements...`);
|
||||
const messageContents = document.querySelectorAll('message-content.model-response-text');
|
||||
if (messageContents && messageContents.length > 0) {
|
||||
console.log(`[${this.name}] Found ${messageContents.length} message-content elements`);
|
||||
const sortedContents = Array.from(messageContents).sort((a, b) => {
|
||||
return (a.id || '').localeCompare(b.id || '');
|
||||
});
|
||||
const lastMessageContent = sortedContents[sortedContents.length - 1];
|
||||
console.log(`[${this.name}] Last message content ID:`, lastMessageContent.id || 'no-id');
|
||||
const markdownDiv = lastMessageContent.querySelector('.markdown');
|
||||
if (markdownDiv) {
|
||||
console.log(`[${this.name}] Found markdown div`);
|
||||
const paragraphs = markdownDiv.querySelectorAll('p');
|
||||
if (paragraphs && paragraphs.length > 0) {
|
||||
console.log(`[${this.name}] Found ${paragraphs.length} paragraphs in markdown div`);
|
||||
let combinedText = "";
|
||||
paragraphs.forEach((p, index) => {
|
||||
const text = p.textContent.trim();
|
||||
console.log(`[${this.name}] Paragraph ${index} text:`, text.substring(0, 30) + (text.length > 30 ? "..." : ""));
|
||||
if (text &&
|
||||
text !== this.lastSentMessage &&
|
||||
!text.includes("Loading") &&
|
||||
!text.includes("Thinking") &&
|
||||
!text.includes("You stopped this response")) {
|
||||
combinedText += text + "\n";
|
||||
}
|
||||
// Fallback: if textContent was empty but there are <p> tags inside the matched element.
|
||||
if (!foundResponse && responseText === "" && element.querySelectorAll) {
|
||||
const paragraphs = element.querySelectorAll('p');
|
||||
if (paragraphs && paragraphs.length > 0) {
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] textContent was empty, trying to combine ${paragraphs.length} <p> tags.`);
|
||||
let combinedPText = "";
|
||||
paragraphs.forEach(p => {
|
||||
combinedPText += p.textContent.trim() + "\n";
|
||||
});
|
||||
if (combinedText.trim()) {
|
||||
responseText = combinedText.trim();
|
||||
console.log(`[${this.name}] Found response in paragraphs:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
foundResponse = true;
|
||||
responseText = combinedPText.trim();
|
||||
// Re-validate
|
||||
if (responseText &&
|
||||
responseText !== this.lastSentMessage &&
|
||||
!responseText.toLowerCase().includes("loading") &&
|
||||
!responseText.toLowerCase().includes("thinking") &&
|
||||
!responseText.includes("You stopped this response")) {
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] Valid response found from combined <p> tags:`, responseText.substring(0, 100) + (responseText.length > 100 ? "..." : ""));
|
||||
foundResponse = true;
|
||||
} else {
|
||||
console.log(`[${this.name}] No valid text found in paragraphs`);
|
||||
responseText = ""; // Clear if not valid after combining paragraphs
|
||||
}
|
||||
} else {
|
||||
console.log(`[${this.name}] No paragraphs found in markdown div`);
|
||||
const text = markdownDiv.textContent.trim();
|
||||
if (text &&
|
||||
text !== this.lastSentMessage &&
|
||||
!text.includes("Loading") &&
|
||||
!text.includes("Thinking") &&
|
||||
!text.includes("You stopped this response")) {
|
||||
responseText = text;
|
||||
console.log(`[${this.name}] Found response in markdown div:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
foundResponse = true;
|
||||
} else {
|
||||
console.log(`[${this.name}] Markdown div text appears to be invalid:`, text.substring(0, 50) + (text.length > 50 ? "..." : ""));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[${this.name}] No markdown div found in message-content`);
|
||||
const text = lastMessageContent.textContent.trim();
|
||||
if (text &&
|
||||
text !== this.lastSentMessage &&
|
||||
!text.includes("Loading") &&
|
||||
!text.includes("Thinking") &&
|
||||
!text.includes("You stopped this response")) {
|
||||
responseText = text;
|
||||
console.log(`[${this.name}] Found response in message-content:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
foundResponse = true;
|
||||
} else {
|
||||
console.log(`[${this.name}] Message-content text appears to be invalid:`, text.substring(0, 50) + (text.length > 50 ? "..." : ""));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[${this.name}] No message-content elements found`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundResponse) {
|
||||
console.log(`[${this.name}] Trying to find paragraphs in the document...`);
|
||||
const paragraphs = document.querySelectorAll('p');
|
||||
if (paragraphs && paragraphs.length > 0) {
|
||||
console.log(`[${this.name}] Found ${paragraphs.length} paragraphs`);
|
||||
let combinedText = "";
|
||||
for (let i = paragraphs.length - 1; i >= 0; i--) {
|
||||
const paragraph = paragraphs[i];
|
||||
const text = paragraph.textContent.trim();
|
||||
const isUserQuery = paragraph.closest('.user-query-container, .user-query-bubble-container');
|
||||
if (isUserQuery) {
|
||||
continue;
|
||||
}
|
||||
if (text &&
|
||||
text !== this.lastSentMessage &&
|
||||
!text.includes("Loading") &&
|
||||
!text.includes("Thinking") &&
|
||||
!text.includes("You stopped this response")) {
|
||||
combinedText = text + "\n" + combinedText;
|
||||
if (text.startsWith("Hello") || text.includes("I'm doing") || text.includes("How can I assist")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedText.trim()) {
|
||||
responseText = combinedText.trim();
|
||||
console.log(`[${this.name}] Found response in paragraphs:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
foundResponse = true;
|
||||
} else {
|
||||
console.log(`[${this.name}] No valid text found in paragraphs`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[${this.name}] No paragraphs found`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundResponse) {
|
||||
console.log(`[${this.name}] Response not found yet, will try again in the next polling cycle`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${this.name}] Error capturing response from Gemini:`, error);
|
||||
console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Error capturing response from Gemini:`, error);
|
||||
}
|
||||
|
||||
// Final cleanup (mostly for newlines)
|
||||
if (foundResponse && responseText) {
|
||||
console.log(`[${this.name}] Cleaning up response text...`);
|
||||
responseText = responseText.trim()
|
||||
.replace(/^(Loading|Thinking).*/gim, '')
|
||||
.replace(/You stopped this response.*/gim, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
console.log(`[${this.name}] Cleaned response text:`, responseText.substring(0, 50) + (responseText.length > 50 ? "..." : ""));
|
||||
responseText = responseText.replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
found: foundResponse && !!responseText.trim(),
|
||||
text: responseText
|
||||
};
|
||||
}
|
||||
|
||||
// (NEW) Method for streaming API patterns
|
||||
getStreamingApiPatterns() {
|
||||
console.log(`[${this.name}] getStreamingApiPatterns called`);
|
||||
// TODO: DEVELOPER ACTION REQUIRED!
|
||||
// Use browser Network DevTools on gemini.google.com to identify the
|
||||
// exact URL(s) that deliver the AI's streaming response when a prompt is sent.
|
||||
// Replace the placeholder pattern below with the correct one(s).
|
||||
// Example: return [{ urlPattern: "*://gemini.google.com/api/generate*", requestStage: "Response" }];
|
||||
return [
|
||||
{ urlPattern: "*://gemini.google.com/api/stream/generateContent*", requestStage: "Response" } // Placeholder - VERIFY THIS!
|
||||
];
|
||||
// --- START: Methods for Debugger-based Response Capture ---
|
||||
|
||||
initiateResponseCapture(requestId, responseCallback) {
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] initiateResponseCapture called for requestId: ${requestId}. Current provider captureMethod: ${this.captureMethod}`);
|
||||
this.pendingResponseCallbacks.set(requestId, responseCallback);
|
||||
if (this.captureMethod === "debugger") {
|
||||
// Ensure accumulator is ready for this request
|
||||
if (!this.requestAccumulators.has(requestId)) {
|
||||
this.requestAccumulators.set(requestId, { text: "", isDefinitelyFinal: false });
|
||||
}
|
||||
console.log(`[${this.name}] [DEBUGGER] Debugger capture selected. Callback stored for requestId: ${requestId}. Ensure background script is set up for '${this.debuggerUrlPattern}'.`);
|
||||
} else if (this.captureMethod === "dom") {
|
||||
// For DOM method, no specific initiation is needed here as polling is handled by content.js
|
||||
console.log(`[${this.name}] [DOM] DOM capture selected. Response will be handled by polling in content.js using captureResponse.`);
|
||||
} else {
|
||||
console.error(`[${this.name}] [${this.captureMethod.toUpperCase()}] Unknown capture method: ${this.captureMethod}`);
|
||||
responseCallback(requestId, `[Error: Unknown capture method '${this.captureMethod}' in provider]`, true);
|
||||
this.pendingResponseCallbacks.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// (NEW) Optional Fallback Methods
|
||||
handleDebuggerData(requestId, rawData, isFinalFromBackground) {
|
||||
// This method is only relevant if this.captureMethod === "debugger"
|
||||
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData ENTER - requestId: ${requestId}, isFinalFromBackground: ${isFinalFromBackground}, rawData: "${rawData ? rawData.substring(0,100) + "..." : "null/empty"}"`);
|
||||
const callback = this.pendingResponseCallbacks.get(requestId);
|
||||
if (!callback) {
|
||||
console.warn(`[${this.name}] [DEBUGGER] handleDebuggerData - No callback for requestId: ${requestId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let accumulator = this.requestAccumulators.get(requestId);
|
||||
if (!accumulator) {
|
||||
console.warn(`[${this.name}] [DEBUGGER] handleDebuggerData - No accumulator for requestId: ${requestId}. Initializing.`);
|
||||
accumulator = { text: "", isDefinitelyFinal: false };
|
||||
this.requestAccumulators.set(requestId, accumulator);
|
||||
}
|
||||
|
||||
if (accumulator.isDefinitelyFinal) {
|
||||
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - Accumulator for ${requestId} is already final. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawData && rawData.trim() !== "") {
|
||||
const parseOutput = this.parseDebuggerResponse(rawData); // Gemini-specific parsing
|
||||
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - requestId: ${requestId}, parseOutput: ${JSON.stringify(parseOutput)}`);
|
||||
|
||||
if (parseOutput.text !== null) { // Allow empty string if it's a valid part of the response
|
||||
accumulator.text = parseOutput.text; // parseOutput.text is the total text from rawData
|
||||
}
|
||||
|
||||
if (parseOutput.isFinalResponse) { // If the parser itself detected a definitive end
|
||||
accumulator.isDefinitelyFinal = true;
|
||||
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - ${requestId} marked as definitelyFinal by parseOutput.`);
|
||||
}
|
||||
}
|
||||
|
||||
const isFinalForCallback = accumulator.isDefinitelyFinal || isFinalFromBackground;
|
||||
|
||||
if (accumulator.text || isFinalForCallback) {
|
||||
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - INVOKING CALLBACK for ${requestId}. Text length: ${accumulator.text.length}, isFinal: ${isFinalForCallback}`);
|
||||
callback(requestId, accumulator.text, isFinalForCallback);
|
||||
}
|
||||
|
||||
if (isFinalForCallback) {
|
||||
console.log(`[${this.name}] [DEBUGGER] handleDebuggerData - CLEANING UP for ${requestId} (isDefinitelyFinal: ${accumulator.isDefinitelyFinal}, isFinalFromBackground: ${isFinalFromBackground}).`);
|
||||
this.pendingResponseCallbacks.delete(requestId);
|
||||
this.requestAccumulators.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: DEVELOPER ACTION REQUIRED!
|
||||
// This is a placeholder parser. You MUST inspect Gemini's actual SSE
|
||||
// stream format and update this parser accordingly.
|
||||
parseDebuggerResponse(rawDataString) {
|
||||
let accumulatedTextFromThisCall = "";
|
||||
let isStreamDefinitelyFinished = false;
|
||||
|
||||
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse INPUT rawDataString (len ${rawDataString.length}): ${rawDataString.substring(0, 300)}`);
|
||||
|
||||
if (!rawDataString) {
|
||||
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse: Empty rawDataString received.`);
|
||||
return { text: "", isFinalResponse: false };
|
||||
}
|
||||
|
||||
let cleanData = rawDataString;
|
||||
if (cleanData.startsWith(")]}'")) {
|
||||
const firstNewlineIndex = cleanData.indexOf('\n');
|
||||
if (firstNewlineIndex !== -1) {
|
||||
cleanData = cleanData.substring(firstNewlineIndex + 1);
|
||||
} else {
|
||||
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse: rawDataString starts with )]}' but no newline found.`);
|
||||
cleanData = "";
|
||||
}
|
||||
}
|
||||
cleanData = cleanData.trimStart();
|
||||
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse cleanData after prefix strip (len ${cleanData.length}): ${cleanData.substring(0, 300)}`);
|
||||
|
||||
const chunks = [];
|
||||
let currentIndex = 0;
|
||||
while (currentIndex < cleanData.length) {
|
||||
// Skip leading whitespace/newlines to find the start of a potential length line
|
||||
while (currentIndex < cleanData.length && /\s/.test(cleanData.charAt(currentIndex))) {
|
||||
currentIndex++;
|
||||
}
|
||||
if (currentIndex >= cleanData.length) { // Reached end after skipping whitespace
|
||||
// console.log(`[${this.name}] Reached end of data after skipping initial whitespace.`);
|
||||
break;
|
||||
}
|
||||
|
||||
const nextNewline = cleanData.indexOf('\n', currentIndex);
|
||||
if (nextNewline === -1) {
|
||||
// Last line processing (potential trailing length or garbage)
|
||||
const remainingStr = cleanData.substring(currentIndex).trim();
|
||||
if (/^\d+$/.test(remainingStr) && chunks.length > 0) {
|
||||
// console.log(`[${this.name}] [DEBUGGER] Trailing number found, likely length for a future (missing) chunk: ${remainingStr}`);
|
||||
} else if (remainingStr.length > 0 && chunks.length > 0) { // If there's non-numeric trailing data and we have prior chunks, it might be an error or unparseable.
|
||||
// console.warn(`[${this.name}] [DEBUGGER] Trailing non-numeric, non-empty data found: ${remainingStr.substring(0,100)}`);
|
||||
} else if (remainingStr.length > 0 && chunks.length === 0) { // If it's the *only* data and not a number.
|
||||
// console.warn(`[${this.name}] [DEBUGGER] Single line of non-numeric, non-empty data found, cannot parse as chunk: ${remainingStr.substring(0,100)}`);
|
||||
}
|
||||
break; // End of data or unparseable trailing data
|
||||
}
|
||||
|
||||
const lengthLineContent = cleanData.substring(currentIndex, nextNewline).trim();
|
||||
let length = NaN;
|
||||
|
||||
if (/^\d+$/.test(lengthLineContent)) { // Check if the line *only* contains digits
|
||||
length = parseInt(lengthLineContent, 10);
|
||||
}
|
||||
|
||||
// console.log(`[${this.name}] [DEBUGGER] Chunk parsing: Potential lengthLineContent="${lengthLineContent}", parsed length=${length}`);
|
||||
|
||||
if (isNaN(length) || length < 0) { // This will catch non-numeric lines or negative/invalid lengths
|
||||
console.warn(`[${this.name}] [DEBUGGER] Invalid, non-positive, or non-numeric length line: "${lengthLineContent}". CurrentIndex: ${currentIndex}. Skipping to next line.`);
|
||||
currentIndex = nextNewline + 1; // Advance past the problematic line
|
||||
continue; // Restart loop to find next potential length line from the new currentIndex
|
||||
}
|
||||
|
||||
const jsonStringStart = nextNewline + 1;
|
||||
const jsonStringEnd = jsonStringStart + length;
|
||||
|
||||
if (length === 0) {
|
||||
// console.log(`[${this.name}] [DEBUGGER] Encountered 0-length chunk at currentIndex ${currentIndex}. Advancing to after its conceptual position.`);
|
||||
currentIndex = jsonStringEnd; // This is effectively nextNewline + 1, the start of where the empty JSON "was"
|
||||
continue;
|
||||
}
|
||||
|
||||
if (jsonStringEnd > cleanData.length) {
|
||||
console.warn(`[${this.name}] [DEBUGGER] Declared length ${length} (from line "${lengthLineContent}") exceeds available data. cleanData.length: ${cleanData.length}, required end: ${jsonStringEnd}. Discarding this length and attempting to resynchronize.`);
|
||||
currentIndex = nextNewline + 1; // Skip the problematic length line
|
||||
continue; // Try to find the next valid length line
|
||||
}
|
||||
|
||||
const jsonString = cleanData.substring(jsonStringStart, jsonStringEnd);
|
||||
// console.log(`[${this.name}] [DEBUGGER] Extracted jsonString (len ${jsonString.length}, expected ${length}): ${jsonString.substring(0,100)}`);
|
||||
|
||||
try {
|
||||
JSON.parse(jsonString);
|
||||
chunks.push(jsonString);
|
||||
currentIndex = jsonStringEnd;
|
||||
} catch (parseError) {
|
||||
const errorMsg = parseError.message || "";
|
||||
const nonWhitespaceMatch = errorMsg.match(/Unexpected non-whitespace character after JSON at position (\d+)/);
|
||||
|
||||
if (nonWhitespaceMatch && nonWhitespaceMatch[1]) {
|
||||
const actualEndPositionInJsonString = parseInt(nonWhitespaceMatch[1], 10);
|
||||
const validJsonSubstring = jsonString.substring(0, actualEndPositionInJsonString);
|
||||
|
||||
try {
|
||||
JSON.parse(validJsonSubstring);
|
||||
chunks.push(validJsonSubstring);
|
||||
currentIndex = jsonStringStart + actualEndPositionInJsonString;
|
||||
console.warn(`[${this.name}] [DEBUGGER] Corrected oversized JSON chunk. Original length ${length} (from line "${lengthLineContent}") was too long. Used shorter valid part of length ${actualEndPositionInJsonString}. New currentIndex: ${currentIndex}. Original error: ${parseError.message}`);
|
||||
} catch (innerParseError) {
|
||||
console.warn(`[${this.name}] [DEBUGGER] Failed to parse even the corrected shorter JSON substring (original length ${length} from line "${lengthLineContent}", attempted correction to ${actualEndPositionInJsonString}). Inner Error: ${innerParseError.message}. JSON hint for corrected: "${validJsonSubstring.substring(0,100)}...". Skipping original length declaration.`);
|
||||
currentIndex = nextNewline + 1;
|
||||
}
|
||||
} else {
|
||||
console.warn(`[${this.name}] [DEBUGGER] Failed to parse JSON chunk (length ${length} from line "${lengthLineContent}", Error: ${parseError.message}). This suggests the reported length was incorrect or JSON malformed. JSON hint: "${jsonString.substring(0, 100)}...". Skipping this length declaration and attempting to resynchronize.`);
|
||||
currentIndex = nextNewline + 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawChunkJson of chunks) {
|
||||
try {
|
||||
const chunkJsonToParse = rawChunkJson.trim();
|
||||
if (!chunkJsonToParse) {
|
||||
continue;
|
||||
}
|
||||
const outerArray = JSON.parse(chunkJsonToParse);
|
||||
if (!Array.isArray(outerArray)) continue;
|
||||
|
||||
for (const item of outerArray) {
|
||||
if (Array.isArray(item) && item.length > 0) {
|
||||
if (item[0] === "wrb.fr" && item.length >= 3 && typeof item[2] === 'string') {
|
||||
const nestedJsonString = item[2];
|
||||
try {
|
||||
const trimmedNestedJsonString = nestedJsonString.trim();
|
||||
if (!trimmedNestedJsonString) {
|
||||
continue;
|
||||
}
|
||||
const innerData = JSON.parse(trimmedNestedJsonString);
|
||||
if (Array.isArray(innerData) && innerData.length > 4 && Array.isArray(innerData[4])) {
|
||||
for (const contentBlock of innerData[4]) {
|
||||
if (Array.isArray(contentBlock) && contentBlock.length >= 2 && typeof contentBlock[0] === 'string' && contentBlock[0].startsWith("rc_") && Array.isArray(contentBlock[1])) {
|
||||
const newText = contentBlock[1].join("");
|
||||
if (newText) {
|
||||
accumulatedTextFromThisCall += newText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[${this.name}] [DEBUGGER] Failed to parse nested JSON: "${nestedJsonString.substring(0,100)}...". Error: ${e.message}`);
|
||||
}
|
||||
} else if (item[0] === "e" && item.length >= 1) {
|
||||
isStreamDefinitelyFinished = true;
|
||||
console.log(`[${this.name}] [DEBUGGER] End-of-stream marker 'e' detected in chunk: ${JSON.stringify(item)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[${this.name}] [DEBUGGER] Failed to parse chunk JSON: "${chunkJsonToParse.substring(0,100)}...". Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`[${this.name}] [DEBUGGER] parseDebuggerResponse FINAL output - text length: ${accumulatedTextFromThisCall.length}, accumulatedText: "${accumulatedTextFromThisCall.substring(0,200)}", isFinalByParser: ${isStreamDefinitelyFinished}`);
|
||||
|
||||
if (accumulatedTextFromThisCall.includes("\\<") || accumulatedTextFromThisCall.includes("\\>")) {
|
||||
console.log(`[${this.name}] [DEBUGGER] Unescaping escaped tool call brackets in final text.`);
|
||||
accumulatedTextFromThisCall = accumulatedTextFromThisCall.replace(/\\</g, "<").replace(/\\>/g, ">");
|
||||
}
|
||||
|
||||
if (accumulatedTextFromThisCall.includes("\\_")) {
|
||||
console.log(`[${this.name}] [DEBUGGER] Unescaping escaped underscores in final text.`);
|
||||
accumulatedTextFromThisCall = accumulatedTextFromThisCall.replace(/\\_/g, "_");
|
||||
}
|
||||
|
||||
return { text: accumulatedTextFromThisCall, isFinalResponse: isStreamDefinitelyFinished };
|
||||
}
|
||||
|
||||
// --- END: Methods for Debugger-based Response Capture ---
|
||||
|
||||
getStreamingApiPatterns() {
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] getStreamingApiPatterns called. Current provider captureMethod: ${this.captureMethod}`);
|
||||
if (this.captureMethod === "debugger" && this.debuggerUrlPattern) {
|
||||
console.log(`[${this.name}] [DEBUGGER] Using debugger URL pattern: ${this.debuggerUrlPattern}`);
|
||||
return [{ urlPattern: this.debuggerUrlPattern, requestStage: "Response" }];
|
||||
}
|
||||
console.log(`[${this.name}] [${this.captureMethod.toUpperCase()}] No debugger patterns to return (captureMethod is '${this.captureMethod}' or no pattern set).`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// (NEW) Optional Fallback Methods - these are largely placeholders if debugger is primary
|
||||
async captureResponseDOMFallback() {
|
||||
console.log(`[${this.name}] captureResponseDOMFallback called. Implement DOM observation logic here if needed as a fallback.`);
|
||||
// TODO: Implement or verify existing DOM fallback logic for Gemini if it's to be kept.
|
||||
|
|
@ -424,10 +529,11 @@ class GeminiProvider {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Check if we should skip response monitoring (Original - UNCHANGED)
|
||||
// Check if we should skip response monitoring
|
||||
shouldSkipResponseMonitoring() {
|
||||
// We want to monitor for responses now that we've fixed the response capturing
|
||||
return false;
|
||||
// If using debugger, we skip DOM-based monitoring.
|
||||
// console.log(`[${this.name}] shouldSkipResponseMonitoring called. Capture method: ${this.captureMethod}`);
|
||||
return this.captureMethod === "debugger";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue