diff --git a/.gitignore b/.gitignore index 4700889..6da8040 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ build/ .env.development.local .env.test.local .env.production.local +!api-relay-server/relay.settings # IDE and editor files .idea/ diff --git a/api-relay-server/package-lock.json b/api-relay-server/package-lock.json index 1e6d133..14e3e26 100644 --- a/api-relay-server/package-lock.json +++ b/api-relay-server/package-lock.json @@ -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", diff --git a/api-relay-server/package.json b/api-relay-server/package.json index 93d6306..c3eb25d 100644 --- a/api-relay-server/package.json +++ b/api-relay-server/package.json @@ -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", diff --git a/api-relay-server/src/admin-ui/admin.html b/api-relay-server/src/admin-ui/admin.html index 23d25f9..c955cf6 100644 --- a/api-relay-server/src/admin-ui/admin.html +++ b/api-relay-server/src/admin-ui/admin.html @@ -1,127 +1,248 @@ - Chat Relay Admin -

Chat Relay Admin Dashboard

@@ -130,24 +251,30 @@
-

Message History

+
+

Message History

+
+ Server Uptime: N/A + Connected Extensions: 0 + + +
+
- - - - - - - - + + + + + + + + @@ -170,8 +297,7 @@
- +
@@ -179,25 +305,12 @@
-
- - -

Ping Interval (ms):

-
-

Server Status

-
-

Server Uptime: N/A

-

Connected Extensions: 0

- -
-
+
Server Logs (click to toggle)
Start TimestampEnd TimestampRequest IDFrom Client (Cline)To ExtensionFrom ExtensionTo Client (Cline)StatusStart TimestampEnd TimestampRequest IDFrom Client -To Extension -From Extension -To Client -Status
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 () 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."); - \ No newline at end of file diff --git a/api-relay-server/src/index.js b/api-relay-server/src/index.js deleted file mode 100644 index 3cad791..0000000 --- a/api-relay-server/src/index.js +++ /dev/null @@ -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'); diff --git a/api-relay-server/src/server.js b/api-relay-server/src/server.js deleted file mode 100644 index fdcdaca..0000000 --- a/api-relay-server/src/server.js +++ /dev/null @@ -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; diff --git a/api-relay-server/src/server.ts b/api-relay-server/src/server.ts index 5cebc2c..f5ef824 100644 --- a/api-relay-server/src/server.ts +++ b/api-relay-server/src/server.ts @@ -1,3 +1,5 @@ +#!/usr/bin/env node +console.log("SERVER.TS: Top of file reached - src/server.ts is being executed."); /* * Chat Relay: Relay for AI Chat Interfaces * Copyright (C) 2025 Jamison Moore @@ -15,14 +17,47 @@ * 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 express, { Request, Response, NextFunction, Router } from 'express'; import bodyParser from 'body-parser'; -import { execSync } from 'child_process'; // Import for executing commands import cors from 'cors'; -import express, { NextFunction, Request, Response, Router } from 'express'; -import fs from 'fs'; +import { WebSocketServer, WebSocket } from 'ws'; import http from 'http'; import path from 'path'; -import { WebSocket, WebSocketServer } from 'ws'; +import fs from 'fs'; +import os from 'os'; + +// Load .env file variables into process.env +const envPath = path.resolve(__dirname, '../relay.settings'); + +if (fs.existsSync(envPath)) { + const lines = fs.readFileSync(envPath, 'utf-8').split('\n'); + for (const line of lines) { + const [key, ...vals] = line.split('='); + if (key && vals.length) process.env[key.trim()] = vals.join('=').trim(); + } + console.log(`SERVER.TS: relay.settings loaded from ${envPath}`); +} else { + console.warn(`SERVER.TS: relay.settings file not found at ${envPath}`); +} + +const ENV_FILE_PATH = envPath; +const TS_VALID_STRATEGIES = ["queue", "drop"]; + +// --- Initialize settings from .env or use defaults --- +let newRequestBehavior: 'queue' | 'drop' = process.env.MESSAGE_SEND_STRATEGY && TS_VALID_STRATEGIES.includes(process.env.MESSAGE_SEND_STRATEGY as any) + ? process.env.MESSAGE_SEND_STRATEGY as 'queue' | 'drop' + : 'queue'; + +let currentRequestTimeoutMs: number = process.env.REQUEST_TIMEOUT_MS && !isNaN(parseInt(process.env.REQUEST_TIMEOUT_MS, 10)) && parseInt(process.env.REQUEST_TIMEOUT_MS, 10) > 0 + ? parseInt(process.env.REQUEST_TIMEOUT_MS, 10) + : 120000; + +let serverPortForListen: number = process.env.PORT && !isNaN(parseInt(process.env.PORT, 10)) && parseInt(process.env.PORT, 10) > 0 + ? parseInt(process.env.PORT, 10) + : 3003; + +console.log(`SERVER.TS: Initial effective settings - Strategy: ${newRequestBehavior}, Timeout: ${currentRequestTimeoutMs}ms, Port: ${serverPortForListen}`); + // Interfaces interface PendingRequest { resolve: (value: any) => void; @@ -31,11 +66,11 @@ interface PendingRequest { interface WebSocketMessage { type: string; requestId?: number; - message?: string; // For messages sent from server to extension - response?: string; // For CHAT_RESPONSE from extension (older DOM method) - chunk?: string; // For CHAT_RESPONSE_CHUNK from extension (debugger method) - isFinal?: boolean; // Flag for CHAT_RESPONSE_CHUNK - error?: string; // For CHAT_RESPONSE_ERROR from extension + message?: string; + response?: string; + chunk?: string; + isFinal?: boolean; + error?: string; settings?: { model?: string; temperature?: number; @@ -43,14 +78,10 @@ interface WebSocketMessage { }; } -// Queuing/Dropping System State -let activeExtensionProcessingId: number | null = null; -// newRequestBehavior will be initialized after loadServerConfig() -let newRequestBehavior: 'queue' | 'drop'; interface QueuedRequest { requestId: number; - req: Request; // Express Request object - res: Response; // Express Response object + req: Request; + res: Response; userMessage: string; model?: string; temperature?: number; @@ -62,6 +93,8 @@ let requestQueue: QueuedRequest[] = []; let activeConnections: WebSocket[] = []; const pendingRequests = new Map(); let requestCounter = 0; +let activeExtensionProcessingId: number | null = null; +let activeExtensionSocketId: string | null = null; // Stores the socketId of the extension processing the current request // In-memory store for admin messages interface ModelSettings { @@ -69,143 +102,245 @@ interface ModelSettings { temperature?: number; max_tokens?: number; } - interface ChatRequestData { fromClient: string; - toExtension: WebSocketMessage; // Assuming WebSocketMessage is defined elsewhere + toExtension: WebSocketMessage; modelSettings: ModelSettings; } - interface ChatResponseData { fromExtension: string; - toClient: any; // This could be more specific if the OpenAI response structure is defined + toClient: any; status: string; } - interface ChatErrorData { - toClientError: any; // This could be more specific if the error JSON structure is defined + toClientError: any; status: string; } - -type AdminLogDataType = ChatRequestData | ChatResponseData | ChatErrorData | any; // Fallback to any for other types - +type AdminLogDataType = ChatRequestData | ChatResponseData | ChatErrorData | any; interface AdminLogEntry { timestamp: string; type: - | 'CHAT_REQUEST_RECEIVED' - | 'CHAT_RESPONSE_SENT' - | 'CHAT_ERROR_RESPONSE_SENT' - | 'CHAT_REQUEST_QUEUED' - | 'CHAT_REQUEST_DROPPED' - | 'CHAT_REQUEST_DEQUEUED' - | 'CHAT_REQUEST_PROCESSING' - | 'CHAT_REQUEST_ERROR' // For pre-processing errors like no extension - | 'SETTING_UPDATE' // Existing type, ensure it's included - | string; // Fallback for other/future types + | 'CHAT_REQUEST_RECEIVED' + | 'CHAT_RESPONSE_SENT' + | 'CHAT_ERROR_RESPONSE_SENT' + | 'CHAT_REQUEST_QUEUED' + | 'CHAT_REQUEST_DROPPED' + | 'CHAT_REQUEST_DEQUEUED' + | 'CHAT_REQUEST_PROCESSING' + | 'CHAT_REQUEST_ERROR' + | 'REQUEST_CANCELLED_DISCONNECT' + | 'REQUEST_CANCELLED_TIMEOUT' + | 'REQUEST_CANCELLED_REFRESH' + | 'EXTENSION_READY' + | 'SETTING_UPDATE' + | string; requestId: string; data: AdminLogDataType; } const MAX_ADMIN_HISTORY_LENGTH = 1000; let adminMessageHistory: AdminLogEntry[] = []; -const serverStartTime = Date.now(); // Store server start time +const serverStartTime = Date.now(); -// Configuration file path -const CONFIG_FILE_PATH = path.join(__dirname, 'server-config.json'); -const RESTART_TRIGGER_FILE_PATH = path.join(__dirname, '.triggerrestart'); // For explicitly triggering nodemon - -interface ServerConfig { - port?: number; - requestTimeoutMs?: number; - lastRestartRequestTimestamp?: number; // New field - newRequestBehavior?: 'queue' | 'drop'; - autoKillPort?: boolean; // New setting for auto-killing port -} - -// Function to read configuration -function loadServerConfig(): ServerConfig { - try { - if (fs.existsSync(CONFIG_FILE_PATH)) { - const configFile = fs.readFileSync(CONFIG_FILE_PATH, 'utf-8'); - return JSON.parse(configFile) as ServerConfig; +// Function to update .env file +function updateEnvFile(settingsToUpdate: Record) { + let envContent = ""; + if (fs.existsSync(ENV_FILE_PATH)) { + envContent = fs.readFileSync(ENV_FILE_PATH, 'utf8'); + } + let lines = envContent.split(os.EOL).map(line => line.trim()).filter(line => line); + const newLinesToAdd: string[] = []; + for (const key in settingsToUpdate) { + const value = settingsToUpdate[key]; + const settingLine = `${key}=${value}`; + let found = false; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith(`${key}=`)) { + lines[i] = settingLine; + found = true; + break; + } + } + if (!found) { + newLinesToAdd.push(settingLine); } - } catch (error) { - console.error('Error reading server-config.json, using defaults/env vars:', error); } - return {}; -} - -// Function to write configuration -function saveServerConfig(config: ServerConfig): void { + lines = lines.concat(newLinesToAdd); + const finalContent = lines.join(os.EOL) + (lines.length > 0 ? os.EOL : ''); try { - fs.writeFileSync(CONFIG_FILE_PATH, JSON.stringify(config, null, 2), 'utf-8'); - console.log('Server configuration saved to server-config.json'); + fs.writeFileSync(ENV_FILE_PATH, finalContent, 'utf8'); + console.log(`SERVER.TS: Settings saved to ${ENV_FILE_PATH}. Content: \n${finalContent}`); } catch (error) { - console.error('Error writing server-config.json:', error); + console.error(`SERVER.TS: Error saving settings to ${ENV_FILE_PATH}:`, error); } } -// Load initial config -const initialConfig = loadServerConfig(); +// Ensure .env file exists with current/default settings +const initialEnvSettingsToEnsure: Record = {}; +if (!process.env.MESSAGE_SEND_STRATEGY || !TS_VALID_STRATEGIES.includes(process.env.MESSAGE_SEND_STRATEGY as any)) { + initialEnvSettingsToEnsure.MESSAGE_SEND_STRATEGY = newRequestBehavior; +} +if (!process.env.REQUEST_TIMEOUT_MS || isNaN(parseInt(process.env.REQUEST_TIMEOUT_MS, 10)) || parseInt(process.env.REQUEST_TIMEOUT_MS, 10) <= 0) { + initialEnvSettingsToEnsure.REQUEST_TIMEOUT_MS = currentRequestTimeoutMs; +} +if (!process.env.PORT || isNaN(parseInt(process.env.PORT, 10)) || parseInt(process.env.PORT, 10) <= 0) { + initialEnvSettingsToEnsure.PORT = serverPortForListen; +} +if (Object.keys(initialEnvSettingsToEnsure).length > 0) { + console.log("SERVER.TS: Initializing/updating .env file with current/default settings:", initialEnvSettingsToEnsure); + updateEnvFile(initialEnvSettingsToEnsure); +} else { + console.log(`SERVER.TS: .env file at ${ENV_FILE_PATH} found and all required settings appear present.`); +} -// Initialize newRequestBehavior from config, defaulting to 'queue' -newRequestBehavior = initialConfig.newRequestBehavior && (initialConfig.newRequestBehavior === 'queue' || initialConfig.newRequestBehavior === 'drop') - ? initialConfig.newRequestBehavior - : 'queue'; -let autoKillPort = initialConfig.autoKillPort === undefined ? false : initialConfig.autoKillPort; // Initialize autoKillPort - -const PORT = initialConfig.port || parseInt(process.env.PORT || '3003', 10); -let currentRequestTimeoutMs = initialConfig.requestTimeoutMs || parseInt(process.env.REQUEST_TIMEOUT_MS || '120000', 10); // Create Express app const app = express(); app.use(cors()); -app.use(bodyParser.json({ limit: '10mb' })); // Increased payload size limit -// Admin UI: Serve static files -// Correct path considering TypeScript's outDir. __dirname will be 'dist' at runtime. -const adminUIDirectory = path.join(__dirname, '../src/admin-ui'); -app.use('/admin-static', express.static(adminUIDirectory)); -// Admin UI: Route for the main admin page -app.get('/admin', (req: Request, res: Response) => { - res.sendFile(path.join(adminUIDirectory, 'admin.html')); +app.use(bodyParser.json({ limit: '10mb' })); + +// Middleware to log all request paths +app.use((req: Request, res: Response, next: NextFunction) => { + console.log(`SERVER.TS: Incoming request: ${req.method} ${req.path}`); + next(); }); -// Create HTTP server + +console.log(`SERVER.TS: Loaded - Strategy: ${newRequestBehavior}, Timeout: ${currentRequestTimeoutMs}ms, Port: ${serverPortForListen}`); + +// Interface for Admin Settings Payload +interface AdminSettingsPayload { + messageSendStrategy?: 'queue' | 'drop'; + requestTimeout?: number | string; // Allow string from form, parse to number + serverPort?: number | string; // Allow string from form, parse to number +} + +// --- Admin API Routes (using .env) --- +app.get('/admin/settings', (req: Request, res: Response) => { + console.log(`SERVER.TS: Handling GET /admin/settings. Current settings: Strategy=${newRequestBehavior}, Timeout=${currentRequestTimeoutMs}, Port=${serverPortForListen}`); + res.status(200).json({ + messageSendStrategy: newRequestBehavior, + requestTimeout: currentRequestTimeoutMs, + serverPort: serverPortForListen + }); +}); +console.log("SERVER.TS: DEFINED ROUTE: GET /admin/settings"); + +app.post('/admin/settings', (req: Request<{}, any, AdminSettingsPayload>, res: Response) => { + const { messageSendStrategy, requestTimeout, serverPort } = req.body; + let changesMade = false; + let errors: string[] = []; + const settingsToSaveToEnv: Record = {}; + + if (messageSendStrategy !== undefined) { + if (TS_VALID_STRATEGIES.includes(messageSendStrategy as any)) { + newRequestBehavior = messageSendStrategy as 'queue' | 'drop'; + settingsToSaveToEnv.MESSAGE_SEND_STRATEGY = newRequestBehavior; + console.log(`SERVER.TS: Handling POST /admin/settings - Strategy updated to: ${newRequestBehavior}`); + changesMade = true; + } else { + errors.push(`Invalid messageSendStrategy. Must be one of: ${TS_VALID_STRATEGIES.join(', ')}`); + console.warn(`SERVER.TS: Handling POST /admin/settings - Invalid strategy: ${messageSendStrategy}`); + } + } + + if (requestTimeout !== undefined) { + const timeoutMs = parseInt(String(requestTimeout), 10); + if (!isNaN(timeoutMs) && timeoutMs > 0) { + currentRequestTimeoutMs = timeoutMs; + settingsToSaveToEnv.REQUEST_TIMEOUT_MS = currentRequestTimeoutMs; + console.log(`SERVER.TS: Handling POST /admin/settings - Timeout updated to: ${currentRequestTimeoutMs}ms`); + changesMade = true; + } else { + errors.push('Invalid requestTimeout. Must be a positive number.'); + console.warn(`SERVER.TS: Handling POST /admin/settings - Invalid timeout: ${requestTimeout}`); + } + } + + if (serverPort !== undefined) { + const portNum = parseInt(String(serverPort), 10); + if (!isNaN(portNum) && portNum > 0 && portNum < 65536) { + serverPortForListen = portNum; + settingsToSaveToEnv.PORT = serverPortForListen; + console.log(`SERVER.TS: Handling POST /admin/settings - Port updated to: ${serverPortForListen}`); + changesMade = true; + } else { + errors.push('Invalid serverPort. Must be a number between 1 and 65535.'); + console.warn(`SERVER.TS: Handling POST /admin/settings - Invalid port: ${serverPort}`); + } + } + + if (errors.length > 0) { + res.status(400).json({ error: errors.join('; ') }); + return; + } + + if (changesMade && Object.keys(settingsToSaveToEnv).length > 0) { + updateEnvFile(settingsToSaveToEnv); + res.status(200).json({ + message: 'Settings updated. .env file modified, server may restart.', + currentSettings: { + messageSendStrategy: newRequestBehavior, + requestTimeout: currentRequestTimeoutMs, + serverPort: serverPortForListen + } + }); + } else { + res.status(200).json({ + message: 'No valid settings were changed.', + currentSettings: { + messageSendStrategy: newRequestBehavior, + requestTimeout: currentRequestTimeoutMs, + serverPort: serverPortForListen + } + }); + } +}); +console.log("SERVER.TS: DEFINED ROUTE: POST /admin/settings"); +// --- End Admin API Routes --- + +// Admin UI: Serve static files +const adminUIDirectory = path.join(__dirname, '../src/admin-ui'); +app.use('/admin', express.static(adminUIDirectory)); +app.get('/admin', (req: Request, res: Response) => { // Redirect /admin to /admin/admin.html + res.redirect('/admin/admin.html'); +}); + +// Create HTTP server & WebSocket server const server = http.createServer(app); -// Create WebSocket server for browser extension communication const wss = new WebSocketServer({ server }); -// Handle WebSocket connections from browser extensions + +// WebSocket server logic wss.on('connection', (ws: WebSocket) => { - console.log('Browser extension connected'); + (ws as any).socketId = Date.now().toString(36) + Math.random().toString(36).substring(2); // Assign unique ID + console.log(`SERVER.TS: Browser extension connected with socketId: ${(ws as any).socketId}`); activeConnections.push(ws); - // Handle messages from browser extension ws.on('message', (message: string) => { try { const data: WebSocketMessage = JSON.parse(message.toString()); - let requestIdToProcess: number | undefined = undefined; + const currentSocketId = (ws as any).socketId; + let requestIdToProcess: number | undefined = data.requestId; let responseDataToUse: string | undefined = undefined; let isErrorMessage = false; - console.log(`SERVER: WebSocket message received from extension: type=${data.type}, requestId=${data.requestId}`); + console.log(`SERVER.TS: WebSocket message received from socketId ${currentSocketId}: type=${data.type}, requestId=${data.requestId}`); - if (data.type === 'CHAT_RESPONSE') { - requestIdToProcess = data.requestId; + if (data.type === 'EXTENSION_READY') { + console.log(`SERVER.TS: Extension with socketId ${currentSocketId} reported EXTENSION_READY.`); + logAdminMessage('EXTENSION_READY', 'N/A', { socketId: currentSocketId }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + // Future: If the extension had a persistent ID, we could check if it was previously + // processing a request and mark it as "Cancelled (Extension Refreshed)". + // For now, the disconnect/reconnect logic based on socketId handles most stale states. + return; + } else if (data.type === 'CHAT_RESPONSE') { responseDataToUse = data.response; - console.log(`SERVER: Processing CHAT_RESPONSE for requestId: ${data.requestId}`); } else if (data.type === 'CHAT_RESPONSE_CHUNK' && data.isFinal === true) { - requestIdToProcess = data.requestId; responseDataToUse = data.chunk; - console.log(`SERVER: Processing final CHAT_RESPONSE_CHUNK for requestId: ${data.requestId}`); } else if (data.type === 'CHAT_RESPONSE_ERROR') { - requestIdToProcess = data.requestId; responseDataToUse = data.error || "Unknown error from extension"; isErrorMessage = true; - console.log(`SERVER: Processing CHAT_RESPONSE_ERROR for requestId: ${data.requestId}`); } else if (data.type === 'CHAT_RESPONSE_STREAM_ENDED') { - // This message type currently doesn't carry the final data itself in background.js, - // the CHAT_RESPONSE_CHUNK with isFinal=true does. - // So, we just log it. The promise should be resolved by the final CHUNK. - console.log(`SERVER: Received CHAT_RESPONSE_STREAM_ENDED for requestId: ${data.requestId}. No action taken as final data comes in CHUNK.`); + console.log(`SERVER.TS: Received CHAT_RESPONSE_STREAM_ENDED for requestId: ${data.requestId} from socketId ${currentSocketId}. No action as final data comes in CHUNK.`); return; } else { - console.log(`SERVER: Received unhandled WebSocket message type: ${data.type} for requestId: ${data.requestId}`); + console.log(`SERVER.TS: Unhandled WebSocket message type: ${data.type} for requestId: ${data.requestId} from socketId ${currentSocketId}`); return; } @@ -213,547 +348,270 @@ wss.on('connection', (ws: WebSocket) => { const pendingRequest = pendingRequests.get(requestIdToProcess); if (pendingRequest) { if (isErrorMessage) { - console.error(`SERVER: Rejecting request ${requestIdToProcess} with error: ${responseDataToUse}`); + console.error(`SERVER.TS: Rejecting request ${requestIdToProcess} with error: ${responseDataToUse}`); pendingRequest.reject(new Error(responseDataToUse || "Error from extension")); } else { - console.log(`SERVER: Resolving request ${requestIdToProcess} with data (first 100 chars): ${(responseDataToUse || "").substring(0, 100)}`); + console.log(`SERVER.TS: Resolving request ${requestIdToProcess} with data (first 100 chars): ${(responseDataToUse || "").substring(0,100)}`); pendingRequest.resolve(responseDataToUse); } pendingRequests.delete(requestIdToProcess); - console.log(`SERVER: Request ${requestIdToProcess} ${isErrorMessage ? 'rejected' : 'resolved'} and removed from pending.`); + if (activeExtensionProcessingId === requestIdToProcess) { // Check if this was the active request + activeExtensionProcessingId = null; + activeExtensionSocketId = null; // Clear the socket ID + console.log(`SERVER.TS: Request ${requestIdToProcess} ${isErrorMessage ? 'rejected' : 'resolved'} and removed. Extension and socket ID freed.`); + } else { + console.log(`SERVER.TS: Request ${requestIdToProcess} ${isErrorMessage ? 'rejected' : 'resolved'} and removed. It was not the primary active request (${activeExtensionProcessingId}).`); + } + processNextInQueue(); } else { - console.warn(`SERVER: Received response for unknown or timed-out requestId: ${requestIdToProcess}. No pending request found.`); + console.warn(`SERVER.TS: Received response for unknown/timed-out requestId: ${requestIdToProcess} from socketId ${currentSocketId}.`); } } else { - // This case should ideally not be reached if the above 'if/else if' for types is exhaustive for messages carrying a requestId. - console.warn(`SERVER: Received WebSocket message but could not determine requestId to process. Type: ${data.type}, Full Data:`, data); + console.warn(`SERVER.TS: WebSocket message received from socketId ${currentSocketId} but no requestId. Type: ${data.type}`); } } catch (error) { - console.error('SERVER: Error processing WebSocket message:', error, 'Raw message:', message.toString()); + const currentSocketIdForError = (ws as any).socketId; + console.error(`SERVER.TS: Error processing WebSocket message from socketId ${currentSocketIdForError}:`, error, 'Raw message:', message.toString()); } }); - // Handle disconnection ws.on('close', () => { - console.log('Browser extension disconnected'); - activeConnections = activeConnections.filter(conn => conn !== ws); + const closedSocketId = (ws as any).socketId; + console.log(`SERVER.TS: Browser extension with socketId: ${closedSocketId} disconnected`); + activeConnections = activeConnections.filter(conn => (conn as any).socketId !== closedSocketId); + + if (closedSocketId && closedSocketId === activeExtensionSocketId) { + console.log(`SERVER.TS: Extension ${closedSocketId} was processing request ${activeExtensionProcessingId}. Attempting to cancel.`); + if (activeExtensionProcessingId !== null) { + const pendingRequest = pendingRequests.get(activeExtensionProcessingId); + if (pendingRequest) { + const errorMessage = `Request ${activeExtensionProcessingId} cancelled: Extension ${closedSocketId} disconnected.`; + console.log(`SERVER.TS: ${errorMessage}`); + pendingRequest.reject(new Error(errorMessage)); + pendingRequests.delete(activeExtensionProcessingId); + } + logAdminMessage('REQUEST_CANCELLED_DISCONNECT', activeExtensionProcessingId, { + reason: 'Extension disconnected', + socketId: closedSocketId + }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + + activeExtensionProcessingId = null; + activeExtensionSocketId = null; + console.log(`SERVER.TS: Freed up extension. activeExtensionProcessingId is now null. Processing next in queue.`); + processNextInQueue(); + } else { + console.log(`SERVER.TS: Extension ${closedSocketId} disconnected, but no activeExtensionProcessingId was set for it. No request to cancel.`); + } + } else { + console.log(`SERVER.TS: Extension ${closedSocketId} disconnected, but it was not the active processor (active is ${activeExtensionSocketId}). No request cancellation needed from this event.`); + } + }); + ws.on('error', (error) => { + console.error('SERVER.TS: WebSocket error:', error); }); }); -// Function to log admin messages to in-memory store -async function logAdminMessage( - type: AdminLogEntry['type'], // Use the more specific type from AdminLogEntry - requestId: string | number, - data: AdminLogDataType // Use the specific union type for data -): Promise { + +async function logAdminMessage(type: AdminLogEntry['type'], requestId: string | number, data: AdminLogDataType): Promise { const timestamp = new Date().toISOString(); - - // For debugging, let's log what's being passed to logAdminMessage - // console.log(`LOGGING [${type}] ReqID [${requestId}]:`, JSON.stringify(data, null, 2)); - - const logEntry: AdminLogEntry = { - timestamp, - type, - requestId: String(requestId), - data, - }; - + const logEntry: AdminLogEntry = { timestamp, type, requestId: String(requestId), data }; adminMessageHistory.unshift(logEntry); - if (adminMessageHistory.length > MAX_ADMIN_HISTORY_LENGTH) { - adminMessageHistory = adminMessageHistory.slice(0, MAX_ADMIN_HISTORY_LENGTH); + adminMessageHistory.pop(); } } -// Define processRequest -async function processRequest(queuedItem: QueuedRequest): Promise { +async function processOrQueueRequest(queuedItem: QueuedRequest): Promise { const { requestId, req, res, userMessage, model, temperature, max_tokens } = queuedItem; - activeExtensionProcessingId = requestId; - logAdminMessage('CHAT_REQUEST_PROCESSING', requestId, { status: 'Sending to extension', activeExtensionProcessingId }) - .catch(err => console.error("ADMIN_LOG_ERROR (CHAT_REQUEST_PROCESSING):", err)); - console.log(`SERVER: Processing request ${requestId}. ActiveExtensionProcessingId: ${activeExtensionProcessingId}`); + logAdminMessage('CHAT_REQUEST_RECEIVED', requestId, { + fromClient: userMessage, + modelSettings: { model, temperature, max_tokens }, + currentActiveExtensionProcessingId: activeExtensionProcessingId, + newRequestBehaviorSetting: newRequestBehavior + }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + + if (activeConnections.length === 0) { + console.log(`SERVER.TS: Request ${requestId} - No extension. Responding 503.`); + logAdminMessage('CHAT_REQUEST_ERROR', requestId, { reason: "No extension" }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + if (!res.headersSent) res.status(503).json({ error: { message: "No browser extension connected." } }); + return; + } + + if (activeExtensionProcessingId !== null) { + if (newRequestBehavior === 'drop') { + console.log(`SERVER.TS: Request ${requestId} dropped (extension busy with ${activeExtensionProcessingId}).`); + logAdminMessage('CHAT_REQUEST_DROPPED', requestId, { reason: "Extension busy" }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + if (!res.headersSent) res.status(429).json({ error: { message: "Too Many Requests: Extension busy." } }); + return; + } else { + requestQueue.push(queuedItem); + console.log(`SERVER.TS: Request ${requestId} queued. Position: ${requestQueue.length}.`); + logAdminMessage('CHAT_REQUEST_QUEUED', requestId, { queuePosition: requestQueue.length }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + return; + } + } + + activeExtensionProcessingId = requestId; + const extension = activeConnections[0]; + activeExtensionSocketId = (extension as any).socketId; + console.log(`SERVER.TS: Processing request ${requestId} directly. ActiveID: ${activeExtensionProcessingId}, Assigned to Extension Socket: ${activeExtensionSocketId}`); + logAdminMessage('CHAT_REQUEST_PROCESSING', requestId, { status: 'Sending to extension', socketId: activeExtensionSocketId }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); try { - if (activeConnections.length === 0) { - console.error(`SERVER: No active extension connection for request ${requestId} during processing.`); - logAdminMessage('CHAT_REQUEST_ERROR', requestId, { - reason: "No extension connected during processing attempt", - activeExtensionProcessingId - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_REQUEST_ERROR):", err)); - if (!res.headersSent) { - res.status(503).json({ - error: { - message: "No browser extension connected when attempting to process request.", - type: "server_error", - code: "no_extension_during_processing" - } - }); - } - return; // Exit early, finally block will call finishProcessingRequest - } - const responsePromise = new Promise((resolve, reject) => { pendingRequests.set(requestId, { resolve, reject }); setTimeout(() => { if (pendingRequests.has(requestId)) { + const timedOutRequest = pendingRequests.get(requestId); + if (timedOutRequest) { + console.log(`SERVER.TS: Request ${requestId} timed out after ${currentRequestTimeoutMs}ms.`); + timedOutRequest.reject(new Error(`Request ${requestId} timed out`)); + } pendingRequests.delete(requestId); - console.log(`SERVER: Request ${requestId} timed out after ${currentRequestTimeoutMs}ms during active processing. Rejecting promise.`); - reject(new Error('Request timed out during active processing')); + + logAdminMessage('REQUEST_CANCELLED_TIMEOUT', requestId, { + reason: `Timed out after ${currentRequestTimeoutMs}ms`, + socketId: activeExtensionSocketId + }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + + if (activeExtensionProcessingId === requestId) { + console.log(`SERVER.TS: Timed out request ${requestId} was the active one. Clearing active state.`); + activeExtensionProcessingId = null; + activeExtensionSocketId = null; + processNextInQueue(); + } } }, currentRequestTimeoutMs); }); - const extension = activeConnections[0]; - const messageToExtension: WebSocketMessage = { - type: 'SEND_CHAT_MESSAGE', - requestId, - message: userMessage, - settings: { model, temperature, max_tokens } - }; - + // const extension = activeConnections[0]; // Moved up to set activeExtensionSocketId + const messageToExtension: WebSocketMessage = { type: 'SEND_CHAT_MESSAGE', requestId, message: userMessage, settings: { model, temperature, max_tokens } }; extension.send(JSON.stringify(messageToExtension)); - console.log(`SERVER: Request ${requestId} sent to browser extension via processRequest.`); + console.log(`SERVER.TS: Request ${requestId} sent to extension.`); const responseData = await responsePromise; - const formattedResponse = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), + id: `chatcmpl-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: model || "relay-model", choices: [{ index: 0, message: { role: "assistant", content: responseData }, finish_reason: "stop" }], usage: { prompt_tokens: -1, completion_tokens: -1, total_tokens: -1 } }; - - logAdminMessage('CHAT_RESPONSE_SENT', requestId, { - fromExtension: responseData, - toClient: formattedResponse, - status: "Success (processed)" - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_RESPONSE_SENT):", err)); - console.log(`SERVER: Request ${requestId} - Sending formatted response to client from processRequest.`); - - if (!res.headersSent) { - res.json(formattedResponse); - } + logAdminMessage('CHAT_RESPONSE_SENT', requestId, { fromExtension: responseData, toClient: formattedResponse, status: "Success" }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + if (!res.headersSent) res.json(formattedResponse); } catch (error: any) { - console.error(`SERVER: Error in processRequest for ${requestId}:`, error); - const errorResponseJson = { - message: error.message || "Unknown error during request processing.", - type: "server_error", - code: error.message === 'Request timed out during active processing' ? "timeout_during_processing" : "processing_error", - requestId - }; - logAdminMessage('CHAT_ERROR_RESPONSE_SENT', requestId, { - toClientError: errorResponseJson, - status: `Error in processRequest: ${error.message}` - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_ERROR_RESPONSE_SENT):", err)); - - if (!res.headersSent) { - res.status(500).json({ error: errorResponseJson }); - } + console.error(`SERVER.TS: Error processing request ${requestId}:`, error); + logAdminMessage('CHAT_ERROR_RESPONSE_SENT', requestId, { toClientError: { message: error.message }, status: "Error" }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + if (!res.headersSent) res.status(500).json({ error: { message: error.message || "Error processing request." } }); } finally { - finishProcessingRequest(requestId); + const currentActiveId = activeExtensionProcessingId; // Capture current state before any changes + + if (currentActiveId === requestId) { + // This path is typically hit if the request timed out on the server-side + // before the extension sent any final message, and no other handler (ws.onmessage, ws.onclose) + // has cleared the active ID for this specific request yet. + console.log(`SERVER.TS: Request ${requestId} (which was active) finalizing. Clearing active state in 'finally' block.`); + activeExtensionProcessingId = null; + activeExtensionSocketId = null; + } else if (currentActiveId === null) { + // This means activeExtensionProcessingId was already cleared, likely by the WebSocket 'onmessage' + // handler (if extension sent a response/error) or by the 'onclose' handler (if extension disconnected). + console.log(`SERVER.TS: Request ${requestId} finalizing. Active processing ID was already null (cleared by ws.onmessage or ws.onclose).`); + } else { // currentActiveId is not null AND not equal to requestId + console.log(`SERVER.TS: Request ${requestId} finalizing, but a different request (${currentActiveId}) is currently active. No change to active state for ${currentActiveId}.`); + } + + pendingRequests.delete(requestId); + console.log(`SERVER.TS: Request ${requestId} removed from pendingRequests map. Calling processNextInQueue.`); + processNextInQueue(); } } -function finishProcessingRequest(completedRequestId: number): void { - activeExtensionProcessingId = null; - pendingRequests.delete(completedRequestId); - console.log(`SERVER: Processing finished for requestId: ${completedRequestId}. Extension is now free.`); - - if (newRequestBehavior === 'queue' && requestQueue.length > 0) { +function processNextInQueue() { + if (activeExtensionProcessingId === null && requestQueue.length > 0) { const nextRequest = requestQueue.shift(); if (nextRequest) { - console.log(`SERVER: Dequeuing request ${nextRequest.requestId}. Queue length: ${requestQueue.length}`); - logAdminMessage('CHAT_REQUEST_DEQUEUED', nextRequest.requestId, { - queueLength: requestQueue.length, - dequeuedRequestId: nextRequest.requestId - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_REQUEST_DEQUEUED):", err)); - - processRequest(nextRequest).catch((error: Error) => { - console.error(`SERVER: Error processing dequeued request ${nextRequest.requestId}:`, error); - if (!nextRequest.res.headersSent) { - nextRequest.res.status(500).json({ - error: { - message: `Failed to process dequeued request: ${error.message || 'Unknown error'}`, - type: "server_error", - code: "dequeued_request_processing_failed", - requestId: nextRequest.requestId - } - }); - } - }); + console.log(`SERVER.TS: Dequeuing request ${nextRequest.requestId}. Queue length: ${requestQueue.length}`); + logAdminMessage('CHAT_REQUEST_DEQUEUED', nextRequest.requestId, { queueLength: requestQueue.length }).catch(err => console.error("ADMIN_LOG_ERROR:", err)); + processOrQueueRequest(nextRequest).catch(e => console.error("Error from dequeued processOrQueueRequest:", e)); } } } -// Create API router const apiRouter: Router = express.Router(); -// OpenAI-compatible chat completions endpoint + apiRouter.post('/chat/completions', async (req: Request, res: Response): Promise => { const requestId = requestCounter++; const { messages, model, temperature, max_tokens } = req.body; const userMessage = messages[messages.length - 1].content; - - // Log initial receipt and intended action - let initialActionLog = 'DirectProcessing'; - if (activeExtensionProcessingId !== null) { - initialActionLog = newRequestBehavior === 'queue' ? 'AttemptQueue' : 'AttemptDrop'; - } - logAdminMessage('CHAT_REQUEST_RECEIVED', requestId, { - fromClient: userMessage, - modelSettings: { model, temperature, max_tokens }, - initialAction: initialActionLog, // Use the determined log value - currentActiveExtensionProcessingId: activeExtensionProcessingId, - newRequestBehaviorSetting: newRequestBehavior - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_REQUEST_RECEIVED):", err)); - console.log(`SERVER: Request ${requestId} received. Initial Action: ${initialActionLog}. Active ID: ${activeExtensionProcessingId}, Behavior: ${newRequestBehavior}`); - - if (activeConnections.length === 0) { - logAdminMessage('CHAT_REQUEST_ERROR', requestId, { - reason: "No extension connected at time of request", - clientMessage: userMessage, - details: "Response 503 sent to client." - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_REQUEST_ERROR):", err)); - console.log(`SERVER: Request ${requestId} - No browser extension connected. Responding 503.`); - if (!res.headersSent) { - res.status(503).json({ - error: { - message: "No browser extension connected. Please open the chat interface and ensure the extension is active.", - type: "server_error", - code: "no_extension_connected" - } - }); - } - return; - } - - const queuedItem: QueuedRequest = { - requestId, - req, - res, - userMessage, - model, - temperature, - max_tokens - }; - - if (activeExtensionProcessingId !== null) { // Extension is busy - if (newRequestBehavior === 'drop') { - logAdminMessage('CHAT_REQUEST_DROPPED', requestId, { - reason: "Extension busy", - droppedForRequestId: activeExtensionProcessingId, - clientMessage: userMessage, - details: "Response 429 sent to client." - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_REQUEST_DROPPED):", err)); - console.log(`SERVER: Request ${requestId} dropped as extension is busy with ${activeExtensionProcessingId}.`); + const queuedItem: QueuedRequest = { requestId, req, res, userMessage, model, temperature, max_tokens }; + processOrQueueRequest(queuedItem).catch(e => { + console.error(`SERVER.TS: Unhandled error from processOrQueueRequest in /chat/completions for ${requestId}:`, e); if (!res.headersSent) { - res.status(429).json({ - error: { - message: "Too Many Requests: Extension is currently busy. Please try again later.", - type: "client_error", - code: "extension_busy_request_dropped" - } - }); + res.status(500).json({ error: { message: "Internal server error handling your request." } }); } - return; - } - - if (newRequestBehavior === 'queue') { - requestQueue.push(queuedItem); - logAdminMessage('CHAT_REQUEST_QUEUED', requestId, { - queuePosition: requestQueue.length, - queuedForRequestId: activeExtensionProcessingId, - clientMessage: userMessage, - queueLength: requestQueue.length - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_REQUEST_QUEUED):", err)); - console.log(`SERVER: Request ${requestId} queued. Position: ${requestQueue.length}. Extension busy with: ${activeExtensionProcessingId}`); - // Do NOT send a response yet, the 'res' object is stored in the queue. - return; - } - } - - // If extension is free (activeExtensionProcessingId is null) - // processRequest will handle its own errors and responses including calling res.json() or res.status().json() - processRequest(queuedItem).catch(error => { - // This catch is a safety net if processRequest itself throws an unhandled error *before* it can send a response. - console.error(`SERVER: Unhandled error from processRequest for ${requestId} in /chat/completions:`, error); - logAdminMessage('CHAT_ERROR_RESPONSE_SENT', requestId, { - toClientError: { message: (error as Error).message, type: "server_error", code: "unhandled_processing_catch" }, - status: `Error: ${(error as Error).message}` - }).catch(err => console.error("ADMIN_LOG_ERROR (CHAT_ERROR_RESPONSE_SENT):", err)); - if (!res.headersSent) { - res.status(500).json({ - error: { - message: `Internal server error during request processing: ${(error as Error).message || 'Unknown error'}`, - type: "server_error", - code: "unhandled_processing_error_in_handler", - requestId: requestId - } - }); - } }); }); -// Models endpoint -apiRouter.get('/models', (req: Request, res: Response, next: NextFunction) => { - try { - res.json({ - object: "list", - data: [ - { - id: "gemini-pro", - object: "model", - created: 1677610602, - owned_by: "relay" - }, - { - id: "claude-3", - object: "model", - created: 1677610602, - owned_by: "relay" - } - ] - }); - } catch (error) { - next(error); - } + +apiRouter.get('/models', (req: Request, res: Response) => { + res.json({ + object: "list", + data: [ + { id: "gemini-pro", object: "model", created: 1677610602, owned_by: "relay" }, + { id: "claude-3", object: "model", created: 1677610602, owned_by: "relay" } + ] + }); }); -// Endpoint to retrieve message history for Admin UI -apiRouter.get('/admin/message-history', (req: Request, res: Response): void => { // No longer async +apiRouter.get('/admin/message-history', (req: Request, res: Response): void => { try { - // Return the latest 100 entries (or fewer if less than 100 exist) const historyToReturn = adminMessageHistory.slice(0, 100); - res.json(historyToReturn); // Objects are already parsed + res.json(historyToReturn); } catch (error) { - console.error('Error fetching message history from in-memory store:', error); - if (!res.headersSent) { - res.status(500).json({ - error: { - message: (error instanceof Error ? error.message : String(error)) || 'Failed to retrieve message history', - type: 'server_error', // Changed from redis_error - code: 'history_retrieval_failed' - } - }); - } + console.error('SERVER.TS: Error fetching message history:', error); + if (!res.headersSent) res.status(500).json({ error: 'Failed to retrieve message history' }); } }); -// Endpoint to provide server configuration and status apiRouter.get('/admin/server-info', (req: Request, res: Response): void => { try { const uptimeSeconds = Math.floor((Date.now() - serverStartTime) / 1000); const serverInfo = { - port: PORT, - requestTimeoutMs: currentRequestTimeoutMs, // Report the current mutable value - newRequestBehavior: newRequestBehavior, // Add the current behavior - autoKillPort: autoKillPort, // Add the current autoKillPort setting - pingIntervalMs: null, // Placeholder - No explicit ping interval defined for client pings + port: serverPortForListen, + requestTimeoutMs: currentRequestTimeoutMs, + newRequestBehavior: newRequestBehavior, + pingIntervalMs: null, connectedExtensionsCount: activeConnections.length, uptimeSeconds: uptimeSeconds, }; res.json(serverInfo); } catch (error) { - console.error('Error fetching server info:', error); - if (!res.headersSent) { - res.status(500).json({ - error: { - message: (error instanceof Error ? error.message : String(error)) || 'Failed to retrieve server info', - type: 'server_error', - code: 'server_info_failed' - } - }); - } + console.error('SERVER.TS: Error fetching server info:', error); + if (!res.headersSent) res.status(500).json({ error: 'Failed to retrieve server info' }); } }); -// Endpoint to restart the server apiRouter.post('/admin/restart-server', (req: Request, res: Response): void => { - console.log('ADMIN: Received request to restart server.'); - // Removed premature res.json() call that was here. - - // Log absolute paths for debugging - const absoluteConfigPath = path.resolve(CONFIG_FILE_PATH); - const absoluteTriggerPath = path.resolve(RESTART_TRIGGER_FILE_PATH); - console.log(`ADMIN: Config file path (absolute): ${absoluteConfigPath}`); - console.log(`ADMIN: Trigger file path (absolute): ${absoluteTriggerPath}`); - - try { - // 1. Update and save server-config.json - const configToSave = loadServerConfig(); - configToSave.lastRestartRequestTimestamp = Date.now(); - saveServerConfig(configToSave); // This function already has its own try/catch and logs - console.log('ADMIN: Attempted to update server-config.json.'); - - // 2. Explicitly touch/create the .triggerrestart file. - try { - fs.writeFileSync(RESTART_TRIGGER_FILE_PATH, Date.now().toString(), 'utf-8'); - console.log(`ADMIN: Successfully wrote to restart trigger file: ${absoluteTriggerPath}`); - } catch (triggerFileError) { - console.error(`ADMIN: FAILED to write restart trigger file at ${absoluteTriggerPath}:`, triggerFileError); - } - } catch (outerError) { - // This catch is for errors in loadServerConfig or if saveServerConfig itself throws unexpectedly - console.error('ADMIN: Error in outer try block during restart sequence (e.g., loading config):', outerError); - } - - // 3. Send response to client - res.status(200).json({ message: 'Server restart initiated. Nodemon should pick up file changes.' }); - - // 4. Exit the process after a longer delay. - setTimeout(() => { - console.log('ADMIN: Exiting process for nodemon to restart.'); - process.exit(0); - }, 1500); // Increased delay to 1.5 seconds + console.log('SERVER.TS: Received request to restart server via /v1/admin/restart-server.'); + res.status(200).json({ message: 'Restart should be handled by nodemon if .env file changes.' }); }); -// The more comprehensive update-settings endpoint below handles both port and requestTimeoutMs. -apiRouter.post('/admin/update-settings', (req: Request, res: Response): void => { - const { requestTimeoutMs, port, newRequestBehavior: newBehaviorValue, autoKillPort: newAutoKillPortValue } = req.body; - let configChanged = false; - let messages: string[] = []; - - const currentConfig = loadServerConfig(); // Load current disk config to preserve other settings - - if (requestTimeoutMs !== undefined) { - const newTimeout = parseInt(String(requestTimeoutMs), 10); - if (!isNaN(newTimeout) && newTimeout > 0) { - currentRequestTimeoutMs = newTimeout; // Update in-memory value immediately - currentConfig.requestTimeoutMs = newTimeout; // Update config for saving - configChanged = true; - messages.push(`Request timeout updated in memory to ${currentRequestTimeoutMs}ms. This change is effective immediately.`); - logAdminMessage('SETTING_UPDATE', 'SERVER_CONFIG', { setting: 'requestTimeoutMs', value: currentRequestTimeoutMs }) - .catch(err => console.error("ADMIN_LOG_ERROR (SETTING_UPDATE):", err)); - } else { - res.status(400).json({ error: 'Invalid requestTimeoutMs value. Must be a positive number.' }); - return; - } - } - - if (port !== undefined) { - const newPort = parseInt(String(port), 10); - if (!isNaN(newPort) && newPort > 0 && newPort <= 65535) { - currentConfig.port = newPort; // Update config for saving - configChanged = true; - messages.push(`Server port configured to ${newPort}. This change will take effect after server restart.`); - logAdminMessage('SETTING_UPDATE', 'SERVER_CONFIG', { setting: 'port', value: newPort, requiresRestart: true }) - .catch(err => console.error("ADMIN_LOG_ERROR (SETTING_UPDATE):", err)); - } else { - res.status(400).json({ error: 'Invalid port value. Must be a positive number between 1 and 65535.' }); - return; - } - } - - if (newBehaviorValue !== undefined) { - if (newBehaviorValue === 'queue' || newBehaviorValue === 'drop') { - newRequestBehavior = newBehaviorValue; // Update in-memory value immediately - currentConfig.newRequestBehavior = newBehaviorValue; // Update config for saving - configChanged = true; - messages.push(`New request behavior updated to '${newRequestBehavior}'. This change is effective immediately.`); - logAdminMessage('SETTING_UPDATE', 'SERVER_CONFIG', { setting: 'newRequestBehavior', value: newRequestBehavior }) - .catch(err => console.error("ADMIN_LOG_ERROR (SETTING_UPDATE newRequestBehavior):", err)); - } else { - res.status(400).json({ error: "Invalid newRequestBehavior value. Must be 'queue' or 'drop'." }); - return; - } - } - - if (newAutoKillPortValue !== undefined) { - if (typeof newAutoKillPortValue === 'boolean') { - autoKillPort = newAutoKillPortValue; // Update in-memory value immediately - currentConfig.autoKillPort = newAutoKillPortValue; // Update config for saving - configChanged = true; - messages.push(`Auto-kill port setting updated to '${autoKillPort}'. This change is effective on next server startup if port conflict occurs.`); - logAdminMessage('SETTING_UPDATE', 'SERVER_CONFIG', { setting: 'autoKillPort', value: autoKillPort, requiresRestartToSeeEffect: true }) - .catch(err => console.error("ADMIN_LOG_ERROR (SETTING_UPDATE autoKillPort):", err)); - } else { - res.status(400).json({ error: "Invalid autoKillPort value. Must be a boolean." }); - return; - } - } - - if (configChanged) { - saveServerConfig(currentConfig); - res.json({ message: messages.join(' ') }); - } else { - res.status(400).json({ error: 'No valid settings provided or no changes made.' }); - } -}); - -// Health check endpoint -app.get('/health', (req: Request, res: Response) => { - res.status(200).json({ status: 'OK', activeBrowserConnections: activeConnections.length }); -}); - -// Mount the API router app.use('/v1', apiRouter); -// Function to handle port conflict before starting the server -function handlePortConflict(portToFree: number, killProcess: boolean): void { - if (!killProcess) { - console.log(`Auto-kill for port ${portToFree} is disabled. Will not attempt to free port.`); - return; - } - - console.log(`Checking if port ${portToFree} is in use...`); - try { - // Command to find process using the port (Windows specific) - const command = `netstat -ano -p TCP | findstr ":${portToFree}.*LISTENING"`; - const output = execSync(command, { encoding: 'utf-8' }); - - if (output) { - console.log(`Port ${portToFree} is in use. Output:\n${output}`); - // Extract PID - Example: TCP 0.0.0.0:3003 0.0.0.0:0 LISTENING 12345 - // PID is the last number on the line. - const lines = output.trim().split('\n'); - if (lines.length > 0) { - const firstLine = lines[0]; - const parts = firstLine.trim().split(/\s+/); - const pid = parts[parts.length - 1]; - - if (pid && !isNaN(parseInt(pid))) { - console.log(`Attempting to kill process with PID: ${pid} using port ${portToFree}`); - try { - execSync(`taskkill /PID ${pid} /F`); - console.log(`Successfully killed process ${pid} using port ${portToFree}.`); - logAdminMessage('PORT_KILLED', `PORT_${portToFree}`, { port: portToFree, pid: pid, status: 'success' }) - .catch(err => console.error("ADMIN_LOG_ERROR (PORT_KILLED):", err)); - } catch (killError) { - console.error(`Failed to kill process ${pid} using port ${portToFree}:`, killError); - logAdminMessage('PORT_KILL_FAILED', `PORT_${portToFree}`, { port: portToFree, pid: pid, status: 'failure', error: (killError as Error).message }) - .catch(err => console.error("ADMIN_LOG_ERROR (PORT_KILL_FAILED):", err)); - } - } else { - console.warn(`Could not extract a valid PID for port ${portToFree} from netstat output: ${firstLine}`); - } - } else { - console.log(`No process found listening on port ${portToFree} from netstat output.`); - } - } else { - console.log(`Port ${portToFree} is free.`); - } - } catch (error: any) { - // If findstr returns an error, it usually means the port is not found / not in use. - if (error.status === 1) { // findstr exits with 1 if string not found - console.log(`Port ${portToFree} appears to be free (netstat/findstr did not find it).`); - } else { - console.error(`Error checking port ${portToFree}:`, error.message); - } - } -} - -// Start the server -async function startServer() { - // Handle potential port conflict before starting the server - handlePortConflict(PORT, autoKillPort); - - 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}`); - }); -} - -startServer().catch(err => { - console.error("Failed to start server:", err); - process.exit(1); +app.get('/health', (req: Request, res: Response) => { + const aliveConnections = activeConnections.filter(conn => conn.readyState === WebSocket.OPEN); + res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + activeBrowserConnections: aliveConnections.length, + totalTrackedBrowserConnections: activeConnections.length, + webSocketServerState: wss.options.server?.listening ? 'listening' : 'not_listening' + }); }); + +server.listen(serverPortForListen, () => { + console.log(`SERVER.TS: OpenAI-compatible relay server started and listening on port ${serverPortForListen}`); + console.log(`SERVER.TS: WebSocket server for browser extensions running on ws://localhost:${serverPortForListen}`); + console.log(`SERVER.TS: Admin UI should be available at http://localhost:${serverPortForListen}/admin/admin.html`); +}); + +export default server; diff --git a/api-relay-server/tsconfig.json b/api-relay-server/tsconfig.json index bd3feae..f1b423f 100644 --- a/api-relay-server/tsconfig.json +++ b/api-relay-server/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "allowJs": true // Add this line }, "include": [ "src/**/*" diff --git a/extension/background.js b/extension/background.js index fdc947c..fc16a48 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,7 +1,27 @@ +/* + * 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/. + */ +// AI Chat Relay - Background Script + +// Default settings const DEFAULT_SETTINGS = { - serverHost: 'localhost', - serverPort: 3003, - serverProtocol: 'ws' + serverHost: 'localhost', + serverPort: 3003, + serverProtocol: 'ws' }; let relaySocket = null; @@ -9,783 +29,784 @@ let reconnectInterval = 5000; let reconnectTimer = null; let activeTabId = null; let serverUrl = ''; -let lastRequestId = null; -let processingRequest = false; -let pendingRequests = []; -let lastSuccessfullyProcessedMessageText = null; -const pendingRequestDetails = new Map(); -let currentRequestTargetTabId = null; +let lastRequestId = null; // User's global lastRequestId +let processingRequest = false; // User's global processing flag +let pendingRequests = []; // User's command queue +let lastSuccessfullyProcessedMessageText = null; // Text of the last message successfully processed (AI response or duplicate handled) +const pendingRequestDetails = new Map(); // Stores { text: string } for active requests, keyed by requestId +// Supported domains for chat interfaces const supportedDomains = ['gemini.google.com', 'aistudio.google.com', 'chatgpt.com', 'claude.ai']; +// ===== DEBUGGER RELATED GLOBALS ===== const BG_LOG_PREFIX = '[BG DEBUGGER]'; -let debuggerAttachedTabs = new Map(); +let debuggerAttachedTabs = new Map(); // tabId -> { providerName, patterns, isFetchEnabled, isAttached, lastKnownRequestId } +// Load settings and connect to the relay server function loadSettingsAndConnect() { - console.log("BACKGROUND: Loading settings and connecting to relay server"); - chrome.storage.sync.get(DEFAULT_SETTINGS, (items) => { - serverUrl = `${items.serverProtocol}://${items.serverHost}:${items.serverPort}`; - console.log("BACKGROUND: Using server URL:", serverUrl); - connectToRelayServer(); + console.log("BACKGROUND: Loading settings and connecting to relay server"); + chrome.storage.sync.get(DEFAULT_SETTINGS, (items) => { + serverUrl = `${items.serverProtocol}://${items.serverHost}:${items.serverPort}`; + console.log("BACKGROUND: Using server URL:", serverUrl); + connectToRelayServer(); + }); +} + +// Connect to the relay server +function connectToRelayServer() { + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + console.log("BACKGROUND: Relay WS: Already connected."); + return; + } + + if (!navigator.onLine) { + console.warn("BACKGROUND: Network offline. Deferring connection attempt."); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(connectToRelayServer, reconnectInterval); + return; + } + + const healthCheckUrl = serverUrl.replace(/^ws/, 'http') + '/health'; + console.log("BACKGROUND: Performing HTTP pre-check to", healthCheckUrl); + + fetch(healthCheckUrl) + .then(response => { + if (!response.ok) { + // Server responded, but not with a 2xx status (e.g., 404, 500) + console.warn(`BACKGROUND: HTTP pre-check to ${healthCheckUrl} received non-OK status: ${response.status}. Server might be having issues. Deferring WebSocket attempt.`); + return Promise.reject(new Error(`Server responded with ${response.status}`)); + } + return response.json(); // Attempt to parse JSON + }) + .then(healthData => { + console.log(`BACKGROUND: HTTP pre-check to ${healthCheckUrl} successful. Server status: ${healthData.status}, Active Connections: ${healthData.activeBrowserConnections}. Proceeding with WebSocket connection.`); + attemptWebSocketConnection(); + }) + .catch(fetchError => { + // This catches network errors (server down) or errors from the .then() chain (non-OK response, JSON parse error) + console.warn(`BACKGROUND: HTTP pre-check to ${healthCheckUrl} failed: ${fetchError.message}. Server is likely down, unreachable, or health endpoint is misbehaving. Deferring WebSocket attempt.`); + relaySocket = null; + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(connectToRelayServer, reconnectInterval); }); } -function connectToRelayServer() { - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - console.log("BACKGROUND: Relay WS: Already connected."); - return; - } - - if (!navigator.onLine) { - console.warn("BACKGROUND: Network offline. Deferring connection attempt."); - if (reconnectTimer) clearTimeout(reconnectTimer); - reconnectTimer = setTimeout(connectToRelayServer, reconnectInterval); - return; - } - - const healthCheckUrl = serverUrl.replace(/^ws/, 'http') + '/health'; - console.log("BACKGROUND: Performing HTTP pre-check to", healthCheckUrl); - - fetch(healthCheckUrl) - .then(response => { - if (!response.ok) { - console.warn(`BACKGROUND: HTTP pre-check to ${healthCheckUrl} received non-OK status: ${response.status}. Server might be having issues. Deferring WebSocket attempt.`); - return Promise.reject(new Error(`Server responded with ${response.status}`)); - } - return response.json(); - }) - .then(healthData => { - console.log(`BACKGROUND: HTTP pre-check to ${healthCheckUrl} successful. Server status: ${healthData.status}, Active Connections: ${healthData.activeBrowserConnections}. Proceeding with WebSocket connection.`); - attemptWebSocketConnection(); - }) - .catch(fetchError => { - console.warn(`BACKGROUND: HTTP pre-check to ${healthCheckUrl} failed: ${fetchError.message}. Server is likely down, unreachable, or health endpoint is misbehaving. Deferring WebSocket attempt.`); - relaySocket = null; - if (reconnectTimer) clearTimeout(reconnectTimer); - reconnectTimer = setTimeout(connectToRelayServer, reconnectInterval); - }); -} - function attemptWebSocketConnection() { - console.log("BACKGROUND: Relay WS: Attempting to connect to", serverUrl); - try { - relaySocket = new WebSocket(serverUrl); + console.log("BACKGROUND: Relay WS: Attempting to connect to", serverUrl); + try { + relaySocket = new WebSocket(serverUrl); - relaySocket.onopen = () => { - console.log("BACKGROUND: Relay WS: Connection established with relay server."); - reconnectInterval = 5000; - if (reconnectTimer) clearTimeout(reconnectTimer); - reconnectTimer = null; - }; + relaySocket.onopen = () => { + console.log("BACKGROUND: Relay WS: Connection established with relay server."); + reconnectInterval = 5000; // Reset reconnect interval on successful connection + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; - relaySocket.onmessage = (event) => { - console.log("BACKGROUND: Relay WS: Message received from relay server:", event.data); - try { - const command = JSON.parse(event.data); - if (command.type === 'SEND_CHAT_MESSAGE') { - console.log("BACKGROUND: Received SEND_CHAT_MESSAGE command with requestId:", command.requestId); + // Notify the server that this extension instance is ready + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + const readyMessage = { type: 'EXTENSION_READY' }; + relaySocket.send(JSON.stringify(readyMessage)); + console.log("BACKGROUND: Relay WS: Sent EXTENSION_READY to server."); + } + }; - pendingRequestDetails.set(command.requestId, { - messageContent: command.message, - settings: command.settings - }); - let messagePreview = ""; - const messageValue = command.message; - if (typeof messageValue === 'string') { - messagePreview = `String: "${messageValue.substring(0, 50)}..."`; - } else if (messageValue instanceof ArrayBuffer) { - messagePreview = `ArrayBuffer data (size: ${messageValue.byteLength} bytes)`; - } else if (messageValue instanceof Blob) { - messagePreview = `Blob data (size: ${messageValue.size} bytes, type: ${messageValue.type})`; - } else if (messageValue && typeof messageValue === 'object' && messageValue !== null) { - messagePreview = `Object data (type: ${Object.prototype.toString.call(messageValue)})`; - } else { - messagePreview = `Data type: ${typeof messageValue}, Value: ${String(messageValue).substring(0, 50)}`; - } - console.log(`BACKGROUND: Stored details for requestId: ${command.requestId}, message: ${messagePreview}`); + relaySocket.onmessage = (event) => { + console.log("BACKGROUND: Relay WS: Message received from relay server:", event.data); + try { + const command = JSON.parse(event.data); + if (command.type === 'SEND_CHAT_MESSAGE') { + console.log("BACKGROUND: Received SEND_CHAT_MESSAGE command with requestId:", command.requestId); + + // Store details for this new request + pendingRequestDetails.set(command.requestId, { messageContent: command.message }); // Changed key 'text' to 'messageContent' + let messagePreview = ""; + const messageValue = command.message; + if (typeof messageValue === 'string') { + messagePreview = `String: "${messageValue.substring(0, 50)}..."`; + } else if (messageValue instanceof ArrayBuffer) { + messagePreview = `ArrayBuffer data (size: ${messageValue.byteLength} bytes)`; + } else if (messageValue instanceof Blob) { + messagePreview = `Blob data (size: ${messageValue.size} bytes, type: ${messageValue.type})`; + } else if (messageValue && typeof messageValue === 'object' && messageValue !== null) { + messagePreview = `Object data (type: ${Object.prototype.toString.call(messageValue)})`; + } else { + messagePreview = `Data type: ${typeof messageValue}, Value: ${String(messageValue).substring(0,50)}`; + } + console.log(`BACKGROUND: Stored details for requestId: ${command.requestId}, message: ${messagePreview}`); - pendingRequests.push(command); - console.log(`BACKGROUND: Added command with requestId: ${command.requestId} to queue. Queue length: ${pendingRequests.length}`); + // Add to the queue + pendingRequests.push(command); + console.log(`BACKGROUND: Added command with requestId: ${command.requestId} to queue. Queue length: ${pendingRequests.length}`); + + // Attempt to process the next request in the queue + processNextRequest(); + } + } catch (error) { + console.error("BACKGROUND: Relay WS: Error processing message from relay server:", error); + } + }; - processNextRequest(); - } - } catch (error) { - console.error("BACKGROUND: Relay WS: Error processing message from relay server:", error); - } - }; + relaySocket.onerror = (errorEvent) => { + console.warn("BACKGROUND: Relay WS: WebSocket connection error (event):", errorEvent); + // onclose will typically follow and handle reconnection logic + }; - relaySocket.onerror = (errorEvent) => { - console.warn("BACKGROUND: Relay WS: WebSocket connection error (event):", errorEvent); - }; - - relaySocket.onclose = (closeEvent) => { - console.log(`BACKGROUND: Relay WS: Connection closed (event). Code: ${closeEvent.code}, Reason: '${closeEvent.reason || 'N/A'}', Cleanly: ${closeEvent.wasClean}. Will attempt reconnect (via connectToRelayServer) in ${reconnectInterval / 1000}s.`); - relaySocket = null; - if (reconnectTimer) clearTimeout(reconnectTimer); - reconnectTimer = setTimeout(connectToRelayServer, reconnectInterval); - }; - } catch (instantiationError) { - console.error("BACKGROUND: Relay WS: Error instantiating WebSocket:", instantiationError); - relaySocket = null; - if (reconnectTimer) clearTimeout(reconnectTimer); - console.log(`BACKGROUND: Relay WS: Instantiation failed. Will attempt reconnect (via connectToRelayServer) in ${reconnectInterval / 1000}s.`); - reconnectTimer = setTimeout(connectToRelayServer, reconnectInterval); - } + relaySocket.onclose = (closeEvent) => { + console.log(`BACKGROUND: Relay WS: Connection closed (event). Code: ${closeEvent.code}, Reason: '${closeEvent.reason || 'N/A'}', Cleanly: ${closeEvent.wasClean}. Will attempt reconnect (via connectToRelayServer) in ${reconnectInterval / 1000}s.`); + relaySocket = null; + if (reconnectTimer) clearTimeout(reconnectTimer); + // Retry the entire connectToRelayServer process, which includes the HTTP pre-check + reconnectTimer = setTimeout(connectToRelayServer, reconnectInterval); + }; + } catch (instantiationError) { + console.error("BACKGROUND: Relay WS: Error instantiating WebSocket:", instantiationError); + relaySocket = null; + if (reconnectTimer) clearTimeout(reconnectTimer); + console.log(`BACKGROUND: Relay WS: Instantiation failed. Will attempt reconnect (via connectToRelayServer) in ${reconnectInterval / 1000}s.`); + // Retry the entire connectToRelayServer process + reconnectTimer = setTimeout(connectToRelayServer, reconnectInterval); + } } -async function forwardCommandToContentScript(command) { - try { - console.log("BACKGROUND: Forwarding command to content script:", command); - let targetTabIdForCommand = null; - - if (activeTabId) { - try { - console.log(`BACKGROUND: Attempting to use stored activeTabId: ${activeTabId}`); - await new Promise((resolve, reject) => { - chrome.tabs.sendMessage(activeTabId, { type: "PING_TAB" }, response => { - if (chrome.runtime.lastError || !response || !response.success) { - console.warn(`BACKGROUND: Ping to stored tab ${activeTabId} failed or no ack:`, chrome.runtime.lastError ? chrome.runtime.lastError.message : "No response/success false"); - activeTabId = null; - reject(new Error("Ping failed")); - } else { - console.log(`BACKGROUND: Ping to stored tab ${activeTabId} successful.`); - targetTabIdForCommand = activeTabId; - resolve(); - } - }); - }); - } catch (error) { - console.warn(`BACKGROUND: Error using stored activeTabId ${activeTabId}, will find new tab:`, error); - } - } - - if (!targetTabIdForCommand) { - targetTabIdForCommand = await findAndSendToSuitableTab(command, true); - } - - if (targetTabIdForCommand) { - if (processingRequest && command.requestId === lastRequestId) { - currentRequestTargetTabId = targetTabIdForCommand; - console.log(`BACKGROUND: Set currentRequestTargetTabId to ${targetTabIdForCommand} for active requestId ${lastRequestId}`); - } - const tabInfo = debuggerAttachedTabs.get(targetTabIdForCommand); - if (tabInfo) { - tabInfo.lastKnownRequestId = command.requestId; - console.log(BG_LOG_PREFIX, `Associated requestId ${command.requestId} with tab ${targetTabIdForCommand} for debugger.`); - } else { - console.warn(BG_LOG_PREFIX, `Tab ${targetTabIdForCommand} is not being debugged. Cannot associate requestId for debugger.`); - } - - chrome.tabs.sendMessage(targetTabIdForCommand, command, (response) => { - if (chrome.runtime.lastError) { - console.error(`BACKGROUND: Error sending message to tab ${targetTabIdForCommand}:`, chrome.runtime.lastError.message); - if (lastRequestId === command.requestId) { - processingRequest = false; - } +// Forward commands to content script +async function forwardCommandToContentScript(command) { // command will include original requestId + try { + console.log("BACKGROUND: Forwarding command to content script:", command); + let targetTabIdForCommand = null; + + if (activeTabId) { + try { + console.log(`BACKGROUND: Attempting to use stored activeTabId: ${activeTabId}`); + // Test send to ensure tab is still valid for this command before associating requestId + await new Promise((resolve, reject) => { + chrome.tabs.sendMessage(activeTabId, { type: "PING_TAB" }, response => { // Ping before associating + if (chrome.runtime.lastError || !response || !response.success) { + console.warn(`BACKGROUND: Ping to stored tab ${activeTabId} failed or no ack:`, chrome.runtime.lastError ? chrome.runtime.lastError.message : "No response/success false"); + activeTabId = null; // Invalidate activeTabId + reject(new Error("Ping failed")); } else { - console.log(`BACKGROUND: Content script in tab ${targetTabIdForCommand} acknowledged command:`, response); + console.log(`BACKGROUND: Ping to stored tab ${activeTabId} successful.`); + targetTabIdForCommand = activeTabId; + resolve(); } }); + }); + } catch (error) { + // Fall through to findAndSendToSuitableTab if ping fails + console.warn(`BACKGROUND: Error using stored activeTabId ${activeTabId}, will find new tab:`, error); + } + } + + if (!targetTabIdForCommand) { + targetTabIdForCommand = await findAndSendToSuitableTab(command, true); // Pass true to only find, not send yet + } + if (targetTabIdForCommand) { + const tabInfo = debuggerAttachedTabs.get(targetTabIdForCommand); + if (tabInfo) { + tabInfo.lastKnownRequestId = command.requestId; // Store command's requestId for this specific tab + console.log(BG_LOG_PREFIX, `Associated requestId ${command.requestId} with tab ${targetTabIdForCommand} for debugger.`); } else { - const errorMsg = "Could not find any suitable tab for command."; - console.error(`BACKGROUND: ${errorMsg} for requestId: ${command.requestId}.`); - - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_ERROR", - requestId: command.requestId, - error: errorMsg - })); - console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR to server for requestId: ${command.requestId} (no suitable tab).`); - } else { - console.error(`BACKGROUND: Relay WS not OPEN, cannot send CHAT_RESPONSE_ERROR for requestId: ${command.requestId} (no suitable tab).`); - } - - if (lastRequestId === command.requestId) { - processingRequest = false; - currentRequestTargetTabId = null; - console.log(`BACKGROUND: Reset processingRequest and currentRequestTargetTabId for requestId: ${command.requestId} (no suitable tab).`); - } - processNextRequest(); + console.warn(BG_LOG_PREFIX, `Tab ${targetTabIdForCommand} is not being debugged. Cannot associate requestId for debugger.`); } - } catch (error) { - console.error("BACKGROUND: Error in forwardCommandToContentScript for requestId:", command.requestId, error); + // Now actually send the command + chrome.tabs.sendMessage(targetTabIdForCommand, command, (response) => { + if (chrome.runtime.lastError) { + const errorMessage = `Error sending message to content script in tab ${targetTabIdForCommand}: ${chrome.runtime.lastError.message}`; + console.error(`BACKGROUND: ${errorMessage}`); + + // Send error to server + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_ERROR", + requestId: command.requestId, + error: `Failed to send command to content script (tab ${targetTabIdForCommand}). Detail: ${chrome.runtime.lastError.message}` + })); + console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR to server for requestId: ${command.requestId} (content script send failed).`); + } else { + console.error(`BACKGROUND: Relay WS not OPEN, cannot send CHAT_RESPONSE_ERROR for requestId: ${command.requestId} (content script send failed).`); + } + + if (lastRequestId === command.requestId) { + processingRequest = false; + console.log(`BACKGROUND: Reset processingRequest for requestId: ${command.requestId} (content script send failed).`); + } + // Attempt to process the next request in the queue as this one failed at the background script level + processNextRequest(); + + } else { + console.log(`BACKGROUND: Content script in tab ${targetTabIdForCommand} acknowledged command:`, response); + // If content script acknowledges, it's responsible for sending a response/error back. + // No need to call processNextRequest() here; content script's response will trigger it. + } + }); + + } else { + const errorMsg = "Could not find any suitable tab for command."; + console.error(`BACKGROUND: ${errorMsg} for requestId: ${command.requestId}.`); + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { relaySocket.send(JSON.stringify({ type: "CHAT_RESPONSE_ERROR", requestId: command.requestId, - error: `Internal error in background script while forwarding command: ${error.message}` + error: errorMsg })); - console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR to server for requestId: ${command.requestId} (exception).`); + console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR to server for requestId: ${command.requestId} (no suitable tab).`); } else { - console.error(`BACKGROUND: Relay WS not OPEN, cannot send CHAT_RESPONSE_ERROR for requestId: ${command.requestId} (exception).`); + console.error(`BACKGROUND: Relay WS not OPEN, cannot send CHAT_RESPONSE_ERROR for requestId: ${command.requestId} (no suitable tab).`); } if (lastRequestId === command.requestId) { processingRequest = false; - currentRequestTargetTabId = null; - console.log(`BACKGROUND: Reset processingRequest and currentRequestTargetTabId for requestId: ${command.requestId} (exception).`); + console.log(`BACKGROUND: Reset processingRequest for requestId: ${command.requestId} (no suitable tab).`); } - } -} - -async function findAndSendToSuitableTab(command, justFinding = false) { - try { - console.log("BACKGROUND: Finding suitable tab for command:", command); - const allTabs = await chrome.tabs.query({}); - const matchingTabs = allTabs.filter(tab => { - if (!tab.url) return false; - return supportedDomains.some(domain => tab.url.includes(domain)); - }); - - console.log(`BACKGROUND: Found ${matchingTabs.length} tabs matching supported domains`); - - if (matchingTabs.length > 0) { - const activeMatchingTabs = matchingTabs.filter(tab => tab.active); - const targetTab = activeMatchingTabs.length > 0 ? activeMatchingTabs[0] : matchingTabs[0]; - console.log(`BACKGROUND: Selected tab ${targetTab.id} (${targetTab.url})`); - activeTabId = targetTab.id; - - if (justFinding) { - return targetTab.id; - } - - console.warn("BACKGROUND: findAndSendToSuitableTab called with justFinding=false. Sending is now handled by caller."); - return targetTab.id; - - } else { - console.error("BACKGROUND: Could not find any tabs matching supported domains."); - return null; - } - } catch (error) { - console.error("BACKGROUND: Error finding suitable tab:", error); - return null; - } -} - -function processNextRequest() { - console.log("BACKGROUND: Processing next request, queue length:", pendingRequests.length); - if (processingRequest && pendingRequests.length > 0) { - console.log("BACKGROUND: Still processing a request, deferring processNextRequest call."); - return; + // Ensure processNextRequest is called to handle any queued items, + // even if this one failed. + processNextRequest(); } - if (pendingRequests.length > 0) { - const nextCommand = pendingRequests.shift(); - console.log("BACKGROUND: Processing next command from queue:", nextCommand); - - if (!pendingRequestDetails.has(nextCommand.requestId) && nextCommand.message !== undefined) { - pendingRequestDetails.set(nextCommand.requestId, { messageContent: nextCommand.message }); - let preview = typeof nextCommand.message === 'string' ? `"${nextCommand.message.substring(0, 30)}..."` : `Type: ${typeof nextCommand.message}`; - console.log(`BACKGROUND: Stored details (messageContent) for queued requestId: ${nextCommand.requestId} (Message: ${preview}) while processing queue.`); - } - - processingRequest = true; - lastRequestId = nextCommand.requestId; - - setTimeout(() => { - forwardCommandToContentScript({ - action: "SEND_CHAT_MESSAGE", - requestId: nextCommand.requestId, - messageContent: nextCommand.message, - settings: nextCommand.settings, - lastProcessedText: lastSuccessfullyProcessedMessageText - }); - }, 500); + } catch (error) { + console.error("BACKGROUND: Error in forwardCommandToContentScript for requestId:", command.requestId, error); + // Send an error back to the server if an unexpected error occurs during forwarding + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_ERROR", + requestId: command.requestId, + error: `Internal error in background script while forwarding command: ${error.message}` + })); + console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR to server for requestId: ${command.requestId} (exception).`); } else { - console.log("BACKGROUND: No pending requests to process."); + console.error(`BACKGROUND: Relay WS not OPEN, cannot send CHAT_RESPONSE_ERROR for requestId: ${command.requestId} (exception).`); } + + if (lastRequestId === command.requestId) { + processingRequest = false; + console.log(`BACKGROUND: Reset processingRequest for requestId: ${command.requestId} (exception).`); + } + } } +// Helper function to find a suitable tab and send the command +async function findAndSendToSuitableTab(command, justFinding = false) { + try { + console.log("BACKGROUND: Finding suitable tab for command:", command); + const allTabs = await chrome.tabs.query({}); + const matchingTabs = allTabs.filter(tab => { + if (!tab.url) return false; + return supportedDomains.some(domain => tab.url.includes(domain)); + }); + + console.log(`BACKGROUND: Found ${matchingTabs.length} tabs matching supported domains`); + + if (matchingTabs.length > 0) { + const activeMatchingTabs = matchingTabs.filter(tab => tab.active); + const targetTab = activeMatchingTabs.length > 0 ? activeMatchingTabs[0] : matchingTabs[0]; + console.log(`BACKGROUND: Selected tab ${targetTab.id} (${targetTab.url})`); + activeTabId = targetTab.id; // Update global activeTabId + + if (justFinding) { + return targetTab.id; + } + + console.warn("BACKGROUND: findAndSendToSuitableTab called with justFinding=false. Sending is now handled by caller."); + return targetTab.id; + + } else { + console.error("BACKGROUND: Could not find any tabs matching supported domains."); + return null; + } + } catch (error) { + console.error("BACKGROUND: Error finding suitable tab:", error); + return null; + } +} + +// Process the next request in the queue +function processNextRequest() { + console.log("BACKGROUND: Processing next request, queue length:", pendingRequests.length); + if (processingRequest && pendingRequests.length > 0) { + console.log("BACKGROUND: Still processing a request, deferring processNextRequest call."); + return; + } + + if (pendingRequests.length > 0) { + const nextCommand = pendingRequests.shift(); + console.log("BACKGROUND: Processing next command from queue:", nextCommand); + + // Ensure details are stored if this came from the pendingRequests queue + // (though ideally they are stored when initially received from server) + if (!pendingRequestDetails.has(nextCommand.requestId) && nextCommand.message !== undefined) { + pendingRequestDetails.set(nextCommand.requestId, { messageContent: nextCommand.message }); // Use messageContent + let preview = typeof nextCommand.message === 'string' ? `"${nextCommand.message.substring(0,30)}..."` : `Type: ${typeof nextCommand.message}`; + console.log(`BACKGROUND: Stored details (messageContent) for queued requestId: ${nextCommand.requestId} (Message: ${preview}) while processing queue.`); + } + + processingRequest = true; + lastRequestId = nextCommand.requestId; + + // Add a delay before forwarding the command + setTimeout(() => { + forwardCommandToContentScript({ + action: "SEND_CHAT_MESSAGE", + requestId: nextCommand.requestId, + messageContent: nextCommand.message, + settings: nextCommand.settings, + lastProcessedText: lastSuccessfullyProcessedMessageText // Pass the text of the last successfully processed message + }); + }, 500); // 500ms delay + } else { + console.log("BACKGROUND: No pending requests to process."); + } +} + +// Helper function to check if a URL is supported by a given provider +// This might need to be more sophisticated if provider domains are complex function isUrlSupportedByProvider(url, providerName) { + // This function would need access to the provider definitions or a shared config + // For AIStudioProvider: if (providerName === "AIStudioProvider") { return url.includes("aistudio.google.com"); } + // For GeminiProvider: if (providerName === "GeminiProvider") { return url.includes("gemini.google.com"); } - if (providerName === "ChatGptProvider") { + // For ChatGPTProvider: + if (providerName === "ChatGptProvider") { // Match the casing used by the provider's .name property return url.includes("chatgpt.com"); } - if (providerName === "ClaudeProvider") { + // For ClaudeProvider: + if (providerName === "ClaudeProvider") { // Match the casing used by the provider's .name property return url.includes("claude.ai"); } + // Add other providers if necessary console.warn(BG_LOG_PREFIX, `isUrlSupportedByProvider: Unknown providerName '${providerName}'`); return false; } +// Listen for tab updates chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { - if (changeInfo.status === 'complete' && tab.url) { - const isSupportedDomain = supportedDomains.some(domain => tab.url.includes(domain)); - if (isSupportedDomain) { - console.log(`BACKGROUND: A supported tab ${tabId} (${tab.url}) was updated. Checking if it should be the active tab.`); - if (tab.active || !activeTabId) { - const currentProvider = providerUtils.getProviderForUrl(tab.url); - if (currentProvider) { - activeTabId = tabId; - console.log(`BACKGROUND: Set ${tabId} (${tab.url}) as the active tab.`); - } - } - } + if (changeInfo.status === 'complete' && tab.url) { + const isSupportedDomain = supportedDomains.some(domain => tab.url.includes(domain)); + if (isSupportedDomain) { + console.log(`BACKGROUND: A supported tab ${tabId} (${tab.url}) was updated. Checking if it should be the active tab.`); + // Potentially update activeTabId, but be careful if multiple supported tabs are open. + // The existing logic for activeTabId update via messages from content script might be more reliable. + // For now, let's ensure it's set if it's the *only* active one or becomes active. + if (tab.active || !activeTabId) { + // Check if this tab is actually one of the supported types before making it active + // This is a bit redundant with supportedDomains check but good for clarity + const currentProvider = providerUtils.getProviderForUrl(tab.url); // Assuming providerUtils is accessible or we have a similar utility + if (currentProvider) { + activeTabId = tabId; + console.log(`BACKGROUND: Set ${tabId} (${tab.url}) as the active tab.`); + } + } } + } - const attachmentDetails = debuggerAttachedTabs.get(tabId); - if (attachmentDetails && attachmentDetails.isAttached && changeInfo.url && tab && tab.url) { - console.log(BG_LOG_PREFIX, `Tab ${tabId} updated. Old URL: ${changeInfo.url}, New URL: ${tab.url}. Checking debugger status.`); + // Handle debugger re-attachment on URL changes for already debugged tabs + const attachmentDetails = debuggerAttachedTabs.get(tabId); + if (attachmentDetails && attachmentDetails.isAttached && changeInfo.url && tab && tab.url) { + // changeInfo.url is the old URL, tab.url is the new one + console.log(BG_LOG_PREFIX, `Tab ${tabId} updated. Old URL: ${changeInfo.url}, New URL: ${tab.url}. Checking debugger status.`); - const providerStillValidForNewUrl = isUrlSupportedByProvider(tab.url, attachmentDetails.providerName); + const providerStillValidForNewUrl = isUrlSupportedByProvider(tab.url, attachmentDetails.providerName); - if (providerStillValidForNewUrl) { - console.log(BG_LOG_PREFIX, `Tab ${tabId} URL changed to ${tab.url}. Provider ${attachmentDetails.providerName} still valid. Re-initiating debugger attachment.`); - const oldProviderName = attachmentDetails.providerName; - const oldPatterns = attachmentDetails.patterns; - - await detachDebugger(tabId); - - try { - const updatedTabInfo = await chrome.tabs.get(tabId); - if (updatedTabInfo) { - console.log(BG_LOG_PREFIX, `Proactively re-attaching debugger to ${tabId} (${updatedTabInfo.url}) with provider ${oldProviderName}.`); - await attachDebuggerAndEnableFetch(tabId, oldProviderName, oldPatterns); - - if (processingRequest && lastRequestId !== null && tabId === currentRequestTargetTabId) { - const interruptedRequestDetails = pendingRequestDetails.get(lastRequestId); - if (interruptedRequestDetails) { - console.warn(BG_LOG_PREFIX, `Tab ${tabId} update (URL: ${tab.url}) may have interrupted processing for requestId: ${lastRequestId}. Attempting to resend after a delay.`); - - setTimeout(() => { - if (processingRequest && lastRequestId !== null && tabId === currentRequestTargetTabId && pendingRequestDetails.has(lastRequestId)) { - console.log(BG_LOG_PREFIX, `Re-forwarding command for interrupted requestId: ${lastRequestId} to tab ${tabId}`); - forwardCommandToContentScript({ - action: "SEND_CHAT_MESSAGE", - requestId: lastRequestId, - messageContent: interruptedRequestDetails.messageContent, - settings: interruptedRequestDetails.settings, - lastProcessedText: lastSuccessfullyProcessedMessageText - }); - } else { - console.log(BG_LOG_PREFIX, `Resend for ${lastRequestId} aborted; state changed before resend timeout. Current processing: ${processingRequest}, current lastReqId: ${lastRequestId}, current targetTab: ${currentRequestTargetTabId}, details still pending: ${pendingRequestDetails.has(lastRequestId)}`); - } - }, 2000); - } else { - console.warn(BG_LOG_PREFIX, `Tab ${tabId} update occurred while processing requestId: ${lastRequestId}, but no details found in pendingRequestDetails to resend. The request might have been cleared by another process.`); - - if (processingRequest && lastRequestId !== null && tabId === currentRequestTargetTabId) { - console.error(BG_LOG_PREFIX, `Critical state: Tab update for ${tabId} (target of ${lastRequestId}), but details missing. Forcing reset of processing state for ${lastRequestId}.`); - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_ERROR", - requestId: lastRequestId, - error: `Request ${lastRequestId} processing was interrupted by tab update and its details were lost. Cannot resend.` - })); - } - processingRequest = false; - currentRequestTargetTabId = null; - pendingRequestDetails.delete(lastRequestId); - const tabInfoForReset = debuggerAttachedTabs.get(tabId); - if (tabInfoForReset && tabInfoForReset.lastKnownRequestId === lastRequestId) { - tabInfoForReset.lastKnownRequestId = null; - } - processNextRequest(); - } - } - } - - if (processingRequest && lastRequestId !== null && tabId === currentRequestTargetTabId) { - const interruptedRequestDetails = pendingRequestDetails.get(lastRequestId); - if (interruptedRequestDetails) { - console.warn(BG_LOG_PREFIX, `Tab ${tabId} update (URL: ${tab.url}) may have interrupted processing for requestId: ${lastRequestId}. Attempting to resend after a delay.`); - - setTimeout(() => { - if (processingRequest && lastRequestId !== null && tabId === currentRequestTargetTabId && pendingRequestDetails.has(lastRequestId)) { - console.log(BG_LOG_PREFIX, `Re-forwarding command for interrupted requestId: ${lastRequestId} to tab ${tabId}`); - forwardCommandToContentScript({ - action: "SEND_CHAT_MESSAGE", - requestId: lastRequestId, - messageContent: interruptedRequestDetails.messageContent, - settings: interruptedRequestDetails.settings, - lastProcessedText: lastSuccessfullyProcessedMessageText - }); - } else { - console.log(BG_LOG_PREFIX, `Resend for ${lastRequestId} aborted; state changed before resend timeout. Current processing: ${processingRequest}, current lastReqId: ${lastRequestId}, current targetTab: ${currentRequestTargetTabId}, details still pending: ${pendingRequestDetails.has(lastRequestId)}`); - } - }, 2000); - } else { - console.warn(BG_LOG_PREFIX, `Tab ${tabId} update occurred while processing requestId: ${lastRequestId}, but no details found in pendingRequestDetails to resend. The request might have been cleared by another process.`); - if (processingRequest && lastRequestId !== null && tabId === currentRequestTargetTabId) { - console.error(BG_LOG_PREFIX, `Critical state: Tab update for ${tabId} (target of ${lastRequestId}), but details missing. Forcing reset of processing state for ${lastRequestId}.`); - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_ERROR", - requestId: lastRequestId, - error: `Request ${lastRequestId} processing was interrupted by tab update and its details were lost. Cannot resend.` - })); - } - processingRequest = false; - currentRequestTargetTabId = null; - pendingRequestDetails.delete(lastRequestId); - const tabInfoForReset = debuggerAttachedTabs.get(tabId); - if (tabInfoForReset && tabInfoForReset.lastKnownRequestId === lastRequestId) { - tabInfoForReset.lastKnownRequestId = null; - } - processNextRequest(); - } - } - } - } - } catch (error) { - console.warn(BG_LOG_PREFIX, `Error getting tab info for ${tabId} during re-attachment attempt:`, error.message); - } - - } else { - console.log(BG_LOG_PREFIX, `Tab ${tabId} URL changed to ${tab.url}. Provider ${attachmentDetails.providerName} no longer valid or URL not supported by provider. Detaching debugger.`); - await detachDebugger(tabId); - } - } else if (attachmentDetails && attachmentDetails.isAttached && changeInfo.status === 'loading' && tab && tab.url && !changeInfo.url) { - const newUrl = tab.url; - console.log(BG_LOG_PREFIX, `Tab ${tabId} is loading new URL: ${newUrl}. Checking debugger status.`); - const providerStillValidForNewUrl = isUrlSupportedByProvider(newUrl, attachmentDetails.providerName); - if (!providerStillValidForNewUrl) { - console.log(BG_LOG_PREFIX, `Tab ${tabId} loading new URL ${newUrl}. Provider ${attachmentDetails.providerName} may no longer be valid. Detaching.`); - await detachDebugger(tabId); + if (providerStillValidForNewUrl) { + console.log(BG_LOG_PREFIX, `Tab ${tabId} URL changed to ${tab.url}. Provider ${attachmentDetails.providerName} still valid. Re-initiating debugger attachment.`); + const oldProviderName = attachmentDetails.providerName; + const oldPatterns = attachmentDetails.patterns; // These patterns were from the content script for the *domain* + + // Detach first to ensure a clean state, then re-attach. + // The 'isAttached' flag in attachmentDetails will be set to false by detachDebugger. + await detachDebugger(tabId); + + // Check if tab still exists (it should, as we are in its onUpdated event) + try { + const updatedTabInfo = await chrome.tabs.get(tabId); + if (updatedTabInfo) { + console.log(BG_LOG_PREFIX, `Proactively re-attaching debugger to ${tabId} (${updatedTabInfo.url}) with provider ${oldProviderName}.`); + // Content script should send SET_DEBUGGER_TARGETS on its re-initialization. + // However, a proactive re-attachment can be beneficial. + // The patterns might need to be re-fetched if they are URL-specific beyond the domain. + // For now, using oldPatterns, assuming they are domain-level. + await attachDebuggerAndEnableFetch(tabId, oldProviderName, oldPatterns); } + } catch (error) { + console.warn(BG_LOG_PREFIX, `Error getting tab info for ${tabId} during re-attachment attempt:`, error.message); + } + + } else { + console.log(BG_LOG_PREFIX, `Tab ${tabId} URL changed to ${tab.url}. Provider ${attachmentDetails.providerName} no longer valid or URL not supported by provider. Detaching debugger.`); + await detachDebugger(tabId); } + } else if (attachmentDetails && attachmentDetails.isAttached && changeInfo.status === 'loading' && tab && tab.url && !changeInfo.url) { + // Sometimes URL change is only visible when status is 'loading' and tab.url is the new one. + // This is a more aggressive check. + const newUrl = tab.url; + console.log(BG_LOG_PREFIX, `Tab ${tabId} is loading new URL: ${newUrl}. Checking debugger status.`); + const providerStillValidForNewUrl = isUrlSupportedByProvider(newUrl, attachmentDetails.providerName); + if (!providerStillValidForNewUrl) { + console.log(BG_LOG_PREFIX, `Tab ${tabId} loading new URL ${newUrl}. Provider ${attachmentDetails.providerName} may no longer be valid. Detaching.`); + await detachDebugger(tabId); + } + // If provider is still valid, we'll let the 'complete' status handler above deal with re-attachment if needed, + // or rely on content script sending SET_DEBUGGER_TARGETS. + } }); +// Listen for messages from Content Scripts and Popup chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - console.log("BACKGROUND: Received message:", message.type || message.action, "from tabId:", sender.tab ? sender.tab.id : 'popup/unknown'); + console.log("BACKGROUND: Received message:", message.type || message.action, "from tabId:", sender.tab ? sender.tab.id : 'popup/unknown'); + + if (sender.tab && sender.tab.id) { + activeTabId = sender.tab.id; // User's original logic for activeTabId + console.log(`BACKGROUND: Updated activeTabId to ${activeTabId} from sender`); + } - if (sender.tab && sender.tab.id) { - activeTabId = sender.tab.id; - console.log(`BACKGROUND: Updated activeTabId to ${activeTabId} from sender`); + if (message.type === "SET_DEBUGGER_TARGETS") { + if (sender.tab && sender.tab.id) { + const tabId = sender.tab.id; + console.log(BG_LOG_PREFIX, `SET_DEBUGGER_TARGETS for tab ${tabId}, provider: ${message.providerName}, patterns:`, message.patterns); + attachDebuggerAndEnableFetch(tabId, message.providerName, message.patterns); + sendResponse({ status: "Debugger attachment initiated" }); + } else { + console.error(BG_LOG_PREFIX, "SET_DEBUGGER_TARGETS message received without valid sender.tab.id"); + sendResponse({ status: "Error: Missing tabId" }); + } + return true; + } + else if (message.type === "CHAT_RELAY_READY") { + console.log(`BACKGROUND: Content script ready in ${message.chatInterface} on tab ${sender.tab ? sender.tab.id : 'unknown'}`); + if (sender.tab && sender.tab.id) activeTabId = sender.tab.id; + sendResponse({ success: true }); + return true; // Indicate that sendResponse might be used (even if synchronously here) + } else if (message.action === "RESPONSE_CAPTURED") { + console.log(`BACKGROUND: Received captured response (OLD DOM METHOD) from content script on tab ${sender.tab ? sender.tab.id : 'unknown'} Request ID: ${message.requestId}`); + + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + console.log("BACKGROUND: Forwarding (OLD DOM) response to relay server:", message.response); + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE", + requestId: message.requestId, + response: message.response, + isFinal: true + })); + sendResponse({ success: true }); + + if (lastRequestId === message.requestId) { + processingRequest = false; + console.log("BACKGROUND: Reset processingRequest after (OLD DOM) RESPONSE_CAPTURED."); + processNextRequest(); + } + + } else { + console.error("BACKGROUND: Relay WS not connected, cannot forward (OLD DOM) response"); + sendResponse({ success: false, error: "Relay WebSocket not connected" }); + if (lastRequestId === message.requestId) { + processingRequest = false; + } } + return true; + } else if (message.action === "GET_CONNECTION_STATUS") { + const isConnected = relaySocket && relaySocket.readyState === WebSocket.OPEN; + sendResponse({ connected: isConnected }); + return true; // Indicate that sendResponse might be used + } else if (message.type === "CHAT_RESPONSE_FROM_DOM") { + console.log(`BACKGROUND: Received CHAT_RESPONSE_FROM_DOM from tab ${sender.tab ? sender.tab.id : 'unknown'} for requestId ${message.requestId}`); + const tabId = sender.tab ? sender.tab.id : null; + const tabInfo = tabId ? debuggerAttachedTabs.get(tabId) : null; - if (message.type === "SET_DEBUGGER_TARGETS") { - if (sender.tab && sender.tab.id) { - const tabId = sender.tab.id; - console.log(BG_LOG_PREFIX, `SET_DEBUGGER_TARGETS for tab ${tabId}, provider: ${message.providerName}, patterns:`, message.patterns); - attachDebuggerAndEnableFetch(tabId, message.providerName, message.patterns); - sendResponse({ status: "Debugger attachment initiated" }); - } else { - console.error(BG_LOG_PREFIX, "SET_DEBUGGER_TARGETS message received without valid sender.tab.id"); - sendResponse({ status: "Error: Missing tabId" }); - } - return true; - } - else if (message.type === "CHAT_RELAY_READY") { - console.log(`BACKGROUND: Content script ready in ${message.chatInterface} on tab ${sender.tab ? sender.tab.id : 'unknown'}`); - if (sender.tab && sender.tab.id) activeTabId = sender.tab.id; - sendResponse({ success: true }); - return true; - } else if (message.action === "RESPONSE_CAPTURED") { - console.log(`BACKGROUND: Received captured response (OLD DOM METHOD) from content script on tab ${sender.tab ? sender.tab.id : 'unknown'} Request ID: ${message.requestId}`); - + if (tabInfo && tabInfo.lastKnownRequestId === message.requestId && processingRequest) { + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_CHUNK", + requestId: message.requestId, + chunk: message.text, + isFinal: message.isFinal !== undefined ? message.isFinal : true + })); + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_STREAM_ENDED", + requestId: message.requestId + })); + console.log(`BACKGROUND: Sent CHAT_RESPONSE_CHUNK (from DOM) and _STREAM_ENDED for app requestId: ${message.requestId}`); + sendResponse({ success: true, message: "DOM Response forwarded to relay." }); + } else { + console.error(`BACKGROUND: Relay WS not connected, cannot send DOM-captured response for requestId: ${message.requestId}`); + sendResponse({ success: false, error: "Relay WebSocket not connected." }); + } + // Finalize this request processing + processingRequest = false; + if (tabInfo) tabInfo.lastKnownRequestId = null; // Clear for this specific tab op + console.log(`BACKGROUND: Reset processingRequest. Cleared lastKnownRequestId for tab ${tabId} after DOM response.`); + processNextRequest(); + } else { + console.warn(`BACKGROUND: Mismatched requestId or not processing for CHAT_RESPONSE_FROM_DOM. Current lastKnownRequestId: ${tabInfo ? tabInfo.lastKnownRequestId : 'N/A'}, processingRequest: ${processingRequest}, msg RequestId: ${message.requestId}`); + sendResponse({ success: false, error: "Mismatched requestId or not processing." }); + } + return true; + } else if (message.type === "CHAT_RESPONSE_FROM_DOM_FAILED") { + console.error(`BACKGROUND: Received CHAT_RESPONSE_FROM_DOM_FAILED from tab ${sender.tab ? sender.tab.id : 'unknown'} for requestId ${message.requestId}: ${message.error}`); + const tabId = sender.tab ? sender.tab.id : null; + const tabInfo = tabId ? debuggerAttachedTabs.get(tabId) : null; + + if (tabInfo && tabInfo.lastKnownRequestId === message.requestId && processingRequest) { + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_ERROR", + requestId: message.requestId, + error: `Failed to capture response from DOM on tab ${tabId}: ${message.error}` + })); + } + sendResponse({ success: true, message: "DOM failure noted and error sent to relay." }); + // Finalize this request processing + processingRequest = false; + if (tabInfo) tabInfo.lastKnownRequestId = null; + console.log(`BACKGROUND: Reset processingRequest. Cleared lastKnownRequestId for tab ${tabId} after DOM failure.`); + processNextRequest(); + } else { + console.warn(`BACKGROUND: Mismatched requestId or not processing for CHAT_RESPONSE_FROM_DOM_FAILED. Current lastKnownRequestId: ${tabInfo ? tabInfo.lastKnownRequestId : 'N/A'}, processingRequest: ${processingRequest}, msg RequestId: ${message.requestId}`); + sendResponse({ success: false, error: "Mismatched requestId or not processing for DOM failure." }); + } + return true; + } else if (message.type === "FINAL_RESPONSE_TO_RELAY") { + console.log(BG_LOG_PREFIX, `[REQ-${message.requestId}] RECEIVED FINAL_RESPONSE_TO_RELAY. FromTab: ${sender.tab ? sender.tab.id : 'N/A'}. HasError: ${!!message.error}. TextLength: ${message.text ? String(message.text).length : 'N/A'}. IsFinal: ${message.isFinal}. FullMsg:`, JSON.stringify(message).substring(0,500)); + const tabId = sender.tab ? sender.tab.id : null; + const tabInfo = tabId ? debuggerAttachedTabs.get(tabId) : null; + + // Update lastSuccessfullyProcessedMessageText regardless of current processing state, + // as this confirms a message text was fully processed by the AI. + const details = pendingRequestDetails.get(message.requestId); + if (details) { + if (typeof details.messageContent === 'string') { + lastSuccessfullyProcessedMessageText = details.messageContent; + console.log(`BACKGROUND: Updated lastSuccessfullyProcessedMessageText to: "${lastSuccessfullyProcessedMessageText.substring(0,50)}..." for completed requestId ${message.requestId}`); + } else { + console.log(`BACKGROUND: RequestId ${message.requestId} (messageContent type: ${typeof details.messageContent}) completed. lastSuccessfullyProcessedMessageText not updated with non-string content.`); + } + pendingRequestDetails.delete(message.requestId); + } else { + console.warn(`BACKGROUND: Received FINAL_RESPONSE_TO_RELAY for unknown requestId ${message.requestId} (not in pendingRequestDetails). Cannot update lastSuccessfullyProcessedMessageText accurately.`); + } + + // Check if this is the request we are currently processing for state reset + if (processingRequest && lastRequestId === message.requestId) { + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + if (message.error) { // Check if content.js sent an error (e.g., response too large) + console.error(BG_LOG_PREFIX, `Content script reported an error for requestId ${message.requestId}: ${message.error}`); + try { + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_ERROR", + requestId: message.requestId, + error: message.error + })); + console.log(BG_LOG_PREFIX, `Sent CHAT_RESPONSE_ERROR to server for requestId ${message.requestId} due to content script error.`); + sendResponse({ success: true, message: "Error reported by content script sent to relay." }); + } catch (e) { + console.error(BG_LOG_PREFIX, `Error sending CHAT_RESPONSE_ERROR to relay for requestId ${message.requestId}:`, e); + sendResponse({ success: false, error: `Error sending CHAT_RESPONSE_ERROR to relay: ${e.message}` }); + } + } else { // No error from content.js, proceed to send data + try { + const responseText = message.text || ""; + console.log(BG_LOG_PREFIX, `Attempting to send FINAL CHAT_RESPONSE_CHUNK for requestId ${message.requestId}. Data length: ${responseText.length}`); + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_CHUNK", + requestId: message.requestId, + chunk: responseText, + isFinal: true + })); + console.log(BG_LOG_PREFIX, `Attempting to send CHAT_RESPONSE_STREAM_ENDED for requestId ${message.requestId}`); + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_STREAM_ENDED", + requestId: message.requestId + })); + console.log(BG_LOG_PREFIX, `Successfully sent FINAL CHAT_RESPONSE_CHUNK and _STREAM_ENDED for app requestId: ${message.requestId} to relaySocket.`); + sendResponse({ success: true, message: "Final response sent to relay." }); + } catch (e) { + console.error(BG_LOG_PREFIX, `Error during relaySocket.send() for FINAL response (requestId ${message.requestId}):`, e); + sendResponse({ success: false, error: `Error sending final response to relay: ${e.message}` }); + } + } + } else { + console.error(BG_LOG_PREFIX, `Relay WS not OPEN (state: ${relaySocket ? relaySocket.readyState : 'null'}), cannot send final response/error for app requestId: ${message.requestId}`); + sendResponse({ success: false, error: "Relay WebSocket not connected." }); + } + + // Finalize this request processing + console.log(BG_LOG_PREFIX, `Processing complete for command with app requestId: ${message.requestId} on tab ${tabId}`); + processingRequest = false; + if (tabInfo) tabInfo.lastKnownRequestId = null; + console.log(BG_LOG_PREFIX, `Reset processingRequest. Cleared lastKnownRequestId for tab ${tabId}.`); + processNextRequest(); + } else { + console.warn(`BACKGROUND: Received FINAL_RESPONSE_TO_RELAY for requestId ${message.requestId}, but not currently processing it (current: ${lastRequestId}, processing: ${processingRequest}). Ignoring.`); + sendResponse({ success: false, error: "Request ID mismatch or not processing." }); + } + return true; // Indicate async response potentially + } else if (message.type === "DUPLICATE_MESSAGE_HANDLED") { + console.log(`BACKGROUND: Content script handled requestId ${message.requestId} as a duplicate of text: "${message.originalText ? message.originalText.substring(0,50) : 'N/A'}..."`); + + // Update last successfully processed text because this text was confirmed as a duplicate of it. + lastSuccessfullyProcessedMessageText = message.originalText; + pendingRequestDetails.delete(message.requestId); // Clean up details map + console.log(`BACKGROUND: Updated lastSuccessfullyProcessedMessageText (due to duplicate) to: "${lastSuccessfullyProcessedMessageText ? lastSuccessfullyProcessedMessageText.substring(0,50) : 'N/A'}..."`); + + if (processingRequest && lastRequestId === message.requestId) { if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - console.log("BACKGROUND: Forwarding (OLD DOM) response to relay server:", message.response); relaySocket.send(JSON.stringify({ type: "CHAT_RESPONSE", requestId: message.requestId, - response: message.response, + response: `[ChatRelay Extension] Request to send duplicate message ("${message.originalText ? message.originalText.substring(0,100) : 'N/A'}") was detected and cleared from input. No message sent to AI.`, isFinal: true })); - sendResponse({ success: true }); - - if (lastRequestId === message.requestId) { - processingRequest = false; - console.log("BACKGROUND: Reset processingRequest after (OLD DOM) RESPONSE_CAPTURED."); - processNextRequest(); - } - + console.log(`BACKGROUND: Sent CHAT_RESPONSE (for duplicate) to server for requestId: ${message.requestId}.`); } else { - console.error("BACKGROUND: Relay WS not connected, cannot forward (OLD DOM) response"); - sendResponse({ success: false, error: "Relay WebSocket not connected" }); - if (lastRequestId === message.requestId) { - processingRequest = false; - } - } - return true; - } else if (message.action === "GET_CONNECTION_STATUS") { - const isConnected = relaySocket && relaySocket.readyState === WebSocket.OPEN; - sendResponse({ connected: isConnected }); - return true; - } else if (message.type === "CHAT_RESPONSE_FROM_DOM") { - console.log(`BACKGROUND: Received CHAT_RESPONSE_FROM_DOM from tab ${sender.tab ? sender.tab.id : 'unknown'} for requestId ${message.requestId}`); - const tabId = sender.tab ? sender.tab.id : null; - const tabInfo = tabId ? debuggerAttachedTabs.get(tabId) : null; - - if (tabInfo && tabInfo.lastKnownRequestId === message.requestId && processingRequest) { - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_CHUNK", - requestId: message.requestId, - chunk: message.text, - isFinal: message.isFinal !== undefined ? message.isFinal : true - })); - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_STREAM_ENDED", - requestId: message.requestId - })); - console.log(`BACKGROUND: Sent CHAT_RESPONSE_CHUNK (from DOM) and _STREAM_ENDED for app requestId: ${message.requestId}`); - sendResponse({ success: true, message: "DOM Response forwarded to relay." }); - } else { - console.error(`BACKGROUND: Relay WS not connected, cannot send DOM-captured response for requestId: ${message.requestId}`); - sendResponse({ success: false, error: "Relay WebSocket not connected." }); - } - processingRequest = false; - if (tabInfo) tabInfo.lastKnownRequestId = null; - console.log(`BACKGROUND: Reset processingRequest. Cleared lastKnownRequestId for tab ${tabId} after DOM response.`); - processNextRequest(); - } else { - console.warn(`BACKGROUND: Mismatched requestId or not processing for CHAT_RESPONSE_FROM_DOM. Current lastKnownRequestId: ${tabInfo ? tabInfo.lastKnownRequestId : 'N/A'}, processingRequest: ${processingRequest}, msg RequestId: ${message.requestId}`); - sendResponse({ success: false, error: "Mismatched requestId or not processing." }); - } - return true; - } else if (message.type === "CHAT_RESPONSE_FROM_DOM_FAILED") { - console.error(`BACKGROUND: Received CHAT_RESPONSE_FROM_DOM_FAILED from tab ${sender.tab ? sender.tab.id : 'unknown'} for requestId ${message.requestId}: ${message.error}`); - const tabId = sender.tab ? sender.tab.id : null; - const tabInfo = tabId ? debuggerAttachedTabs.get(tabId) : null; - - if (tabInfo && tabInfo.lastKnownRequestId === message.requestId && processingRequest) { - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_ERROR", - requestId: message.requestId, - error: `Failed to capture response from DOM on tab ${tabId}: ${message.error}` - })); - } - sendResponse({ success: true, message: "DOM failure noted and error sent to relay." }); - processingRequest = false; - if (tabInfo) tabInfo.lastKnownRequestId = null; - console.log(`BACKGROUND: Reset processingRequest. Cleared lastKnownRequestId for tab ${tabId} after DOM failure.`); - processNextRequest(); - } else { - console.warn(`BACKGROUND: Mismatched requestId or not processing for CHAT_RESPONSE_FROM_DOM_FAILED. Current lastKnownRequestId: ${tabInfo ? tabInfo.lastKnownRequestId : 'N/A'}, processingRequest: ${processingRequest}, msg RequestId: ${message.requestId}`); - sendResponse({ success: false, error: "Mismatched requestId or not processing for DOM failure." }); - } - return true; - } else if (message.type === "FINAL_RESPONSE_TO_RELAY") { - console.log(BG_LOG_PREFIX, `[REQ-${message.requestId}] RECEIVED FINAL_RESPONSE_TO_RELAY. FromTab: ${sender.tab ? sender.tab.id : 'N/A'}. HasError: ${!!message.error}. TextLength: ${message.text ? String(message.text).length : 'N/A'}. IsFinal: ${message.isFinal}. FullMsg:`, JSON.stringify(message).substring(0, 500)); - const tabId = sender.tab ? sender.tab.id : null; - const tabInfo = tabId ? debuggerAttachedTabs.get(tabId) : null; - - const details = pendingRequestDetails.get(message.requestId); - if (details) { - if (typeof details.messageContent === 'string') { - lastSuccessfullyProcessedMessageText = details.messageContent; - console.log(`BACKGROUND: Updated lastSuccessfullyProcessedMessageText to: "${lastSuccessfullyProcessedMessageText.substring(0, 50)}..." for completed requestId ${message.requestId}`); - } else { - console.log(`BACKGROUND: RequestId ${message.requestId} (messageContent type: ${typeof details.messageContent}) completed. lastSuccessfullyProcessedMessageText not updated with non-string content.`); - } - pendingRequestDetails.delete(message.requestId); - } else { - console.warn(`BACKGROUND: Received FINAL_RESPONSE_TO_RELAY for unknown requestId ${message.requestId} (not in pendingRequestDetails). Cannot update lastSuccessfullyProcessedMessageText accurately.`); + console.error(`BACKGROUND: Relay WS not OPEN, cannot send CHAT_RESPONSE (for duplicate) for requestId: ${message.requestId}.`); } - if (processingRequest && lastRequestId === message.requestId) { - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - if (message.error) { - console.error(BG_LOG_PREFIX, `Content script reported an error for requestId ${message.requestId}: ${message.error}`); - try { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_ERROR", - requestId: message.requestId, - error: message.error - })); - console.log(BG_LOG_PREFIX, `Sent CHAT_RESPONSE_ERROR to server for requestId ${message.requestId} due to content script error.`); - sendResponse({ success: true, message: "Error reported by content script sent to relay." }); - } catch (e) { - console.error(BG_LOG_PREFIX, `Error sending CHAT_RESPONSE_ERROR to relay for requestId ${message.requestId}:`, e); - sendResponse({ success: false, error: `Error sending CHAT_RESPONSE_ERROR to relay: ${e.message}` }); - } + processingRequest = false; + // lastRequestId remains, it's the ID of the last command *received* + // currentRequestText (if used) would be nulled here. + const tabInfo = sender.tab ? debuggerAttachedTabs.get(sender.tab.id) : null; + if (tabInfo && tabInfo.lastKnownRequestId === message.requestId) { + tabInfo.lastKnownRequestId = null; + } + + console.log(`BACKGROUND: Reset processingRequest after DUPLICATE_MESSAGE_HANDLED for requestId: ${message.requestId}.`); + processNextRequest(); + } else { + console.warn(`BACKGROUND: Received DUPLICATE_MESSAGE_HANDLED for requestId ${message.requestId}, but not currently processing it or ID mismatch. Current lastRequestId: ${lastRequestId}, processing: ${processingRequest}. Still updated LSPMT.`); + // If it was an older request, its details are cleaned, LSPMT updated. Server informed if possible. + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE", + requestId: message.requestId, + response: `[ChatRelay Extension] An older/superseded request (ID: ${message.requestId}, Text: "${message.originalText ? message.originalText.substring(0,100) : 'N/A'}") was handled as a duplicate.`, + isFinal: true + })); + } + } + sendResponse({ success: true, message: "Duplicate handling acknowledged by background." }); + return true; + } else if (message.type === "USER_STOP_REQUEST") { + const requestIdToStop = message.requestId; + console.log(`BACKGROUND: Received USER_STOP_REQUEST for requestId: ${requestIdToStop}`); + let responseSent = false; // To ensure sendResponse is called once + + // Case 1: The request to stop is the currently processing one. + if (processingRequest && lastRequestId === requestIdToStop) { + console.log(`BACKGROUND: Initiating stop for currently processing request: ${lastRequestId}. Content script will send FINAL_RESPONSE_TO_RELAY.`); + if (activeTabId) { + chrome.tabs.sendMessage(activeTabId, { + action: "STOP_STREAMING", + requestId: lastRequestId + }, response => { + if (chrome.runtime.lastError) { + console.error(`BACKGROUND: Error sending STOP_STREAMING to tab ${activeTabId} for requestId ${lastRequestId}:`, chrome.runtime.lastError.message); } else { - try { - let responseText = message.text || ""; - - // Decode text if it was encoded by content script - if (message.encoded) { - responseText = decodeURIComponent(responseText); - } - - console.log(BG_LOG_PREFIX, `Attempting to send FINAL CHAT_RESPONSE_CHUNK for requestId ${message.requestId}. Data length: ${responseText.length}`); - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_CHUNK", - requestId: message.requestId, - chunk: responseText, - isFinal: true - })); - console.log(BG_LOG_PREFIX, `Attempting to send CHAT_RESPONSE_STREAM_ENDED for requestId ${message.requestId}`); - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_STREAM_ENDED", - requestId: message.requestId - })); - console.log(BG_LOG_PREFIX, `Successfully sent FINAL CHAT_RESPONSE_CHUNK and _STREAM_ENDED for app requestId: ${message.requestId} to relaySocket.`); - sendResponse({ success: true, message: "Final response sent to relay." }); - } catch (e) { - console.error(BG_LOG_PREFIX, `Error during relaySocket.send() for FINAL response (requestId ${message.requestId}):`, e); - sendResponse({ success: false, error: `Error sending final response to relay: ${e.message}` }); - } + console.log(`BACKGROUND: Sent STOP_STREAMING to tab ${activeTabId} for requestId ${lastRequestId}. Content script ack:`, response); } - } else { - console.error(BG_LOG_PREFIX, `Relay WS not OPEN (state: ${relaySocket ? relaySocket.readyState : 'null'}), cannot send final response/error for app requestId: ${message.requestId}`); - sendResponse({ success: false, error: "Relay WebSocket not connected." }); - } - - console.log(BG_LOG_PREFIX, `Processing complete for command with app requestId: ${message.requestId} on tab ${tabId}`); - processingRequest = false; - currentRequestTargetTabId = null; - if (tabInfo) tabInfo.lastKnownRequestId = null; - console.log(BG_LOG_PREFIX, `Reset processingRequest. Cleared lastKnownRequestId for tab ${tabId}.`); - processNextRequest(); + }); } else { - console.warn(`BACKGROUND: Received FINAL_RESPONSE_TO_RELAY for requestId ${message.requestId}, but not currently processing it (current: ${lastRequestId}, processing: ${processingRequest}). Ignoring.`); - sendResponse({ success: false, error: "Request ID mismatch or not processing." }); - } - return true; - } else if (message.type === "DUPLICATE_MESSAGE_HANDLED") { - console.log(`BACKGROUND: Content script handled requestId ${message.requestId} as a duplicate of text: "${message.originalText ? message.originalText.substring(0, 50) : 'N/A'}..."`); - - lastSuccessfullyProcessedMessageText = message.originalText; - pendingRequestDetails.delete(message.requestId); - console.log(`BACKGROUND: Updated lastSuccessfullyProcessedMessageText (due to duplicate) to: "${lastSuccessfullyProcessedMessageText ? lastSuccessfullyProcessedMessageText.substring(0, 50) : 'N/A'}..."`); - - if (processingRequest && lastRequestId === message.requestId) { - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE", - requestId: message.requestId, - response: `[ChatRelay Extension] Request to send duplicate message ("${message.originalText ? message.originalText.substring(0, 100) : 'N/A'}") was detected and cleared from input. No message sent to AI.`, - isFinal: true - })); - console.log(`BACKGROUND: Sent CHAT_RESPONSE (for duplicate) to server for requestId: ${message.requestId}.`); - } else { - console.error(`BACKGROUND: Relay WS not OPEN, cannot send CHAT_RESPONSE (for duplicate) for requestId: ${message.requestId}.`); - } - - processingRequest = false; - currentRequestTargetTabId = null; - const tabInfo = sender.tab ? debuggerAttachedTabs.get(sender.tab.id) : null; - if (tabInfo && tabInfo.lastKnownRequestId === message.requestId) { - tabInfo.lastKnownRequestId = null; - } - - console.log(`BACKGROUND: Reset processingRequest after DUPLICATE_MESSAGE_HANDLED for requestId: ${message.requestId}.`); - processNextRequest(); - } else { - console.warn(`BACKGROUND: Received DUPLICATE_MESSAGE_HANDLED for requestId ${message.requestId}, but not currently processing it or ID mismatch. Current lastRequestId: ${lastRequestId}, processing: ${processingRequest}. Still updated LSPMT.`); - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE", - requestId: message.requestId, - response: `[ChatRelay Extension] An older/superseded request (ID: ${message.requestId}, Text: "${message.originalText ? message.originalText.substring(0, 100) : 'N/A'}") was handled as a duplicate.`, - isFinal: true - })); - } - } - sendResponse({ success: true, message: "Duplicate handling acknowledged by background." }); - return true; - } else if (message.type === "USER_STOP_REQUEST") { - const requestIdToStop = message.requestId; - console.log(`BACKGROUND: Received USER_STOP_REQUEST for requestId: ${requestIdToStop}`); - let responseSent = false; - - if (processingRequest && lastRequestId === requestIdToStop) { - console.log(`BACKGROUND: Initiating stop for currently processing request: ${lastRequestId}. Content script will send FINAL_RESPONSE_TO_RELAY.`); - if (activeTabId) { - chrome.tabs.sendMessage(activeTabId, { - action: "STOP_STREAMING", - requestId: lastRequestId - }, response => { - if (chrome.runtime.lastError) { - console.error(`BACKGROUND: Error sending STOP_STREAMING to tab ${activeTabId} for requestId ${lastRequestId}:`, chrome.runtime.lastError.message); - } else { - console.log(`BACKGROUND: Sent STOP_STREAMING to tab ${activeTabId} for requestId ${lastRequestId}. Content script ack:`, response); - } - }); - } else { - console.warn(`BACKGROUND: Cannot send STOP_STREAMING for currently processing requestId ${lastRequestId}, activeTabId is null. This request might not be properly finalized by the provider.`); - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_ERROR", - requestId: lastRequestId, - error: "Request cancelled by user (no active tab to signal provider)." - })); - } - processingRequest = false; - currentRequestTargetTabId = null; - pendingRequestDetails.delete(lastRequestId); - console.log(`BACKGROUND: Forcefully reset processingRequest for ${lastRequestId} due to USER_STOP_REQUEST with no active tab.`); - processNextRequest(); - } - + console.warn(`BACKGROUND: Cannot send STOP_STREAMING for currently processing requestId ${lastRequestId}, activeTabId is null. This request might not be properly finalized by the provider.`); + // If no active tab, we can't tell content.js to stop. + // We should still inform the relay and clean up what we can, + // though the provider state might remain for this request. if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { relaySocket.send(JSON.stringify({ type: "CHAT_RESPONSE_ERROR", requestId: lastRequestId, - error: "Request cancelled by user." + error: "Request cancelled by user (no active tab to signal provider)." })); - console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR (user cancelled) to server for currently processing requestId: ${lastRequestId}.`); } + // Since we can't rely on FINAL_RESPONSE_TO_RELAY, we have to clean up here. + processingRequest = false; + pendingRequestDetails.delete(lastRequestId); + // lastSuccessfullyProcessedMessageText = null; // Consider if this should be reset + console.log(`BACKGROUND: Forcefully reset processingRequest for ${lastRequestId} due to USER_STOP_REQUEST with no active tab.`); + processNextRequest(); // Attempt to process next + } - sendResponse({ success: true, message: `Stop initiated for currently processing request ${lastRequestId}. Waiting for finalization from content script.` }); + // Inform relay server about cancellation (can be done early) + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_ERROR", // Or a new type like "USER_CANCELLED_REQUEST" + requestId: lastRequestId, // Use lastRequestId as it's the one being processed + error: "Request cancelled by user." + })); + console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR (user cancelled) to server for currently processing requestId: ${lastRequestId}.`); + } + + // IMPORTANT: Do NOT set processingRequest = false or clear lastRequestId details here. + // Let the FINAL_RESPONSE_TO_RELAY (triggered by provider.stopStreaming) handle the final state cleanup. + sendResponse({ success: true, message: `Stop initiated for currently processing request ${lastRequestId}. Waiting for finalization from content script.` }); + responseSent = true; + + // Case 2: The request to stop is in the pending queue (not actively processing). + } else { + const initialQueueLength = pendingRequests.length; + pendingRequests = pendingRequests.filter(req => req.requestId !== requestIdToStop); + if (pendingRequests.length < initialQueueLength) { + console.log(`BACKGROUND: Removed requestId ${requestIdToStop} from pendingRequests queue.`); + pendingRequestDetails.delete(requestIdToStop); // Clean up details for the queued item + + if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { + relaySocket.send(JSON.stringify({ + type: "CHAT_RESPONSE_ERROR", + requestId: requestIdToStop, + error: `Request ${requestIdToStop} cancelled by user while in queue.` + })); + console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR (user cancelled in queue) to server for requestId: ${requestIdToStop}.`); + } + if (!responseSent) sendResponse({ success: true, message: `Request ${requestIdToStop} removed from queue.` }); responseSent = true; - - } else { - const initialQueueLength = pendingRequests.length; - pendingRequests = pendingRequests.filter(req => req.requestId !== requestIdToStop); - if (pendingRequests.length < initialQueueLength) { - console.log(`BACKGROUND: Removed requestId ${requestIdToStop} from pendingRequests queue.`); - pendingRequestDetails.delete(requestIdToStop); - - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_ERROR", - requestId: requestIdToStop, - error: `Request ${requestIdToStop} cancelled by user while in queue.` - })); - console.log(`BACKGROUND: Sent CHAT_RESPONSE_ERROR (user cancelled in queue) to server for requestId: ${requestIdToStop}.`); - } - if (!responseSent) sendResponse({ success: true, message: `Request ${requestIdToStop} removed from queue.` }); - responseSent = true; - } } - - if (!responseSent) { - console.warn(`BACKGROUND: USER_STOP_REQUEST for ${requestIdToStop}, but it was not actively processing nor found in the pending queue. Current active: ${lastRequestId}, processing: ${processingRequest}`); - sendResponse({ success: false, error: "Request not found processing or in queue." }); - } - return true; } + + if (!responseSent) { + console.warn(`BACKGROUND: USER_STOP_REQUEST for ${requestIdToStop}, but it was not actively processing nor found in the pending queue. Current active: ${lastRequestId}, processing: ${processingRequest}`); + sendResponse({ success: false, error: "Request not found processing or in queue." }); + } + return true; + } + // IMPORTANT: Add other top-level else if (message.action === "SAVE_SETTINGS") etc. here if they exist outside this snippet }); +// Listen for storage changes to update the server URL chrome.storage.onChanged.addListener((changes, namespace) => { - if (namespace === 'sync') { - let needsReconnect = false; - if (changes.serverHost || changes.serverPort || changes.serverProtocol) { - needsReconnect = true; - } - - if (needsReconnect) { - console.log("BACKGROUND: Server settings changed, reconnecting..."); - if (relaySocket) { - relaySocket.close(); - } else { - loadSettingsAndConnect(); - } - } + if (namespace === 'sync') { + let needsReconnect = false; + if (changes.serverHost || changes.serverPort || changes.serverProtocol) { + needsReconnect = true; } + + if (needsReconnect) { + console.log("BACKGROUND: Server settings changed, reconnecting..."); + if (relaySocket) { + relaySocket.close(); + } else { + loadSettingsAndConnect(); + } + } + } }); +// Initial setup loadSettingsAndConnect(); +// Placeholder for providerUtils if it's not globally available from another script. +// In a real extension, this would likely be imported or part of a shared module. const providerUtils = { - _providers: {}, - registerProvider: function (name, domains, instance) { + _providers: {}, // providerName -> { instance, domains } + registerProvider: function(name, domains, instance) { this._providers[name] = { instance, domains }; + // console.log(BG_LOG_PREFIX, `Provider registered in background (simulated): ${name}`); }, - getProviderForUrl: function (url) { + getProviderForUrl: function(url) { for (const name in this._providers) { if (this._providers[name].domains.some(domain => url.includes(domain))) { return this._providers[name].instance; @@ -793,7 +814,10 @@ const providerUtils = { } return null; }, - _initializeSimulatedProviders: function () { + // Simulate AIStudioProvider registration for isUrlSupportedByProvider + // This would normally happen if provider-utils.js was also loaded in background context + // or if this info was passed/stored differently. + _initializeSimulatedProviders: function() { this.registerProvider("AIStudioProvider", ["aistudio.google.com"], { name: "AIStudioProvider" }); this.registerProvider("GeminiProvider", ["gemini.google.com"], { name: "GeminiProvider" }); this.registerProvider("GeminiProvider", ["chatgpt.com"], { name: "ChatGPTProvider" }); @@ -801,11 +825,12 @@ const providerUtils = { } }; -providerUtils._initializeSimulatedProviders(); +providerUtils._initializeSimulatedProviders(); // Call to populate for the helper console.log("BACKGROUND: AI Chat Relay: Background Service Worker started."); +// ===== DEBUGGER LOGIC ===== async function attachDebuggerAndEnableFetch(tabId, providerName, patterns) { if (!tabId || !patterns || patterns.length === 0) { console.error(BG_LOG_PREFIX, `attachDebuggerAndEnableFetch: Invalid parameters for tab ${tabId}. Patterns:`, patterns); @@ -833,27 +858,47 @@ async function attachDebuggerAndEnableFetch(tabId, providerName, patterns) { patterns: patterns, isFetchEnabled: false, isAttached: true, - lastKnownRequestId: null + lastKnownRequestId: null }); resolve(); }); }); } - + const currentTabDataForPatterns = debuggerAttachedTabs.get(tabId); if (currentTabDataForPatterns) { - currentTabDataForPatterns.patterns = patterns; - currentTabDataForPatterns.providerName = providerName; + currentTabDataForPatterns.patterns = patterns; + currentTabDataForPatterns.providerName = providerName; + } + + // Explicitly disable Fetch first, in case it's in a weird state + try { + console.log(BG_LOG_PREFIX, `Attempting to disable Fetch domain for tab ${tabId} before re-enabling.`); + await new Promise((resolve, reject) => { + chrome.debugger.sendCommand(debuggee, "Fetch.disable", {}, (disableResponse) => { + if (chrome.runtime.lastError) { + console.warn(BG_LOG_PREFIX, `Warning/Error disabling Fetch for tab ${tabId}:`, chrome.runtime.lastError.message); + // Don't reject, just log, as we want to proceed to enable anyway + } else { + console.log(BG_LOG_PREFIX, `Successfully disabled Fetch for tab ${tabId}. Response:`, disableResponse); + } + resolve(); + }); + }); + } catch (e) { + console.warn(BG_LOG_PREFIX, `Exception during explicit Fetch.disable for tab ${tabId}:`, e); } console.log(BG_LOG_PREFIX, `Enabling Fetch domain for tab ${tabId} with patterns:`, patterns); await new Promise((resolve, reject) => { - chrome.debugger.sendCommand(debuggee, "Fetch.enable", { patterns: patterns }, () => { + const fetchEnableParams = { patterns: patterns }; + console.log(BG_LOG_PREFIX, `Preparing to call Fetch.enable for tab ${tabId} with params:`, JSON.stringify(fetchEnableParams)); + chrome.debugger.sendCommand(debuggee, "Fetch.enable", fetchEnableParams, (response) => { if (chrome.runtime.lastError) { console.error(BG_LOG_PREFIX, `Error enabling Fetch for tab ${tabId}:`, chrome.runtime.lastError.message); return reject(chrome.runtime.lastError); } - console.log(BG_LOG_PREFIX, `Successfully enabled Fetch for tab ${tabId}`); + console.log(BG_LOG_PREFIX, `Successfully enabled Fetch for tab ${tabId}. Response from Fetch.enable:`, response); const currentTabData = debuggerAttachedTabs.get(tabId); if (currentTabData) { currentTabData.isFetchEnabled = true; @@ -863,9 +908,13 @@ async function attachDebuggerAndEnableFetch(tabId, providerName, patterns) { }); } catch (error) { console.error(BG_LOG_PREFIX, `Error in attachDebuggerAndEnableFetch for tab ${tabId}:`, error); - const currentTabData = debuggerAttachedTabs.get(tabId); - if (currentTabData && !currentTabData.isAttached) { - debuggerAttachedTabs.delete(tabId); + // Ensure flags are reset on error + const currentTabDataOnError = debuggerAttachedTabs.get(tabId); + if (currentTabDataOnError) { + currentTabDataOnError.isFetchEnabled = false; // Ensure this is false + // if (!currentTabDataOnError.isAttached) { // Commenting out: if attach failed earlier, it's already deleted. If attach succeeded but enable failed, we want to keep attachment info. + // debuggerAttachedTabs.delete(tabId); + // } } } } @@ -884,14 +933,15 @@ async function detachDebugger(tabId) { console.log(BG_LOG_PREFIX, `Successfully detached debugger from tab ${tabId}`); } debuggerAttachedTabs.delete(tabId); - resolve(); + resolve(); // Resolve even if detach had an error, as we've cleaned up map }); }); - } catch (error) { + } catch (error) { // Catch errors from the Promise constructor itself or unhandled rejections console.error(BG_LOG_PREFIX, `Exception during detach for tab ${tabId}:`, error); - debuggerAttachedTabs.delete(tabId); + debuggerAttachedTabs.delete(tabId); // Ensure cleanup } } else { + // If not attached or no details, still ensure it's not in the map debuggerAttachedTabs.delete(tabId); } } @@ -920,6 +970,8 @@ chrome.debugger.onEvent.addListener((debuggeeId, message, params) => { const tabId = debuggeeId.tabId; const tabInfo = debuggerAttachedTabs.get(tabId); + // DEVELOPER ACTION: This parsing function needs to be robustly implemented + // based on consistent observation of the AI Studio response structure. function parseAiStudioResponse(jsonString) { try { const parsed = JSON.parse(jsonString); @@ -932,16 +984,18 @@ chrome.debugger.onEvent.addListener((debuggeeId, message, params) => { for (const innerMostArray of candidateBlock[0][0][0][0]) { if (Array.isArray(innerMostArray) && innerMostArray.length > 1 && typeof innerMostArray[1] === 'string') { const textSegment = innerMostArray[1]; + // Basic heuristic to filter out "thought process" or similar meta-commentary. + // This will need refinement based on actual response variations. if (!textSegment.toLowerCase().includes("thinking process") && !textSegment.toLowerCase().includes("thought process") && - !textSegment.startsWith("1.") && + !textSegment.startsWith("1.") && // Avoid numbered list from thoughts !textSegment.startsWith("2.") && !textSegment.startsWith("3.") && !textSegment.startsWith("4.") && !textSegment.startsWith("5.") && !textSegment.startsWith("6.") && textSegment.trim() !== "**") { - combinedText += textSegment; + combinedText += textSegment; // Concatenate, newlines are part of the text } } } @@ -950,13 +1004,14 @@ chrome.debugger.onEvent.addListener((debuggeeId, message, params) => { } } } + // Cleanup common markdown/formatting that might not be desired for relay let cleanedMessage = combinedText.replace(/\*\*/g, "").replace(/\\n/g, "\n").replace(/\n\s*\n/g, '\n').trim(); - + if (cleanedMessage) { console.log(BG_LOG_PREFIX, "Parsed AI Studio response to (first 100 chars):", cleanedMessage.substring(0, 100)); return cleanedMessage; } else { - console.warn(BG_LOG_PREFIX, "Parsing AI Studio response yielded empty text. Original (first 200 chars):", jsonString.substring(0, 200)); + console.warn(BG_LOG_PREFIX, "Parsing AI Studio response yielded empty text. Original (first 200 chars):", jsonString.substring(0,200)); } } catch (e) { console.error(BG_LOG_PREFIX, "Error parsing AI Studio response JSON:", e, "Original string (first 200 chars):", jsonString.substring(0, 200)); @@ -966,122 +1021,145 @@ chrome.debugger.onEvent.addListener((debuggeeId, message, params) => { } if (message === "Fetch.requestPaused") { - console.log(BG_LOG_PREFIX, `Fetch.requestPaused for tab ${tabId}, URL: ${params.request.url}, RequestId (debugger): ${params.requestId}, Stage: ${params.responseErrorReason || params.requestStage}`); + // Log immediately upon entering Fetch.requestPaused + console.log(BG_LOG_PREFIX, `ENTERED Fetch.requestPaused for tab ${tabId}, URL: ${params.request.url}, Debugger NetworkRequestId: ${params.requestId}, Stage: ${params.responseErrorReason || params.requestStage}, Headers:`, params.request.headers); - if (!tabInfo || !tabInfo.isFetchEnabled) { - console.log(BG_LOG_PREFIX, `Tab ${tabId} not actively monitored or Fetch not enabled. Continuing request.`); + if (!tabInfo) { + console.warn(BG_LOG_PREFIX, `Fetch.requestPaused for tab ${tabId} but NO tabInfo found in debuggerAttachedTabs. Continuing request.`); + chrome.debugger.sendCommand(debuggeeId, "Fetch.continueRequest", { requestId: params.requestId }); + return; + } + if (!tabInfo.isFetchEnabled) { + console.log(BG_LOG_PREFIX, `Tab ${tabId} not actively monitored or Fetch not enabled (tabInfo.isFetchEnabled is false). Continuing request.`); chrome.debugger.sendCommand(debuggeeId, "Fetch.continueRequest", { requestId: params.requestId }); return; } - if (params.responseStatusCode && params.responseStatusCode >= 200 && params.responseStatusCode < 300) { - if (params.requestStage !== "Response") { - console.warn(BG_LOG_PREFIX, `Proceeding to getResponseBody for tab ${tabId}, debugger requestId ${params.requestId}, even though requestStage is '${params.requestStage}' (expected 'Response'). Status: ${params.responseStatusCode}`); + // This is the application's requestId (e.g., 0, 1, 2...) + // It's CRITICAL that tabInfo.lastKnownRequestId is correctly set when the command was initially forwarded. + const currentOperationRequestId = tabInfo.lastKnownRequestId; + + if (currentOperationRequestId === null || currentOperationRequestId === undefined) { + console.warn(BG_LOG_PREFIX, `Fetch.requestPaused for tab ${tabId} (URL: ${params.request.url}) but tabInfo.lastKnownRequestId is null/undefined. Cannot associate with an operation. Continuing request.`); + chrome.debugger.sendCommand(debuggeeId, "Fetch.continueRequest", { requestId: params.requestId }); + return; + } + + // Check if the URL matches any of the patterns for this tab + const matchesPattern = tabInfo.patterns.some(p => { + try { + // Ensure the pattern is treated as a string and properly escaped for regex construction. + // Basic wildcard to regex: replace * with .*? (non-greedy) + const patternRegex = new RegExp(String(p.urlPattern).replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*?')); + return patternRegex.test(params.request.url); + } catch (e) { + console.error(BG_LOG_PREFIX, `Error creating regex from pattern '${p.urlPattern}':`, e); + return false; } + }); - chrome.debugger.sendCommand(debuggeeId, "Fetch.getResponseBody", { requestId: params.requestId }, (responseBodyData) => { - let currentOperationRequestId = null; - try { - if (chrome.runtime.lastError) { - console.error(BG_LOG_PREFIX, `Error calling Fetch.getResponseBody for tab ${tabId}, debugger requestId ${params.requestId}:`, chrome.runtime.lastError.message); - return; - } - if (!responseBodyData) { - console.error(BG_LOG_PREFIX, `Fetch.getResponseBody returned null or undefined for tab ${tabId}, debugger requestId ${params.requestId}.`); - return; - } - console.log(BG_LOG_PREFIX, `Raw responseBodyData for debugger requestId ${params.requestId} (first 200 chars):`, JSON.stringify(responseBodyData).substring(0, 200) + "..."); + if (!matchesPattern) { + // console.log(BG_LOG_PREFIX, `Fetch.requestPaused for URL ${params.request.url} did NOT match stored patterns for tab ${tabId}. Continuing request. Patterns:`, tabInfo.patterns); + chrome.debugger.sendCommand(debuggeeId, "Fetch.continueRequest", { requestId: params.requestId }); + return; + } + console.log(BG_LOG_PREFIX, `Fetch.requestPaused for URL ${params.request.url} MATCHED pattern. App RequestId: ${currentOperationRequestId}. Proceeding...`); + + console.log(BG_LOG_PREFIX, `[DEBUG_STAGE_STATUS] For matched URL ${params.request.url}, appReqId: ${currentOperationRequestId}, debugger netReqId: ${params.requestId}:`); + console.log(BG_LOG_PREFIX, ` - params.requestStage: ${params.requestStage}`); + console.log(BG_LOG_PREFIX, ` - params.responseStatusCode: ${params.responseStatusCode}`); + console.log(BG_LOG_PREFIX, ` - params.responseErrorReason: ${params.responseErrorReason}`); - const rawBodyText = responseBodyData.base64Encoded ? new TextDecoder('utf-8').decode(Uint8Array.from(atob(responseBodyData.body), c => c.charCodeAt(0))) : responseBodyData.body; + // Additional pre-condition logging + console.log(BG_LOG_PREFIX, `[PRE-CONDITION CHECK] For AppReqId: ${currentOperationRequestId}, Debugger netReqId: ${params.requestId}`); + console.log(BG_LOG_PREFIX, ` - tabInfo (raw):`, tabInfo); // Log the raw tabInfo object + console.log(BG_LOG_PREFIX, ` - tabInfo.patterns:`, tabInfo ? JSON.stringify(tabInfo.patterns) : "tabInfo is null"); + console.log(BG_LOG_PREFIX, ` - tabInfo.lastKnownRequestId: ${tabInfo ? tabInfo.lastKnownRequestId : "tabInfo is null"} (should be currentOperationRequestId)`); + console.log(BG_LOG_PREFIX, ` - currentOperationRequestId (derived from tabInfo.lastKnownRequestId): ${currentOperationRequestId}`); - if (rawBodyText === undefined || rawBodyText === null) { - console.error(BG_LOG_PREFIX, `Extracted rawBodyText is undefined or null for debugger requestId ${params.requestId}.`); - return; - } - - let tempRequestId = tabInfo ? tabInfo.lastKnownRequestId : null; - - if (processingRequest && lastRequestId !== null) { - if (tempRequestId !== null && tempRequestId === lastRequestId) { - currentOperationRequestId = tempRequestId; - console.log(BG_LOG_PREFIX, `Using tabInfo.lastKnownRequestId: ${currentOperationRequestId} for debugger event on tab ${tabId} (debugger requestId ${params.requestId})`); - } else { - currentOperationRequestId = lastRequestId; - console.warn(BG_LOG_PREFIX, `Using global lastRequestId: ${currentOperationRequestId} for debugger event on tab ${tabId} (debugger requestId ${params.requestId}). TabInfo had: ${tempRequestId}.`); - } - } else if (tempRequestId !== null) { - currentOperationRequestId = tempRequestId; - console.warn(BG_LOG_PREFIX, `Not in global processingRequest, but using tabInfo.lastKnownRequestId: ${currentOperationRequestId} for debugger event on tab ${tabId} (debugger requestId ${params.requestId}). This might be unexpected.`); - } else { - currentOperationRequestId = null; - } - - if (currentOperationRequestId === null || currentOperationRequestId === undefined) { - console.warn(BG_LOG_PREFIX, `Could not determine currentOperationRequestId for debugger event on tab ${tabId} (debugger requestId ${params.requestId}). Global lastRequestId: ${lastRequestId}, processingRequest: ${processingRequest}, tabInfo.lastKnownRequestId: ${tabInfo ? tabInfo.lastKnownRequestId : 'N/A'}. Ignoring body.`); - return; - } - - if (rawBodyText === "") { - console.warn(BG_LOG_PREFIX, `Received empty rawBodyText for app requestId ${currentOperationRequestId} (debugger requestId ${params.requestId}). Not processing further for this event, waiting for potential subsequent data.`); - return; - } - console.log(BG_LOG_PREFIX, `Raw bodyText for tab ${tabId}, debugger requestId ${params.requestId} (first 100 chars):`, rawBodyText.substring(0, 100)); - - const dataToSend = rawBodyText; - - console.log(BG_LOG_PREFIX, `Data to send for app requestId ${currentOperationRequestId} (first 100 chars): '${dataToSend ? dataToSend.substring(0, 100) : "[EMPTY_DATA]"}'`); - - if (currentOperationRequestId !== null && currentOperationRequestId !== undefined && pendingRequestDetails.has(currentOperationRequestId)) { - if (tabId) { - const messageToSend = { - type: "DEBUGGER_RESPONSE", - requestId: currentOperationRequestId, - data: dataToSend, - isFinal: true - }; - console.log(BG_LOG_PREFIX, `Attempting to send DEBUGGER_RESPONSE to tab ${tabId} for app requestId ${currentOperationRequestId}. Message object:`, JSON.stringify(messageToSend)); - chrome.tabs.sendMessage(tabId, messageToSend, response => { - if (chrome.runtime.lastError || !response || !response.success) { - const errorMessage = chrome.runtime.lastError ? chrome.runtime.lastError.message : (response && response.error ? response.error : "No response or success false from content script"); - console.error(BG_LOG_PREFIX, `Error sending/acking DEBUGGER_RESPONSE to tab ${tabId} (app requestId ${currentOperationRequestId}): ${errorMessage}`); - - if (relaySocket && relaySocket.readyState === WebSocket.OPEN) { - relaySocket.send(JSON.stringify({ - type: "CHAT_RESPONSE_ERROR", - requestId: currentOperationRequestId, - error: `Failed to send/ack DEBUGGER_RESPONSE to content script for requestId ${currentOperationRequestId}: ${errorMessage}` - })); - } - if (processingRequest && lastRequestId === currentOperationRequestId) { - processingRequest = false; - pendingRequestDetails.delete(currentOperationRequestId); - const tabInfoForReset = debuggerAttachedTabs.get(tabId); - if (tabInfoForReset && tabInfoForReset.lastKnownRequestId === currentOperationRequestId) { - tabInfoForReset.lastKnownRequestId = null; - } - console.log(BG_LOG_PREFIX, `Reset processingRequest due to DEBUGGER_RESPONSE send/ack failure for requestId ${currentOperationRequestId}.`); - processNextRequest(); - } - } else { - console.log(BG_LOG_PREFIX, `Successfully sent DEBUGGER_RESPONSE to tab ${tabId} (app requestId ${currentOperationRequestId}), content script ack:`, response); - console.log(BG_LOG_PREFIX, `Debugger response acknowledged by content script for app requestId ${currentOperationRequestId}. Waiting for FINAL_RESPONSE_TO_RELAY from provider.`); - } - }); - } - } else { - console.warn(BG_LOG_PREFIX, `Skipping sending DEBUGGER_RESPONSE for app requestId ${currentOperationRequestId} (debugger requestId ${params.requestId}) because it's no longer in pendingRequestDetails or ID is null/undefined. Tab: ${tabId}.`); - } - } finally { - console.log(BG_LOG_PREFIX, `[FINALLY] Continuing debugger request ${params.requestId}.`); - chrome.debugger.sendCommand(debuggeeId, "Fetch.continueRequest", { requestId: params.requestId }, () => { - if (chrome.runtime.lastError) { - console.error(BG_LOG_PREFIX, `Error continuing request ${params.requestId} for tab ${tabId} in finally:`, chrome.runtime.lastError.message); - } - }); + // Scenario 1: Network error before even getting a response status (e.g., DNS failure) + if (params.responseErrorReason) { + console.error(BG_LOG_PREFIX, `Response error for ${params.request.url} (debugger netReqId ${params.requestId}): ${params.responseErrorReason}. AppReqId: ${currentOperationRequestId}.`); + const messageToContent = { + type: "PROVIDER_DEBUGGER_EVENT", // Ensure this type is handled by content.js + detail: { + requestId: currentOperationRequestId, + networkRequestId: params.requestId, + error: `Network error: ${params.responseErrorReason}`, + isFinal: true } + }; + chrome.tabs.sendMessage(tabId, messageToContent, response => { + if (chrome.runtime.lastError) console.error(BG_LOG_PREFIX, `Error sending debugger error event (responseErrorReason) to content script for tab ${tabId}:`, chrome.runtime.lastError.message); + else console.log(BG_LOG_PREFIX, `Sent debugger error event (responseErrorReason) to content script for tab ${tabId}, appReqId: ${currentOperationRequestId}, ack:`, response); }); + chrome.debugger.sendCommand(debuggeeId, "Fetch.continueRequest", { requestId: params.requestId }); + return; + } + + // Scenario 2: We have response headers (indicated by params.responseStatusCode being present). + if (params.responseStatusCode) { + if (params.responseStatusCode >= 200 && params.responseStatusCode < 300) { + // SUCCESS: We have a 2xx status, attempt to get the response body. + console.log(BG_LOG_PREFIX, `Attempting Fetch.getResponseBody for ${params.request.url}, appReqId: ${currentOperationRequestId}, debugger netReqId: ${params.requestId} (Stage: ${params.requestStage}, Status: ${params.responseStatusCode})`); + chrome.debugger.sendCommand(debuggeeId, "Fetch.getResponseBody", { requestId: params.requestId }, (responseBodyData) => { + let errorMessageForContent = null; + if (chrome.runtime.lastError) { + errorMessageForContent = `Error calling Fetch.getResponseBody: ${chrome.runtime.lastError.message}`; + console.error(BG_LOG_PREFIX, `${errorMessageForContent} for tab ${tabId}, appReqId: ${currentOperationRequestId}, debugger netReqId: ${params.requestId}`); + } + + console.log(BG_LOG_PREFIX, `[getResponseBody CB] appReqId: ${currentOperationRequestId}, netReqId: ${params.requestId}. responseBodyData raw:`, responseBodyData); + if (responseBodyData) { + console.log(BG_LOG_PREFIX, `[getResponseBody CB] responseBodyData.body (first 100): ${responseBodyData.body ? String(responseBodyData.body).substring(0,100) : 'N/A'}, .base64Encoded: ${responseBodyData.base64Encoded}`); + } + console.log(BG_LOG_PREFIX, `[getResponseBody CB] errorMessageForContent before check: '${errorMessageForContent}'`); + + if (!responseBodyData && !errorMessageForContent) { + errorMessageForContent = "No response body data and no explicit error from getResponseBody."; + console.warn(BG_LOG_PREFIX, `${errorMessageForContent} for tab ${tabId}, appReqId: ${currentOperationRequestId}, debugger netReqId: ${params.requestId}`); + } + console.log(BG_LOG_PREFIX, `[getResponseBody CB] errorMessageForContent AFTER check: '${errorMessageForContent}'`); + + const messageToContent = { + type: "PROVIDER_DEBUGGER_EVENT", + detail: { + requestId: currentOperationRequestId, + networkRequestId: params.requestId, + data: responseBodyData ? responseBodyData.body : null, + base64Encoded: responseBodyData ? responseBodyData.base64Encoded : false, + error: errorMessageForContent, + isFinal: true + } + }; + console.log(BG_LOG_PREFIX, `Sending PROVIDER_DEBUGGER_EVENT (body/error) to content script for tab ${tabId}, appReqId: ${currentOperationRequestId}. Error: ${errorMessageForContent}, Data (first 100): ${messageToContent.detail.data ? String(messageToContent.detail.data).substring(0,100) + "..." : "null"}`); + chrome.tabs.sendMessage(tabId, messageToContent, response => { + if (chrome.runtime.lastError) console.error(BG_LOG_PREFIX, `Error sending/acking debugger event (body/error) to content script for tab ${tabId}:`, chrome.runtime.lastError.message); + else console.log(BG_LOG_PREFIX, `Sent debugger event (body/error) to content script for tab ${tabId}, appReqId: ${currentOperationRequestId}, ack:`, response); + }); + }); + } else { // Non-2xx status code + const httpErrorMessage = `HTTP error ${params.responseStatusCode} for ${params.request.url}`; + console.error(BG_LOG_PREFIX, `${httpErrorMessage}. AppReqId: ${currentOperationRequestId}, Debugger netReqId: ${params.requestId}.`); + const messageToContent = { + type: "PROVIDER_DEBUGGER_EVENT", + detail: { + requestId: currentOperationRequestId, + networkRequestId: params.requestId, + error: httpErrorMessage, + isFinal: true + } + }; + chrome.tabs.sendMessage(tabId, messageToContent, response => { + if (chrome.runtime.lastError) console.error(BG_LOG_PREFIX, `Error sending debugger HTTP error event to content script for tab ${tabId}:`, chrome.runtime.lastError.message); + else console.log(BG_LOG_PREFIX, `Sent debugger HTTP error event to content script for tab ${tabId}, appReqId: ${currentOperationRequestId}, ack:`, response); + }); + chrome.debugger.sendCommand(debuggeeId, "Fetch.continueRequest", { requestId: params.requestId }); + } } else { - console.log(BG_LOG_PREFIX, `Fetch.requestPaused for tab ${tabId} (debugger requestId ${params.requestId}). Not a capturable success response. Status: ${params.responseStatusCode}, ErrorReason: ${params.responseErrorReason}, Stage: ${params.requestStage}. Continuing request.`); + // No response headers yet (no params.responseStatusCode), and no responseErrorReason. + // This could be an earlier stage of the request or one not relevant for body capture. + console.log(BG_LOG_PREFIX, `Request for ${params.request.url} (debugger netReqId: ${params.requestId}, appReqId: ${currentOperationRequestId}) does not have responseStatusCode. Stage: ${params.requestStage}. Continuing request.`); chrome.debugger.sendCommand(debuggeeId, "Fetch.continueRequest", { requestId: params.requestId }); } } diff --git a/extension/content.js b/extension/content.js index f0fdb9e..98038b7 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1,19 +1,41 @@ +/* + * 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/. + */ +// AI Chat Relay - Content Script +// Prefix for console logs const CS_LOG_PREFIX = '[CS CONTENT]'; console.log(CS_LOG_PREFIX, "Content Script Injected & Loaded"); -let provider = null; +// Global state +let provider = null; // This will be set by initializeContentRelay let setupComplete = false; let currentRequestId = null; -let processingMessage = false; -let responseMonitoringTimers = []; -let captureAttempts = 0; -const MAX_CAPTURE_ATTEMPTS = 30; -const CAPTURE_DELAY = 1000; +let processingMessage = false; // Flag to track if we're currently processing a message +let responseMonitoringTimers = []; // Keep track of all monitoring timers +let captureAttempts = 0; // Track how many capture attempts we've made +const MAX_CAPTURE_ATTEMPTS = 30; // Maximum number of capture attempts +const CAPTURE_DELAY = 1000; // 1 second between capture attempts +// Helper function to find potential input fields and buttons function findPotentialSelectors() { console.log(CS_LOG_PREFIX, "Searching for potential input fields and buttons..."); - + + // Find all textareas const textareas = document.querySelectorAll('textarea'); console.log(CS_LOG_PREFIX, "Found textareas:", textareas.length); textareas.forEach((textarea, index) => { @@ -25,7 +47,8 @@ function findPotentialSelectors() { name: textarea.name }); }); - + + // Find all input fields const inputs = document.querySelectorAll('input[type="text"]'); console.log(CS_LOG_PREFIX, "Found text inputs:", inputs.length); inputs.forEach((input, index) => { @@ -37,7 +60,8 @@ function findPotentialSelectors() { name: input.name }); }); - + + // Find all buttons const buttons = document.querySelectorAll('button'); console.log(CS_LOG_PREFIX, "Found buttons:", buttons.length); buttons.forEach((button, index) => { @@ -51,102 +75,109 @@ function findPotentialSelectors() { } function initializeContentRelay() { - if (setupComplete) { - console.log(CS_LOG_PREFIX, "Initialization already attempted or complete."); - return; - } - console.log(CS_LOG_PREFIX, 'Initializing content relay...'); - - if (window.providerUtils) { - const detectedProvider = window.providerUtils.detectProvider(window.location.hostname); - provider = detectedProvider; - - console.log(CS_LOG_PREFIX, 'Detected provider:', provider ? provider.name : 'None'); - - if (provider && typeof provider.getStreamingApiPatterns === 'function') { - const patternsFromProvider = provider.getStreamingApiPatterns(); - console.log(CS_LOG_PREFIX, 'Retrieved patterns from provider:', patternsFromProvider); - - if (patternsFromProvider && patternsFromProvider.length > 0) { - chrome.runtime.sendMessage({ - type: "SET_DEBUGGER_TARGETS", - providerName: provider.name, - patterns: patternsFromProvider - }, response => { - if (chrome.runtime.lastError) { - console.error(CS_LOG_PREFIX, 'Error sending SET_DEBUGGER_TARGETS:', chrome.runtime.lastError.message); - } else { - console.log(CS_LOG_PREFIX, 'SET_DEBUGGER_TARGETS message sent, response:', response); - } - }); - } else { - console.log(CS_LOG_PREFIX, 'No patterns returned by provider or patterns array is empty.'); - } - } else { - if (provider) { - console.log(CS_LOG_PREFIX, `Provider '${provider.name}' found, but getStreamingApiPatterns method is missing or not a function.`); - } else { - console.log(CS_LOG_PREFIX, 'No current provider instance found to get patterns from.'); - } + if (setupComplete) { + console.log(CS_LOG_PREFIX, "Initialization already attempted or complete."); + return; } - } else { - console.error(CS_LOG_PREFIX, 'providerUtils not found. Cannot detect provider or send patterns.'); - } + console.log(CS_LOG_PREFIX, 'Initializing content relay...'); - chrome.runtime.sendMessage({ - type: "CHAT_RELAY_READY", - chatInterface: provider ? provider.name : "unknown" - }, response => { - if (chrome.runtime.lastError) { - console.error(CS_LOG_PREFIX, 'Error sending CHAT_RELAY_READY:', chrome.runtime.lastError.message); + // Provider Detection + if (window.providerUtils) { + const detectedProvider = window.providerUtils.detectProvider(window.location.hostname); // New detection method + provider = detectedProvider; // Update the global provider instance + + console.log(CS_LOG_PREFIX, 'Detected provider:', provider ? provider.name : 'None'); + + if (provider && typeof provider.getStreamingApiPatterns === 'function') { + const patternsFromProvider = provider.getStreamingApiPatterns(); + console.log(CS_LOG_PREFIX, 'Retrieved patterns from provider:', patternsFromProvider); + + if (patternsFromProvider && patternsFromProvider.length > 0) { + chrome.runtime.sendMessage({ + type: "SET_DEBUGGER_TARGETS", + providerName: provider.name, + patterns: patternsFromProvider + }, response => { + if (chrome.runtime.lastError) { + console.error(CS_LOG_PREFIX, 'Error sending SET_DEBUGGER_TARGETS:', chrome.runtime.lastError.message); + } else { + console.log(CS_LOG_PREFIX, 'SET_DEBUGGER_TARGETS message sent, response:', response); + } + }); + } else { + console.log(CS_LOG_PREFIX, 'No patterns returned by provider or patterns array is empty.'); + } + } else { + if (provider) { + console.log(CS_LOG_PREFIX, `Provider '${provider.name}' found, but getStreamingApiPatterns method is missing or not a function.`); + } else { + console.log(CS_LOG_PREFIX, 'No current provider instance found to get patterns from.'); + } + } } else { - console.log(CS_LOG_PREFIX, 'CHAT_RELAY_READY message sent, response:', response); + console.error(CS_LOG_PREFIX, 'providerUtils not found. Cannot detect provider or send patterns.'); } - }); + // Send CHAT_RELAY_READY (always, after attempting provider setup) + chrome.runtime.sendMessage({ + type: "CHAT_RELAY_READY", + chatInterface: provider ? provider.name : "unknown" // Add provider name + }, response => { + if (chrome.runtime.lastError) { + console.error(CS_LOG_PREFIX, 'Error sending CHAT_RELAY_READY:', chrome.runtime.lastError.message); + } else { + console.log(CS_LOG_PREFIX, 'CHAT_RELAY_READY message sent, response:', response); + } + }); + + // Setup message listeners (will be called later, once, via setupMessageListeners) - if (provider) { - console.log(CS_LOG_PREFIX, `Proceeding with provider-specific setup for: ${provider.name}`); - setTimeout(() => { - if (!setupComplete) { - findPotentialSelectors(); - setupAutomaticResponseCapture(); - startElementPolling(); - console.log(CS_LOG_PREFIX, "Provider-specific DOM setup (response capture, polling) initiated after delay."); - } - }, 2000); - } else { - console.warn(CS_LOG_PREFIX, "No provider detected. Some provider-specific features (response capture, element polling) will not be initialized."); - } - - setupComplete = true; - console.log(CS_LOG_PREFIX, "Content relay initialization sequence finished."); + // If a provider is detected, proceed with provider-specific setup after a delay + if (provider) { + console.log(CS_LOG_PREFIX, `Proceeding with provider-specific setup for: ${provider.name}`); + setTimeout(() => { + // Double check setupComplete flag in case of async issues or rapid calls, though less likely here. + if (!setupComplete) { + findPotentialSelectors(); + setupAutomaticResponseCapture(); + startElementPolling(); + console.log(CS_LOG_PREFIX, "Provider-specific DOM setup (response capture, polling) initiated after delay."); + } + }, 2000); // Delay to allow page elements to fully render + } else { + console.warn(CS_LOG_PREFIX, "No provider detected. Some provider-specific features (response capture, element polling) will not be initialized."); + } + + setupComplete = true; + console.log(CS_LOG_PREFIX, "Content relay initialization sequence finished."); } +// Poll for elements that might be loaded dynamically function startElementPolling() { if (!provider) { console.warn(CS_LOG_PREFIX, "Cannot start element polling: no provider detected."); return; } console.log(CS_LOG_PREFIX, "Starting element polling..."); - + + // Check every 2 seconds for the input field and send button const pollingInterval = setInterval(() => { - if (!provider) { - clearInterval(pollingInterval); - console.warn(CS_LOG_PREFIX, "Stopping element polling: provider became unavailable."); - return; + if (!provider) { // Provider might have been lost or was never there + clearInterval(pollingInterval); + console.warn(CS_LOG_PREFIX, "Stopping element polling: provider became unavailable."); + return; } const inputField = document.querySelector(provider.inputSelector); const sendButton = document.querySelector(provider.sendButtonSelector); - + if (inputField) { console.log(CS_LOG_PREFIX, "Found input field:", inputField); } - + if (sendButton) { console.log(CS_LOG_PREFIX, "Found send button:", sendButton); } - + if (inputField && sendButton) { console.log(CS_LOG_PREFIX, "Found all required elements, stopping polling"); clearInterval(pollingInterval); @@ -154,15 +185,18 @@ function startElementPolling() { }, 2000); } +// Function to send a message to the chat interface function sendChatMessage(text) { if (!provider) { console.error(CS_LOG_PREFIX, "Cannot send chat message: No provider configured."); - processingMessage = false; + processingMessage = false; // Reset flag return false; } - return sendChatMessageWithRetry(text, 5); + // Try to send the message with retries + return sendChatMessageWithRetry(text, 5); // Try up to 5 times } +// Helper function to send a message with retries function sendChatMessageWithRetry(text, maxRetries, currentRetry = 0) { if (!provider) { console.error(CS_LOG_PREFIX, `Cannot send chat message with retry (attempt ${currentRetry + 1}/${maxRetries}): No provider.`); @@ -178,13 +212,13 @@ function sendChatMessageWithRetry(text, maxRetries, currentRetry = 0) { setTimeout(() => { sendChatMessageWithRetry(text, maxRetries, currentRetry + 1); }, 1000); - return true; + return true; } console.error(CS_LOG_PREFIX, "Could not find input field after all retries"); - processingMessage = false; + processingMessage = false; return false; } - + const sendButton = document.querySelector(provider.sendButtonSelector); if (!sendButton) { console.log(CS_LOG_PREFIX, `Could not find send button (attempt ${currentRetry + 1}/${maxRetries})`); @@ -193,31 +227,31 @@ function sendChatMessageWithRetry(text, maxRetries, currentRetry = 0) { setTimeout(() => { sendChatMessageWithRetry(text, maxRetries, currentRetry + 1); }, 1000); - return true; + return true; } console.error(CS_LOG_PREFIX, "Could not find send button after all retries"); - processingMessage = false; + processingMessage = false; return false; } - + const result = provider.sendChatMessage(text, inputField, sendButton); - + if (result) { - console.log(CS_LOG_PREFIX, "Message sent successfully via provider."); - if (provider.shouldSkipResponseMonitoring && provider.shouldSkipResponseMonitoring()) { - console.log(CS_LOG_PREFIX, `Provider ${provider.name} has requested to skip response monitoring.`); - processingMessage = false; - } else { - console.log(CS_LOG_PREFIX, `Waiting ${CAPTURE_DELAY / 1000} seconds before starting to monitor for responses...`); - const timer = setTimeout(() => { - console.log(CS_LOG_PREFIX, "Starting to monitor for responses now"); - startMonitoringForResponse(); - }, CAPTURE_DELAY); - responseMonitoringTimers.push(timer); - } + console.log(CS_LOG_PREFIX, "Message sent successfully via provider."); + if (provider.shouldSkipResponseMonitoring && provider.shouldSkipResponseMonitoring()) { + console.log(CS_LOG_PREFIX, `Provider ${provider.name} has requested to skip response monitoring.`); + processingMessage = false; // Message sent, no monitoring, so reset. + } else { + console.log(CS_LOG_PREFIX, `Waiting ${CAPTURE_DELAY/1000} seconds before starting to monitor for responses...`); + const timer = setTimeout(() => { + console.log(CS_LOG_PREFIX, "Starting to monitor for responses now"); + startMonitoringForResponse(); + }, CAPTURE_DELAY); + responseMonitoringTimers.push(timer); + } } else { - console.error(CS_LOG_PREFIX, "Provider reported failure sending message."); - processingMessage = false; + console.error(CS_LOG_PREFIX, "Provider reported failure sending message."); + processingMessage = false; // Reset on failure } return result; } catch (error) { @@ -227,47 +261,49 @@ function sendChatMessageWithRetry(text, maxRetries, currentRetry = 0) { setTimeout(() => { sendChatMessageWithRetry(text, maxRetries, currentRetry + 1); }, 1000); - return true; + return true; } - processingMessage = false; + processingMessage = false; return false; } } +// Function to start monitoring for a response function startMonitoringForResponse() { if (!provider || !provider.responseSelector || !provider.getResponseText) { console.error(CS_LOG_PREFIX, "Cannot monitor for response: Provider or necessary provider methods/selectors are not configured."); - processingMessage = false; + processingMessage = false; // Can't monitor, so reset. return; } console.log(CS_LOG_PREFIX, "Starting response monitoring process..."); - captureAttempts = 0; + captureAttempts = 0; // Reset capture attempts for this new monitoring session const attemptCapture = () => { if (!processingMessage && currentRequestId === null) { console.log(CS_LOG_PREFIX, "Response monitoring stopped because processingMessage is false and currentRequestId is null (likely request completed or cancelled)."); - return; + return; // Stop if no longer processing a message } - + if (captureAttempts >= MAX_CAPTURE_ATTEMPTS) { console.error(CS_LOG_PREFIX, "Maximum response capture attempts reached. Stopping monitoring."); - if (currentRequestId !== null) { - chrome.runtime.sendMessage({ - type: "FINAL_RESPONSE_TO_RELAY", - requestId: currentRequestId, - error: "Response capture timed out in content script.", - isFinal: true - }, response => { - if (chrome.runtime.lastError) { - console.error(CS_LOG_PREFIX, 'Error sending capture timeout error:', chrome.runtime.lastError.message); - } else { - console.log(CS_LOG_PREFIX, 'Capture timeout error sent to background, response:', response); - } - }); + // Send a timeout/error message back to the background script + if (currentRequestId !== null) { // Ensure there's a request ID to report error for + chrome.runtime.sendMessage({ + type: "FINAL_RESPONSE_TO_RELAY", + requestId: currentRequestId, + error: "Response capture timed out in content script.", + isFinal: true // Treat as final to unblock server + }, response => { + if (chrome.runtime.lastError) { + console.error(CS_LOG_PREFIX, 'Error sending capture timeout error:', chrome.runtime.lastError.message); + } else { + console.log(CS_LOG_PREFIX, 'Capture timeout error sent to background, response:', response); + } + }); } processingMessage = false; - currentRequestId = null; + currentRequestId = null; // Clear current request ID as it timed out return; } @@ -277,40 +313,45 @@ function startMonitoringForResponse() { const responseElement = document.querySelector(provider.responseSelector); if (responseElement) { const responseText = provider.getResponseText(responseElement); - const isFinal = provider.isResponseComplete ? provider.isResponseComplete(responseElement) : false; + const isFinal = provider.isResponseComplete ? provider.isResponseComplete(responseElement) : false; // Default to false if not implemented console.log(CS_LOG_PREFIX, `Captured response text (length: ${responseText.length}), isFinal: ${isFinal}`); - + + // Send to background chrome.runtime.sendMessage({ - type: "FINAL_RESPONSE_TO_RELAY", + type: "FINAL_RESPONSE_TO_RELAY", // Or a new type like "PARTIAL_RESPONSE" if needed requestId: currentRequestId, text: responseText, isFinal: isFinal }, response => { - if (chrome.runtime.lastError) { - console.error(CS_LOG_PREFIX, 'Error sending response data to background:', chrome.runtime.lastError.message); - } else { - console.log(CS_LOG_PREFIX, 'Response data sent to background, response:', response); - } + if (chrome.runtime.lastError) { + console.error(CS_LOG_PREFIX, 'Error sending response data to background:', chrome.runtime.lastError.message); + } else { + console.log(CS_LOG_PREFIX, 'Response data sent to background, response:', response); + } }); if (isFinal) { console.log(CS_LOG_PREFIX, "Final response detected. Stopping monitoring."); - processingMessage = false; - return; + processingMessage = false; // Reset flag as processing is complete + // currentRequestId will be cleared by handleProviderResponse or if a new message comes + return; } } else { console.log(CS_LOG_PREFIX, "Response element not found yet."); } + // Continue polling const timer = setTimeout(attemptCapture, CAPTURE_DELAY); responseMonitoringTimers.push(timer); }; + // Initial call to start the process attemptCapture(); } +// Function to set up automatic response capture using MutationObserver function setupAutomaticResponseCapture() { if (!provider || !provider.responseContainerSelector || typeof provider.handleMutation !== 'function') { console.warn(CS_LOG_PREFIX, "Cannot set up automatic response capture: Provider or necessary provider methods/selectors are not configured."); @@ -323,35 +364,47 @@ function setupAutomaticResponseCapture() { if (!targetNode) { console.warn(CS_LOG_PREFIX, `Response container element ('${provider.responseContainerSelector}') not found. MutationObserver not started. Will rely on polling or debugger.`); + // Optionally, retry finding the targetNode after a delay, or fall back to polling exclusively. + // For now, we just warn and don't start the observer. return; } const config = { childList: true, subtree: true, characterData: true }; const callback = (mutationsList, observer) => { + // If not processing a message, or no current request, don't do anything. + // This check is crucial to prevent processing mutations when not expected. if (!processingMessage || currentRequestId === null) { - return; + // console.log(CS_LOG_PREFIX, "MutationObserver: Ignoring mutation, not actively processing a message or no currentRequestId."); + return; } - + + // Let the provider handle the mutation and decide if it's relevant + // The provider's handleMutation should call handleProviderResponse with the requestId try { - provider.handleMutation(mutationsList, observer, currentRequestId, handleProviderResponse); + provider.handleMutation(mutationsList, observer, currentRequestId, handleProviderResponse); } catch (e) { - console.error(CS_LOG_PREFIX, "Error in provider.handleMutation:", e); + console.error(CS_LOG_PREFIX, "Error in provider.handleMutation:", e); } }; const observer = new MutationObserver(callback); - + try { - observer.observe(targetNode, config); - console.log(CS_LOG_PREFIX, "MutationObserver started on:", targetNode); + observer.observe(targetNode, config); + console.log(CS_LOG_PREFIX, "MutationObserver started on:", targetNode); } catch (e) { - console.error(CS_LOG_PREFIX, "Failed to start MutationObserver:", e, "on target:", targetNode); + console.error(CS_LOG_PREFIX, "Failed to start MutationObserver:", e, "on target:", targetNode); + // Fallback or error handling if observer cannot be started } + // Store the observer if we need to disconnect it later + // e.g., window.chatRelayObserver = observer; } +// Function to monitor for the completion of a response (e.g., when a "thinking" indicator disappears) +// This is a more generic version, specific providers might have more tailored logic. function monitorResponseCompletion(element) { if (!provider || !provider.thinkingIndicatorSelector) { console.warn(CS_LOG_PREFIX, "Cannot monitor response completion: No thinkingIndicatorSelector in provider."); @@ -360,20 +413,35 @@ function monitorResponseCompletion(element) { const thinkingIndicator = document.querySelector(provider.thinkingIndicatorSelector); if (!thinkingIndicator) { + // If the indicator is already gone, assume completion or it never appeared. + // Provider's getResponseText should ideally capture the full text. console.log(CS_LOG_PREFIX, "Thinking indicator not found, assuming response is complete or was never present."); + // Potentially call captureResponse one last time if needed by provider logic + // captureResponse(null, true); // Example, might need adjustment return; } console.log(CS_LOG_PREFIX, "Thinking indicator found. Monitoring for its removal..."); const observer = new MutationObserver((mutationsList, obs) => { - + // Check if the thinking indicator (or its parent, if it's removed directly) is no longer in the DOM + // or if a specific class/attribute indicating completion appears. + // This logic needs to be robust and provider-specific. + + // A simple check: if the element itself is removed or a known parent. + // More complex checks might involve looking for specific classes on the response element. if (!document.body.contains(thinkingIndicator)) { console.log(CS_LOG_PREFIX, "Thinking indicator removed. Assuming response completion."); obs.disconnect(); - captureResponse(null, true); + // Capture the final response + // This assumes captureResponse can get the full text now. + // The 'true' flag indicates this is considered the final capture. + captureResponse(null, true); } + // Add other provider-specific checks here if needed }); + // Observe the parent of the thinking indicator for changes in its children (e.g., removal of the indicator) + // Or observe attributes of the indicator itself if it changes state instead of being removed. if (thinkingIndicator.parentNode) { observer.observe(thinkingIndicator.parentNode, { childList: true, subtree: true }); } else { @@ -381,116 +449,141 @@ function monitorResponseCompletion(element) { } } +// Specific monitoring for Gemini, if needed (example) function monitorGeminiResponse(element) { - console.log(CS_LOG_PREFIX, "Monitoring Gemini response element:", element); - const observer = new MutationObserver((mutationsList, obs) => { - let isComplete = false; + // Gemini specific logic for monitoring response element for completion + // This might involve looking for specific attributes or child elements + // that indicate the stream has finished. + console.log(CS_LOG_PREFIX, "Monitoring Gemini response element:", element); + // Example: Observe for a specific class or attribute change + const observer = new MutationObserver((mutationsList, obs) => { + let isComplete = false; + // Check mutations for signs of completion based on Gemini's DOM structure + // For instance, a "generating" class is removed, or a "complete" attribute is set. + // This is highly dependent on the actual Gemini interface. + // Example (conceptual): + // if (element.classList.contains('response-complete')) { + // isComplete = true; + // } - if (isComplete) { - console.log(CS_LOG_PREFIX, "Gemini response detected as complete by mutation."); - obs.disconnect(); - captureResponse(element, true); - } - }); - observer.observe(element, { attributes: true, childList: true, subtree: true }); - console.log(CS_LOG_PREFIX, "Gemini response observer started."); + if (isComplete) { + console.log(CS_LOG_PREFIX, "Gemini response detected as complete by mutation."); + obs.disconnect(); + captureResponse(element, true); // Capture final response + } + }); + observer.observe(element, { attributes: true, childList: true, subtree: true }); + console.log(CS_LOG_PREFIX, "Gemini response observer started."); } function monitorGeminiContentStability(element) { - let lastContent = ""; - let stableCount = 0; - const STABLE_THRESHOLD = 3; - const CHECK_INTERVAL = 300; + let lastContent = ""; + let stableCount = 0; + const STABLE_THRESHOLD = 3; // Number of intervals content must remain unchanged + const CHECK_INTERVAL = 300; // Milliseconds - console.log(CS_LOG_PREFIX, "Starting Gemini content stability monitoring for element:", element); + console.log(CS_LOG_PREFIX, "Starting Gemini content stability monitoring for element:", element); - const intervalId = setInterval(() => { - if (!processingMessage || currentRequestId === null) { - console.log(CS_LOG_PREFIX, "Gemini stability: Stopping, no longer processing message."); - clearInterval(intervalId); - return; - } + const intervalId = setInterval(() => { + if (!processingMessage || currentRequestId === null) { + console.log(CS_LOG_PREFIX, "Gemini stability: Stopping, no longer processing message."); + clearInterval(intervalId); + return; + } - const currentContent = provider.getResponseText(element); - if (currentContent === lastContent) { - stableCount++; - console.log(CS_LOG_PREFIX, `Gemini stability: Content stable, count: ${stableCount}`); - } else { - lastContent = currentContent; - stableCount = 0; - console.log(CS_LOG_PREFIX, `Gemini stability: Content changed. New length: ${currentContent.length}`); - if (provider.sendPartialUpdates) { - handleProviderResponse(currentRequestId, currentContent, false); - } - } + const currentContent = provider.getResponseText(element); + if (currentContent === lastContent) { + stableCount++; + console.log(CS_LOG_PREFIX, `Gemini stability: Content stable, count: ${stableCount}`); + } else { + lastContent = currentContent; + stableCount = 0; // Reset if content changes + console.log(CS_LOG_PREFIX, `Gemini stability: Content changed. New length: ${currentContent.length}`); + // Send partial update if provider wants it + if (provider.sendPartialUpdates) { + handleProviderResponse(currentRequestId, currentContent, false); + } + } - if (stableCount >= STABLE_THRESHOLD) { - console.log(CS_LOG_PREFIX, "Gemini stability: Content stable for threshold. Assuming final."); - clearInterval(intervalId); - const finalContent = provider.getResponseText(element); - handleProviderResponse(currentRequestId, finalContent, true); - } - }, CHECK_INTERVAL); - responseMonitoringTimers.push(intervalId); + if (stableCount >= STABLE_THRESHOLD) { + console.log(CS_LOG_PREFIX, "Gemini stability: Content stable for threshold. Assuming final."); + clearInterval(intervalId); + // Ensure the very latest content is captured and sent as final + const finalContent = provider.getResponseText(element); + handleProviderResponse(currentRequestId, finalContent, true); + } + }, CHECK_INTERVAL); + responseMonitoringTimers.push(intervalId); // Store to clear if needed } +// Function to capture the response text +// potentialTurnElement is passed by some providers (like Gemini) if they identify the specific response "turn" element function captureResponse(potentialTurnElement = null, isFinal = false) { if (!provider || !provider.getResponseText) { console.error(CS_LOG_PREFIX, "Cannot capture response: No provider or getResponseText method."); if (currentRequestId !== null) { - handleProviderResponse(currentRequestId, "Error: Provider misconfiguration for response capture.", true); + handleProviderResponse(currentRequestId, "Error: Provider misconfiguration for response capture.", true); } return; } + // Use the potentialTurnElement if provided and valid, otherwise fall back to provider.responseSelector let responseElement = null; if (potentialTurnElement && typeof potentialTurnElement === 'object' && potentialTurnElement.nodeType === 1) { - responseElement = potentialTurnElement; - console.log(CS_LOG_PREFIX, "Using provided potentialTurnElement for capture:", responseElement); + responseElement = potentialTurnElement; + console.log(CS_LOG_PREFIX, "Using provided potentialTurnElement for capture:", responseElement); } else { - if (!provider.responseSelector) { - console.error(CS_LOG_PREFIX, "Cannot capture response: No responseSelector in provider and no valid potentialTurnElement given."); - if (currentRequestId !== null) { - handleProviderResponse(currentRequestId, "Error: Provider responseSelector missing.", true); + if (!provider.responseSelector) { + console.error(CS_LOG_PREFIX, "Cannot capture response: No responseSelector in provider and no valid potentialTurnElement given."); + if (currentRequestId !== null) { + handleProviderResponse(currentRequestId, "Error: Provider responseSelector missing.", true); + } + return; } - return; - } - responseElement = document.querySelector(provider.responseSelector); - console.log(CS_LOG_PREFIX, "Using provider.responseSelector for capture:", provider.responseSelector); + responseElement = document.querySelector(provider.responseSelector); + console.log(CS_LOG_PREFIX, "Using provider.responseSelector for capture:", provider.responseSelector); } if (!responseElement) { console.warn(CS_LOG_PREFIX, "Response element not found during capture."); + // If it's supposed to be final and element is not found, it might be an issue. if (isFinal && currentRequestId !== null) { - handleProviderResponse(currentRequestId, "Error: Response element not found for final capture.", true); + handleProviderResponse(currentRequestId, "Error: Response element not found for final capture.", true); } return; } const responseText = provider.getResponseText(responseElement); + // isFinal flag is now passed as an argument, but provider might have its own check const trulyFinal = isFinal || (provider.isResponseComplete ? provider.isResponseComplete(responseElement) : false); console.log(CS_LOG_PREFIX, `Captured response (length: ${responseText.length}), isFinal: ${trulyFinal}. Passed isFinal: ${isFinal}`); - + if (currentRequestId === null) { - console.warn(CS_LOG_PREFIX, "captureResponse: currentRequestId is null. Cannot send response to background."); - return; + console.warn(CS_LOG_PREFIX, "captureResponse: currentRequestId is null. Cannot send response to background."); + return; } + // Call handleProviderResponse, which will then relay to background + // This centralizes the logic for sending FINAL_RESPONSE_TO_RELAY handleProviderResponse(currentRequestId, responseText, trulyFinal); } +// Function to clear all active response monitoring timers function clearResponseMonitoringTimers() { - console.log(CS_LOG_PREFIX, `Clearing ${responseMonitoringTimers.length} response monitoring timers.`); - responseMonitoringTimers.forEach(timerId => clearTimeout(timerId)); - responseMonitoringTimers = []; + console.log(CS_LOG_PREFIX, `Clearing ${responseMonitoringTimers.length} response monitoring timers.`); + responseMonitoringTimers.forEach(timerId => clearTimeout(timerId)); // Works for both setTimeout and setInterval IDs + responseMonitoringTimers = []; // Reset the array } -function setupMessageListeners() { +// Define message listener function *before* calling it +// Renamed setupAutomaticMessageSending to setupMessageListeners +function setupMessageListeners() { // Renamed from setupAutomaticMessageSending + // Listen for commands from the background script chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "SEND_CHAT_MESSAGE") { - const messageContent = message.messageContent; + const messageContent = message.messageContent; // Use messageContent let messagePreview = ""; if (typeof messageContent === 'string') { messagePreview = `String: "${messageContent.substring(0, 50)}..."`; @@ -501,9 +594,9 @@ function setupMessageListeners() { } else if (messageContent && typeof messageContent === 'object' && messageContent !== null) { messagePreview = `Object data (type: ${Object.prototype.toString.call(messageContent)})`; } else { - messagePreview = `Data type: ${typeof messageContent}, Value: ${String(messageContent).substring(0, 50)}`; + messagePreview = `Data type: ${typeof messageContent}, Value: ${String(messageContent).substring(0,50)}`; } - console.log(CS_LOG_PREFIX, "Received command to send message:", messagePreview, "Request ID:", message.requestId, "Last Processed Text:", message.lastProcessedText ? `"${message.lastProcessedText.substring(0, 50)}..."` : "null"); + console.log(CS_LOG_PREFIX, "Received command to send message:", messagePreview, "Request ID:", message.requestId, "Last Processed Text:", message.lastProcessedText ? `"${message.lastProcessedText.substring(0,50)}..."` : "null"); if (!provider) { console.error(CS_LOG_PREFIX, "Cannot send message: No provider detected."); @@ -511,6 +604,7 @@ function setupMessageListeners() { return true; } + // Superseding / duplicate requestId logic (unchanged) if (processingMessage && currentRequestId !== null && currentRequestId !== message.requestId) { console.warn(CS_LOG_PREFIX, `New message (requestId: ${message.requestId}) received while request ${currentRequestId} was processing. The new message will supersede the old one.`); clearResponseMonitoringTimers(); @@ -518,10 +612,11 @@ function setupMessageListeners() { currentRequestId = null; } else if (processingMessage && currentRequestId === message.requestId) { console.warn(CS_LOG_PREFIX, `Received duplicate SEND_CHAT_MESSAGE for already processing requestId: ${message.requestId}. Ignoring duplicate command.`); - sendResponse({ success: false, error: "Duplicate command for already processing requestId." }); + sendResponse({ success: false, error: "Duplicate command for already processing requestId."}); return true; } + // Attempt to get the input field const inputField = document.querySelector(provider.inputSelector); let currentUIInputText = null; @@ -529,18 +624,24 @@ function setupMessageListeners() { currentUIInputText = inputField.value; } else { console.error(CS_LOG_PREFIX, "Input field not found via selector:", provider.inputSelector, "Cannot process SEND_CHAT_MESSAGE for requestId:", message.requestId); - if (currentRequestId === message.requestId) { - processingMessage = false; + // Reset state if this was meant to be the current request + if (currentRequestId === message.requestId) { // Check if we were about to set this as current + processingMessage = false; // Ensure it's reset if it was about to become active + // currentRequestId is not yet set to message.requestId here if it's a new command } sendResponse({ success: false, error: "Input field not found by content script." }); return true; } + // Duplicate Message Scenario Check: + // 1. We have a record of the last processed text from the background script. + // 2. The server is trying to send that exact same text again (messageContent === message.lastProcessedText). + // 3. The UI input field also currently contains that exact same text (currentUIInputText === messageContent). let isDuplicateMessageScenario = false; if (typeof messageContent === 'string' && typeof message.lastProcessedText === 'string' && typeof currentUIInputText === 'string') { isDuplicateMessageScenario = message.lastProcessedText && - messageContent === message.lastProcessedText && - currentUIInputText === messageContent; + messageContent === message.lastProcessedText && + currentUIInputText === messageContent; } if (isDuplicateMessageScenario) { @@ -548,14 +649,16 @@ function setupMessageListeners() { console.log(CS_LOG_PREFIX, ` Server wants to send: "${messageContent.substring(0, 50)}..."`); console.log(CS_LOG_PREFIX, ` Last processed text was: "${message.lastProcessedText.substring(0, 50)}..."`); console.log(CS_LOG_PREFIX, ` Current UI input is: "${currentUIInputText.substring(0, 50)}..."`); - + console.log(CS_LOG_PREFIX, "Clearing input field and notifying background."); - inputField.value = ''; + inputField.value = ''; // Clear the input field + // Optionally, dispatch 'input' or 'change' events if the website needs them for reactivity + // inputField.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); chrome.runtime.sendMessage({ type: "DUPLICATE_MESSAGE_HANDLED", requestId: message.requestId, - originalText: messageContent + originalText: messageContent // The text that was duplicated }, response => { if (chrome.runtime.lastError) { console.error(CS_LOG_PREFIX, 'Error sending DUPLICATE_MESSAGE_HANDLED:', chrome.runtime.lastError.message); @@ -564,22 +667,29 @@ function setupMessageListeners() { } }); + // This request is now considered "handled" by the content script (as a duplicate). + // Reset content script's immediate processing state if this was about to become the active request. + // Note: currentRequestId might not yet be message.requestId if this is a brand new command. + // The background script will manage its own processingRequest flag based on DUPLICATE_MESSAGE_HANDLED. + // For content.js, we ensure we don't proceed to send this. + // If currentRequestId was already message.requestId (e.g. from a retry/glitch), reset it. if (currentRequestId === message.requestId) { - processingMessage = false; - currentRequestId = null; + processingMessage = false; + currentRequestId = null; } - + sendResponse({ success: true, message: "Duplicate message scenario handled by clearing input." }); return true; } + // If not a duplicate, proceed with normal sending logic: console.log(CS_LOG_PREFIX, `Not a duplicate scenario for requestId: ${message.requestId}. Proceeding to send.`); processingMessage = true; currentRequestId = message.requestId; console.log(CS_LOG_PREFIX, `Set currentRequestId to ${currentRequestId} for processing.`); if (provider && typeof provider.sendChatMessage === 'function') { - provider.sendChatMessage(messageContent, currentRequestId) + provider.sendChatMessage(messageContent, currentRequestId) // Pass messageContent and the requestId .then(success => { if (success) { console.log(CS_LOG_PREFIX, `Message sending initiated successfully via provider for requestId: ${currentRequestId}.`); @@ -588,13 +698,15 @@ function setupMessageListeners() { provider.initiateResponseCapture(currentRequestId, handleProviderResponse); } else { console.error(CS_LOG_PREFIX, `Provider ${provider.name} does not have initiateResponseCapture method. Response will not be processed for requestId ${currentRequestId}.`); - chrome.runtime.sendMessage({ - type: "FINAL_RESPONSE_TO_RELAY", - requestId: currentRequestId, - error: `Provider ${provider.name} cannot capture responses. Message sent but no response will be relayed.`, - isFinal: true + // If no response capture, this request might hang on the server side. + // Consider sending an error back to background.js or directly to server. + chrome.runtime.sendMessage({ + type: "FINAL_RESPONSE_TO_RELAY", + requestId: currentRequestId, + error: `Provider ${provider.name} cannot capture responses. Message sent but no response will be relayed.`, + isFinal: true }); - processingMessage = false; + processingMessage = false; // As we can't process response currentRequestId = null; } sendResponse({ success: true, message: "Message sending initiated by provider." }); @@ -613,34 +725,42 @@ function setupMessageListeners() { } else { console.error(CS_LOG_PREFIX, "Provider or provider.sendChatMessage is not available for requestId:", message.requestId); processingMessage = false; - currentRequestId = null; + currentRequestId = null; // Ensure reset if it was about to be set sendResponse({ success: false, error: "Provider or sendChatMessage method missing." }); } - return true; + return true; // Indicate async response } else if (message.type === "DEBUGGER_RESPONSE") { - console.log(CS_LOG_PREFIX, "Received DEBUGGER_RESPONSE message object:", JSON.stringify(message)); + console.log(CS_LOG_PREFIX, "Received DEBUGGER_RESPONSE message object:", JSON.stringify(message)); // Log full received message console.log(CS_LOG_PREFIX, `Processing DEBUGGER_RESPONSE for app requestId: ${currentRequestId}. Debugger requestId: ${message.requestId}. Data length: ${message.data ? message.data.length : 'null'}`); if (!provider) { - console.error(CS_LOG_PREFIX, "Received DEBUGGER_RESPONSE but no provider is active."); - sendResponse({ success: false, error: "No provider active." }); - return true; + console.error(CS_LOG_PREFIX, "Received DEBUGGER_RESPONSE but no provider is active."); + sendResponse({ success: false, error: "No provider active." }); + return true; } if (typeof provider.handleDebuggerData !== 'function') { - console.error(CS_LOG_PREFIX, `Provider ${provider.name} does not implement handleDebuggerData.`); - sendResponse({ success: false, error: `Provider ${provider.name} does not support debugger method.` }); - return true; + console.error(CS_LOG_PREFIX, `Provider ${provider.name} does not implement handleDebuggerData.`); + sendResponse({ success: false, error: `Provider ${provider.name} does not support debugger method.` }); + return true; + } + // IMPORTANT: The message.requestId IS the application's original requestId, + // associated by background.js. We should use this directly. + // The content.js currentRequestId might have been cleared if the provider.sendChatMessage failed, + // but the debugger stream might still be valid for message.requestId. + + if (!message.requestId && message.requestId !== 0) { // Check if message.requestId is missing or invalid (0 is a valid requestId) + console.error(CS_LOG_PREFIX, `Received DEBUGGER_RESPONSE without a valid message.requestId. Ignoring. Message:`, message); + sendResponse({ success: false, error: "DEBUGGER_RESPONSE missing requestId." }); + return true; } - if (!message.requestId && message.requestId !== 0) { - console.error(CS_LOG_PREFIX, `Received DEBUGGER_RESPONSE without a valid message.requestId. Ignoring. Message:`, message); - sendResponse({ success: false, error: "DEBUGGER_RESPONSE missing requestId." }); - return true; - } - - console.log(CS_LOG_PREFIX, `Calling provider.handleDebuggerData for requestId: ${message.requestId} with isFinal: ${message.isFinal}`); - provider.handleDebuggerData(message.requestId, message.data, message.isFinal, handleProviderResponse); - sendResponse({ success: true, message: "Debugger data passed to provider." }); - return true; + // Pass the raw data, the message's requestId, and isFinal flag to the provider + // The provider's handleDebuggerData is responsible for calling handleProviderResponse + const errorFromBackground = message.error || null; // Get error from message, default to null + console.log(CS_LOG_PREFIX, `Calling provider.handleDebuggerData for requestId: ${message.requestId} with isFinal: ${message.isFinal}, errorFromBackground: ${errorFromBackground}`); // Log before call + provider.handleDebuggerData(message.requestId, message.data, message.isFinal, errorFromBackground, handleProviderResponse); // Pass errorFromBackground + // Acknowledge receipt of the debugger data + sendResponse({ success: true, message: "Debugger data (and potential error) passed to provider." }); + return true; // Indicate async response (provider will eventually call handleProviderResponse) } else if (message.type === "PING_TAB") { console.log(CS_LOG_PREFIX, "Received PING_TAB from background script."); @@ -650,11 +770,13 @@ function setupMessageListeners() { console.log(CS_LOG_PREFIX, `Received STOP_STREAMING command for requestId: ${message.requestId}`); if (provider && typeof provider.stopStreaming === 'function') { provider.stopStreaming(message.requestId); + // The handleProviderResponse might have already cleared currentRequestId if it matched. + // We ensure processingMessage is false if this was the active request. if (currentRequestId === message.requestId) { - processingMessage = false; - currentRequestId = null; - clearResponseMonitoringTimers(); - console.log(CS_LOG_PREFIX, `STOP_STREAMING: Cleared active currentRequestId ${message.requestId} and processingMessage flag.`); + processingMessage = false; + currentRequestId = null; // Explicitly clear here as well + clearResponseMonitoringTimers(); // Ensure any DOM timers are also cleared + console.log(CS_LOG_PREFIX, `STOP_STREAMING: Cleared active currentRequestId ${message.requestId} and processingMessage flag.`); } sendResponse({ success: true, message: `Streaming stopped for requestId: ${message.requestId}` }); } else { @@ -664,69 +786,79 @@ function setupMessageListeners() { return true; } + // Handle other potential message types if needed + // else if (message.type === '...') { ... } + // If the message type isn't handled, return false or undefined console.log(CS_LOG_PREFIX, "Unhandled message type received:", message.type || message.action); + // sendResponse({ success: false, error: "Unhandled message type" }); // Optional: send error back + // return false; // Or let it be undefined }); } +// Generic callback function passed to the provider. +// The provider calls this when it has determined the final response or a chunk of it. function handleProviderResponse(requestId, responseText, isFinal) { - console.log(CS_LOG_PREFIX, `handleProviderResponse called for requestId: ${requestId}. Data length: ${responseText ? String(responseText).length : 'null'}. isFinal: ${isFinal}. Data (first 100 chars): '${(responseText || "").substring(0, 100)}', Type: ${typeof responseText}`); - - if (currentRequestId !== requestId && currentRequestId !== null) { - console.warn(CS_LOG_PREFIX, `handleProviderResponse: content.js currentRequestId (${currentRequestId}) differs from provider's response requestId (${requestId}). Proceeding with provider's requestId for data relay.`); + console.log(CS_LOG_PREFIX, `handleProviderResponse called for requestId: ${requestId}. Data length: ${responseText ? String(responseText).length : 'null'}. isFinal: ${isFinal}. Data (first 100 chars): '${(responseText || "").substring(0,100)}', Type: ${typeof responseText}`); + + // The requestId parameter here is the one that the provider determined this response is for. + // This should be the definitive requestId for this piece of data. + // We log if content.js's currentRequestId is different, but proceed with the passed 'requestId'. + if (currentRequestId !== requestId && currentRequestId !== null) { // also check currentRequestId is not null to avoid warning on initial load or after reset + console.warn(CS_LOG_PREFIX, `handleProviderResponse: content.js currentRequestId (${currentRequestId}) differs from provider's response requestId (${requestId}). Proceeding with provider's requestId for data relay.`); } + // Continue to process with the 'requestId' passed to this function. if (chrome.runtime && chrome.runtime.sendMessage) { - const MAX_RESPONSE_TEXT_LENGTH = 500 * 1024; - let messageToSendToBackground; + const MAX_RESPONSE_TEXT_LENGTH = 500 * 1024; // 500KB limit for safety + let messageToSendToBackground; - // Encode special Unicode characters before transmission - const encodedText = responseText ? encodeURIComponent(responseText) : ""; - - if (responseText && typeof responseText === 'string' && responseText.length > MAX_RESPONSE_TEXT_LENGTH) { - console.warn(CS_LOG_PREFIX, `ResponseText for requestId ${requestId} is too large (${responseText.length} bytes). Sending error and truncated text.`); - messageToSendToBackground = { - type: "FINAL_RESPONSE_TO_RELAY", - requestId: requestId, - error: `Response too large to transmit (length: ${responseText.length}). Check content script logs for truncated version.`, - text: `Error: Response too large (length: ${responseText.length}). See AI Studio for full response.`, - isFinal: true, - encoded: true - }; - } else { - messageToSendToBackground = { - type: "FINAL_RESPONSE_TO_RELAY", - requestId: requestId, - text: encodedText, - isFinal: isFinal, - encoded: true - }; - } + if (responseText && typeof responseText === 'string' && responseText.length > MAX_RESPONSE_TEXT_LENGTH) { + console.warn(CS_LOG_PREFIX, `ResponseText for requestId ${requestId} is too large (${responseText.length} bytes). Sending error and truncated text.`); + messageToSendToBackground = { + type: "FINAL_RESPONSE_TO_RELAY", + requestId: requestId, + error: `Response too large to transmit (length: ${responseText.length}). Check content script logs for truncated version.`, + // text: responseText.substring(0, MAX_RESPONSE_TEXT_LENGTH) + "... (truncated by content.js)", // Optionally send truncated + text: `Error: Response too large (length: ${responseText.length}). See AI Studio for full response.`, // Simpler error text + isFinal: true // This is a final error state + }; + } else { + messageToSendToBackground = { + type: "FINAL_RESPONSE_TO_RELAY", + requestId: requestId, + text: responseText, // Can be null if AIStudioProvider parsed it as such + isFinal: isFinal + }; + } - console.log(CS_LOG_PREFIX, `[REQ-${requestId}] PRE-SEND to BG: Type: ${messageToSendToBackground.type}, isFinal: ${messageToSendToBackground.isFinal}, HasError: ${!!messageToSendToBackground.error}, TextLength: ${messageToSendToBackground.text ? String(messageToSendToBackground.text).length : (messageToSendToBackground.error ? String(messageToSendToBackground.error).length : 'N/A')}`); - try { - chrome.runtime.sendMessage(messageToSendToBackground, response => { - if (chrome.runtime.lastError) { - console.error(CS_LOG_PREFIX, `[REQ-${requestId}] SEND FAILED to BG: ${chrome.runtime.lastError.message}. Message attempted:`, JSON.stringify(messageToSendToBackground).substring(0, 500)); - } else { - console.log(CS_LOG_PREFIX, `[REQ-${requestId}] SEND SUCCESS to BG. Ack from BG:`, response); - } - }); - } catch (syncError) { - console.error(CS_LOG_PREFIX, `[REQ-${requestId}] SYNC ERROR sending to BG: ${syncError.message}. Message attempted:`, JSON.stringify(messageToSendToBackground).substring(0, 500), syncError); - } + console.log(CS_LOG_PREFIX, `[REQ-${requestId}] PRE-SEND to BG: Type: ${messageToSendToBackground.type}, isFinal: ${messageToSendToBackground.isFinal}, HasError: ${!!messageToSendToBackground.error}, TextLength: ${messageToSendToBackground.text ? String(messageToSendToBackground.text).length : (messageToSendToBackground.error ? String(messageToSendToBackground.error).length : 'N/A')}`); + try { + chrome.runtime.sendMessage(messageToSendToBackground, response => { + if (chrome.runtime.lastError) { + console.error(CS_LOG_PREFIX, `[REQ-${requestId}] SEND FAILED to BG: ${chrome.runtime.lastError.message}. Message attempted:`, JSON.stringify(messageToSendToBackground).substring(0, 500)); + } else { + console.log(CS_LOG_PREFIX, `[REQ-${requestId}] SEND SUCCESS to BG. Ack from BG:`, response); + } + }); + } catch (syncError) { + console.error(CS_LOG_PREFIX, `[REQ-${requestId}] SYNC ERROR sending to BG: ${syncError.message}. Message attempted:`, JSON.stringify(messageToSendToBackground).substring(0, 500), syncError); + } } else { - console.error(CS_LOG_PREFIX, "Cannot send FINAL_RESPONSE_TO_RELAY, runtime is invalid."); + console.error(CS_LOG_PREFIX, "Cannot send FINAL_RESPONSE_TO_RELAY, runtime is invalid."); } if (isFinal) { + // Reset content script state AFTER sending the final response message, + // but only if the finalized requestId matches what content.js currently considers its active request. if (currentRequestId === requestId) { processingMessage = false; currentRequestId = null; - clearResponseMonitoringTimers(); + clearResponseMonitoringTimers(); // Clear any timers associated with this request console.log(CS_LOG_PREFIX, `Processing finished for active requestId: ${requestId}. State reset in content.js.`); } else { console.log(CS_LOG_PREFIX, `Processing finished for requestId: ${requestId}. This was not the active content.js requestId (${currentRequestId}), so content.js state not altered by this finalization. However, timers for ${requestId} might need explicit cleanup if any were started by it.`); + // If specific timers were associated with 'requestId' (not currentRequestId), they should be cleared by the provider or a more granular timer management. } } else { console.log(CS_LOG_PREFIX, `Partial response processed for requestId: ${requestId}. Awaiting more data or final flag.`); @@ -734,20 +866,22 @@ function handleProviderResponse(requestId, responseText, isFinal) { } +// Call initialization functions +// Ensure DOM is ready for provider detection and DOM manipulations if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", attemptInitialization); + document.addEventListener("DOMContentLoaded", attemptInitialization); } else { - attemptInitialization(); + attemptInitialization(); // DOMContentLoaded has already fired } function attemptInitialization() { - console.log(CS_LOG_PREFIX, "Attempting initialization..."); - if (window.attemptedInitialization) { - console.log(CS_LOG_PREFIX, "Initialization already attempted. Skipping."); - return; - } - window.attemptedInitialization = true; - initializeContentRelay(); - setupMessageListeners(); - console.log(CS_LOG_PREFIX, "Initialization attempt complete. Message listeners set up."); + console.log(CS_LOG_PREFIX, "Attempting initialization..."); + if (window.attemptedInitialization) { + console.log(CS_LOG_PREFIX, "Initialization already attempted. Skipping."); + return; + } + window.attemptedInitialization = true; + initializeContentRelay(); // Initialize provider detection, DOM setup, etc. + setupMessageListeners(); // Setup listeners for messages from background script + console.log(CS_LOG_PREFIX, "Initialization attempt complete. Message listeners set up."); } diff --git a/extension/providers/claude.js b/extension/providers/claude.js index 4056945..b41e237 100644 --- a/extension/providers/claude.js +++ b/extension/providers/claude.js @@ -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 []; diff --git a/extension/providers/gemini.js b/extension/providers/gemini.js index e697657..3bc56e0 100644 --- a/extension/providers/gemini.js +++ b/extension/providers/gemini.js @@ -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

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}

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

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, ">"); + } + + 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"; } }