hins111's picture
Upload 5 files
5a8f14b verified
// server.cjs (优化版 v2.17 - 增加日志ID & 常量)
const express = require('express');
const fs = require('fs');
const path = require('path');
const cors = require('cors');
// --- 依赖检查 ---
let playwright, expect;
const requiredModules = ['express', 'playwright', '@playwright/test', 'cors'];
const missingModules = [];
for (const modName of requiredModules) {
try {
if (modName === 'playwright') {
playwright = require(modName);
} else if (modName === '@playwright/test') {
expect = require(modName).expect;
} else {
require(modName);
}
// console.log(`✅ 模块 ${modName} 已加载。`); // Optional: Log success
} catch (e) {
console.error(`❌ 模块 ${modName} 未找到。`);
missingModules.push(modName);
}
}
if (missingModules.length > 0) {
console.error("-------------------------------------------------------------");
console.error("❌ 错误:缺少必要的依赖模块!");
console.error("请根据您使用的包管理器运行以下命令安装依赖:");
console.error("-------------------------------------------------------------");
console.error(` npm install ${missingModules.join(' ')}`);
console.error(" 或");
console.error(` yarn add ${missingModules.join(' ')}`);
console.error(" 或");
console.error(` pnpm install ${missingModules.join(' ')}`);
console.error("-------------------------------------------------------------");
process.exit(1);
}
// --- 配置 ---
const SERVER_PORT = process.env.PORT || 2048;
const CHROME_DEBUGGING_PORT = 8848;
const CDP_ADDRESS = `http://127.0.0.1:${CHROME_DEBUGGING_PORT}`;
const AI_STUDIO_URL_PATTERN = 'aistudio.google.com/';
const RESPONSE_COMPLETION_TIMEOUT = 300000; // 5分钟总超时
const POLLING_INTERVAL = 300; // 非流式/通用检查间隔
const POLLING_INTERVAL_STREAM = 200; // 流式检查轮询间隔 (ms)
// v2.12: Timeout for secondary checks *after* spinner disappears
const POST_SPINNER_CHECK_DELAY_MS = 500; // Spinner消失后稍作等待再检查其他状态
const FINAL_STATE_CHECK_TIMEOUT_MS = 1500; // 检查按钮和输入框最终状态的超时
const SPINNER_CHECK_TIMEOUT_MS = 1000; // 检查Spinner状态的超时
const POST_COMPLETION_BUFFER = 1000; // JSON模式下可以缩短检查后等待时间
const SILENCE_TIMEOUT_MS = 1500; // 文本静默多久后认为稳定 (Spinner消失后)
// --- 常量 ---
const MODEL_NAME = 'google-ai-studio-via-playwright-cdp-json';
const CHAT_COMPLETION_ID_PREFIX = 'chatcmpl-';
// --- 选择器常量 ---
const INPUT_SELECTOR = 'ms-prompt-input-wrapper textarea';
const SUBMIT_BUTTON_SELECTOR = 'button[aria-label="Run"]';
const RESPONSE_CONTAINER_SELECTOR = 'ms-chat-turn .chat-turn-container.model'; // 选择器指向 AI 模型回复的容器
const RESPONSE_TEXT_SELECTOR = 'ms-cmark-node.cmark-node';
const LOADING_SPINNER_SELECTOR = 'button[aria-label="Run"] svg .stoppable-spinner';
const ERROR_TOAST_SELECTOR = 'div.toast.warning, div.toast.error';
// !! 新增:清空聊天记录相关选择器 !!
const CLEAR_CHAT_BUTTON_SELECTOR = 'button[aria-label="Clear chat"][data-test-clear="outside"]:has(span.material-symbols-outlined:has-text("refresh"))'; // 清空按钮 (带图标确认)
const CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR = 'button.mdc-button:has-text("Continue")'; // 确认对话框中的 "Continue" 按钮
// !! 新增:清空验证相关常量 !!
const CLEAR_CHAT_VERIFY_TIMEOUT_MS = 5000; // 等待清空生效的总超时时间 (ms)
const CLEAR_CHAT_VERIFY_INTERVAL_MS = 300; // 检查清空状态的轮询间隔 (ms)
// v2.16: JSON Structure Prompt (Restored for non-streaming)
const prepareAIStudioPrompt = (userPrompt, systemPrompt = null) => {
let fullPrompt = `
IMPORTANT: Your entire response MUST be a single JSON object. Do not include any text outside of this JSON object.
The JSON object must have a single key named "response".
Inside the value of the "response" key (which is a string), you MUST put the exact marker "<<<START_RESPONSE>>>"" at the very beginning of your actual answer. There should be NO text before this marker within the response string.
`;
if (systemPrompt && systemPrompt.trim() !== '') {
fullPrompt += `\nSystem Instruction: ${systemPrompt}\n`;
}
fullPrompt += `
Example 1:
User asks: "What is the capital of France?"
Your response MUST be:
{
"response": "<<<START_RESPONSE>>>The capital of France is Paris."
}
Example 2:
User asks: "Write a python function to add two numbers"
Your response MUST be:
{
"response": "<<<START_RESPONSE>>>\\\`\\\`\\\`python\\\\ndef add(a, b):\\\\n return a + b\\\\n\\\`\\\`\\\`"
}
Now, answer the following user prompt, ensuring your output strictly adheres to the JSON format AND the start marker requirement described above:
User Prompt: "${userPrompt}"
Your JSON Response:
`;
return fullPrompt;
};
// v2.26: Use JSON prompt for streaming as well -> vNEXT: Use Markdown Code Block for streaming
// vNEXT: Instruct AI to output *incomplete* JSON for streaming -> vNEXT: Instruct AI to output Markdown Code Block
const prepareAIStudioPromptStream = (userPrompt, systemPrompt = null) => {
let fullPrompt = `
IMPORTANT: For this streaming request, your entire response MUST be enclosed in a single markdown code block (like \`\`\` block \`\`\`).
Inside this code block, your actual answer text MUST start immediately after the exact marker "<<<START_RESPONSE>>>".
Start your response exactly with "\`\`\`\n<<<START_RESPONSE>>>" followed by your answer content.
Continue outputting your answer content. You SHOULD include the final closing "\`\`\`" at the very end of your full response stream.
`;
if (systemPrompt && systemPrompt.trim() !== '') {
fullPrompt += `\nSystem Instruction: ${systemPrompt}\n`;
}
fullPrompt += `
Example 1 (Streaming):
User asks: "What is the capital of France?"
Your streamed response MUST look like this over time:
Stream part 1: \`\`\`\n<<<START_RESPONSE>>>The capital
Stream part 2: of France is
Stream part 3: Paris.\n\`\`\`
Example 2 (Streaming):
User asks: "Write a python function to add two numbers"
Your streamed response MUST look like this over time:
Stream part 1: \`\`\`\n<<<START_RESPONSE>>>\`\`\`python\ndef add(a, b):
Stream part 2: \n return a + b\n
Stream part 3: \`\`\`\n\`\`\`
Now, answer the following user prompt, ensuring your output strictly adheres to the markdown code block, start marker, and streaming requirements described above:
User Prompt: "${userPrompt}"
Your Response (Streaming, within a markdown code block):
`;
return fullPrompt;
};
const app = express();
// --- 全局变量 ---
let browser = null;
let page = null;
let isPlaywrightReady = false;
let isInitializing = false;
// v2.18: 请求队列和处理状态
let requestQueue = [];
let isProcessing = false;
// --- Playwright 初始化函数 ---
async function initializePlaywright() {
if (isPlaywrightReady || isInitializing) return;
isInitializing = true;
console.log(`--- 初始化 Playwright: 连接到 ${CDP_ADDRESS} ---`);
try {
browser = await playwright.chromium.connectOverCDP(CDP_ADDRESS, { timeout: 20000, ignoreHTTPSErrors: true });
console.log('✅ 成功连接到正在运行的 Chrome 实例!');
browser.once('disconnected', () => {
console.error('❌ Playwright 与 Chrome 的连接已断开!');
isPlaywrightReady = false;
browser = null;
page = null;
// v2.18: Clear queue on disconnect? Maybe not, let requests fail naturally.
});
await new Promise(resolve => setTimeout(resolve, 500));
const contexts = browser.contexts();
let context;
if (!contexts || contexts.length === 0) {
await new Promise(resolve => setTimeout(resolve, 1500));
const retryContexts = browser.contexts();
if (!retryContexts || retryContexts.length === 0) {
throw new Error('无法获取浏览器上下文。请检查 Chrome 是否已正确启动并响应。');
}
context = retryContexts[0];
} else {
context = contexts[0];
}
let foundPage = null;
const pages = context.pages();
console.log(`-> 发现 ${pages.length} 个页面。正在搜索 AI Studio (匹配 "${AI_STUDIO_URL_PATTERN}")...`);
for (const p of pages) {
try {
if (p.isClosed()) continue;
const url = p.url();
if (url.includes(AI_STUDIO_URL_PATTERN) && url.includes('/prompts/')) {
console.log(`-> 找到 AI Studio 页面: ${url}`);
foundPage = p;
break;
}
} catch (pageError) {
if (!p.isClosed()) {
console.warn(` 警告:评估页面 URL 时出错: ${pageError.message.split('\\n')[0]}`);
}
}
}
if (!foundPage) {
throw new Error(`未在已连接的 Chrome 中找到包含 "${AI_STUDIO_URL_PATTERN}" 和 "/prompts/" 的页面。请确保 auto_connect_aistudio.js 已成功运行,并且 AI Studio 页面 (例如 prompts/new_chat) 已打开。`);
}
page = foundPage;
console.log('-> 已定位到 AI Studio 页面。');
await page.bringToFront();
console.log('-> 尝试将页面置于前台。检查加载状态...');
await page.waitForLoadState('domcontentloaded', { timeout: 15000 });
console.log('-> 页面 DOM 已加载。');
try {
console.log("-> 尝试定位核心输入区域以确认页面就绪...");
await page.locator('ms-prompt-input-wrapper').waitFor({ state: 'visible', timeout: 15000 });
console.log("-> 核心输入区域容器已找到。");
} catch(initCheckError) {
console.warn(`⚠️ 初始化检查警告:未能快速定位到核心输入区域容器。页面可能仍在加载或结构有变: ${initCheckError.message.split('\\n')[0]}`);
await saveErrorSnapshot('init_check_fail');
}
isPlaywrightReady = true;
console.log('✅ Playwright 已准备就绪。');
// v2.18: Start processing queue if playwright just became ready and queue has items
if (requestQueue.length > 0 && !isProcessing) {
console.log(`[Queue] Playwright 就绪,队列中有 ${requestQueue.length} 个请求,开始处理...`);
processQueue();
}
} catch (error) {
console.error(`❌ 初始化 Playwright 失败: ${error.message}`);
await saveErrorSnapshot('init_fail');
isPlaywrightReady = false;
browser = null;
page = null;
} finally {
isInitializing = false;
}
}
// --- 中间件 ---
app.use(cors());
app.use(express.json({ limit: '20mb' }));
app.use(express.urlencoded({ limit: '20mb', extended: true })); // Also for urlencoded
// --- Web UI Route ---
app.get('/', (req, res) => {
const htmlPath = path.join(__dirname, 'index.html');
if (fs.existsSync(htmlPath)) {
res.sendFile(htmlPath);
} else {
res.status(404).send('Error: index.html not found.');
}
});
// --- 健康检查 ---
app.get('/health', (req, res) => {
const isConnected = browser?.isConnected() ?? false;
const isPageValid = page && !page.isClosed();
const queueLength = requestQueue.length;
const status = {
status: 'Unknown',
message: '',
playwrightReady: isPlaywrightReady,
browserConnected: isConnected,
pageValid: isPageValid,
initializing: isInitializing,
processing: isProcessing,
queueLength: queueLength
};
if (isPlaywrightReady && isPageValid && isConnected) {
status.status = 'OK';
status.message = `Server running, Playwright connected, page valid. Currently ${isProcessing ? 'processing' : 'idle'} with ${queueLength} item(s) in queue.`;
res.status(200).json(status);
} else {
status.status = 'Error';
const reasons = [];
if (!isPlaywrightReady) reasons.push("Playwright not initialized or ready");
if (!isPageValid) reasons.push("Target page not found or closed");
if (!isConnected) reasons.push("Browser disconnected");
if (isInitializing) reasons.push("Playwright is currently initializing");
status.message = `Service Unavailable. Issues: ${reasons.join(', ')}. Currently ${isProcessing ? 'processing' : 'idle'} with ${queueLength} item(s) in queue.`;
res.status(503).json(status);
}
});
// --- 新增:API 辅助函数 ---
// 验证聊天请求
// v2.19: Updated validation to handle array content (text parts only)
function validateChatRequest(messages) {
const reqId = messages?.[0]?.reqId || 'validation'; // Get reqId if passed, fallback
if (!messages || !Array.isArray(messages) || messages.length === 0) {
throw new Error(`[${reqId}] Invalid request: "messages" array is missing or empty.`);
}
const lastUserMessage = messages.filter(msg => msg.role === 'user').pop();
if (!lastUserMessage) {
throw new Error(`[${reqId}] Invalid request: No user message found in the "messages" array.`);
}
let userPromptContentInput = lastUserMessage.content;
let processedUserPrompt = ""; // Initialize as empty string
// 1. Handle null/undefined content
if (userPromptContentInput === null || userPromptContentInput === undefined) {
console.warn(`[${reqId}] (Validation) Warning: Last user message content is null or undefined. Treating as empty string.`);
processedUserPrompt = "";
}
// 2. Handle string content (most common case)
else if (typeof userPromptContentInput === 'string') {
processedUserPrompt = userPromptContentInput;
}
// 3. Handle array content (attempt compatibility with OpenAI vision format)
else if (Array.isArray(userPromptContentInput)) {
console.log(`[${reqId}] (Validation) Info: Last user message content is an array. Processing text parts...`);
let textParts = [];
let unsupportedParts = false;
for (const item of userPromptContentInput) {
if (typeof item === 'object' && item !== null && item.type === 'text' && typeof item.text === 'string') {
textParts.push(item.text);
} else if (typeof item === 'object' && item !== null && item.type === 'image_url') {
console.warn(`[${reqId}] (Validation) Warning: Found 'image_url' content part. This proxy cannot process images via AI Studio web UI. Ignoring image.`);
unsupportedParts = true;
// Optionally, include the URL as text, but it might confuse the AI:
// textParts.push(`[Image URL (Unsupported): ${item.image_url?.url || 'N/A'}]`);
} else {
// Handle other unexpected items in the array - stringify them?
console.warn(`[${reqId}] (Validation) Warning: Found unexpected item in content array (Type: ${typeof item}). Converting to JSON string.`);
try {
textParts.push(JSON.stringify(item));
unsupportedParts = true;
} catch (e) {
console.error(`[${reqId}] (Validation) Error stringifying array item: ${e}. Skipping item.`);
}
}
}
processedUserPrompt = textParts.join('\\n'); // Join text parts with newline
if (unsupportedParts) {
console.warn(`[${reqId}] (Validation) Warning: Some parts of the array content were unsupported or ignored (e.g., images). Only text parts were included in the final prompt.`);
}
if (!processedUserPrompt) {
console.warn(`[${reqId}] (Validation) Warning: Processed array content resulted in an empty prompt.`);
}
}
// 4. Handle other object types (fallback to JSON stringify)
else if (typeof userPromptContentInput === 'object' && userPromptContentInput !== null) {
console.warn(`[${reqId}] (Validation) Warning: Last user message content is an object but not a recognized array format. Converting to JSON string.`);
try {
processedUserPrompt = JSON.stringify(userPromptContentInput);
} catch (stringifyError) {
console.error(`[${reqId}] (Validation) Error stringifying object user content: ${stringifyError}. Falling back to empty string.`);
processedUserPrompt = "";
}
}
// 5. Handle other primitive types (e.g., number, boolean) - convert to string
else {
console.warn(`[${reqId}] (Validation) Warning: Last user message content is an unexpected primitive type (${typeof userPromptContentInput}). Converting to string.`);
processedUserPrompt = String(userPromptContentInput);
}
// Final check - should always be a string here
if (typeof processedUserPrompt !== 'string') {
console.error(`[${reqId}] (Validation) CRITICAL ERROR: Failed to process user prompt content into a string. Type after processing: ${typeof processedUserPrompt}. Using empty string.`);
processedUserPrompt = ""; // Safeguard
}
// Extract system prompt (remains the same logic)
const systemPromptContent = messages.find(msg => msg.role === 'system')?.content;
// Basic validation for system prompt (ensure it's a string if provided)
let processedSystemPrompt = null;
if (systemPromptContent !== null && systemPromptContent !== undefined) {
if (typeof systemPromptContent === 'string') {
processedSystemPrompt = systemPromptContent;
} else {
console.warn(`[${reqId}] (Validation) Warning: System prompt content is not a string (Type: ${typeof systemPromptContent}). Ignoring system prompt.`);
// Optionally stringify it: processedSystemPrompt = JSON.stringify(systemPromptContent);
}
}
return {
userPrompt: processedUserPrompt, // Ensure this is always a string
systemPrompt: processedSystemPrompt // Ensure this is null or a string
};
}
// 与页面交互并提交 Prompt
async function interactAndSubmitPrompt(page, prompt, reqId) {
console.log(`[${reqId}] 开始页面交互...`);
const inputField = page.locator(INPUT_SELECTOR);
const submitButton = page.locator(SUBMIT_BUTTON_SELECTOR);
const loadingSpinner = page.locator(LOADING_SPINNER_SELECTOR); // Keep spinner locator here for later use
console.log(`[${reqId}] - 等待输入框可用...`);
try {
await inputField.waitFor({ state: 'visible', timeout: 10000 });
} catch (e) {
console.error(`[${reqId}] ❌ 查找输入框失败!`);
await saveErrorSnapshot(`input_field_not_visible_${reqId}`);
throw new Error(`[${reqId}] Failed to find visible input field. Error: ${e.message}`);
}
console.log(`[${reqId}] - 清空并填充输入框...`);
await inputField.fill(prompt, { timeout: 60000 });
console.log(`[${reqId}] - 等待运行按钮可用...`);
try {
await expect(submitButton).toBeEnabled({ timeout: 10000 });
} catch (e) {
console.error(`[${reqId}] ❌ 等待运行按钮变为可用状态超时!`);
await saveErrorSnapshot(`submit_button_not_enabled_before_click_${reqId}`);
throw new Error(`[${reqId}] Submit button not enabled before click. Error: ${e.message}`);
}
console.log(`[${reqId}] - 点击运行按钮...`);
await submitButton.click({ timeout: 10000 });
return { inputField, submitButton, loadingSpinner }; // Return locators
}
// 定位最新的回复元素
async function locateResponseElements(page, { inputField, submitButton, loadingSpinner }, reqId) {
console.log(`[${reqId}] 定位 AI 回复元素...`);
let lastResponseContainer;
let responseElement;
let locatedResponseElements = false;
for (let i = 0; i < 3 && !locatedResponseElements; i++) {
try {
console.log(`[${reqId}] 尝试定位最新回复容器及文本元素 (第 ${i + 1} 次)`);
await page.waitForTimeout(500 + i * 500); // 固有延迟
const isEndState = await checkEndConditionQuickly(page, loadingSpinner, inputField, submitButton, 250, reqId);
const locateTimeout = isEndState ? 3000 : 60000;
if (isEndState) {
console.log(`[${reqId}] -> 检测到结束条件已满足,使用 ${locateTimeout / 1000}s 超时进行定位。`);
}
lastResponseContainer = page.locator(RESPONSE_CONTAINER_SELECTOR).last();
await lastResponseContainer.waitFor({ state: 'attached', timeout: locateTimeout });
responseElement = lastResponseContainer.locator(RESPONSE_TEXT_SELECTOR);
await responseElement.waitFor({ state: 'attached', timeout: locateTimeout });
console.log(`[${reqId}] 回复容器和文本元素定位成功。`);
locatedResponseElements = true;
} catch (locateError) {
console.warn(`[${reqId}] 第 ${i + 1} 次定位回复元素失败: ${locateError.message.split('\n')[0]}`);
if (i === 2) {
await saveErrorSnapshot(`response_locate_fail_${reqId}`);
throw new Error(`[${reqId}] Failed to locate response elements after multiple attempts.`);
}
}
}
if (!locatedResponseElements) throw new Error(`[${reqId}] Could not locate response elements.`);
return { responseElement, lastResponseContainer }; // Return located elements
}
// --- 新增:处理流式响应 (vNEXT: 标记优先,静默结束,无JSON处理) ---
async function handleStreamingResponse(res, responseElement, page, { inputField, submitButton, loadingSpinner }, operationTimer, reqId, isRequestCancelled) {
console.log(`[${reqId}] - 流式传输开始 (vNEXT: Marker priority, silence end, no JSON handling)...`); // TODO: Update version
let lastRawText = "";
let lastSentResponseContent = ""; // Tracks content *after* the marker that has been SENT
let responseStarted = false; // Tracks if <<<START_RESPONSE>>> has been seen
const startTime = Date.now();
let spinnerHasDisappeared = false;
let lastTextChangeTimestamp = Date.now();
const startMarker = '<<<START_RESPONSE>>>';
let streamFinishedNaturally = false;
while (Date.now() - startTime < RESPONSE_COMPLETION_TIMEOUT && !streamFinishedNaturally) {
// --- 添加检查:请求是否已取消 ---
const cancelled = isRequestCancelled(); // 调用检查函数
// 添加日志记录检查结果
// console.log(`[${reqId}] (Streaming Loop Check) isRequestCancelled() returned: ${cancelled}`); // 可选:过于频繁,暂时注释掉
if (cancelled) {
console.log(`[${reqId}] (Streaming) 检测到请求已取消 (isRequestCancelled() is true),停止处理。`); // 修改日志
clearTimeout(operationTimer); // 确保定时器清除
if (!res.writableEnded) res.end(); // 确保响应结束
return; // 退出函数
}
// --- 结束检查 ---
const loopStartTime = Date.now();
// 1. Get current raw text
const currentRawText = await getRawTextContent(responseElement, lastRawText, reqId);
if (currentRawText !== lastRawText) {
lastTextChangeTimestamp = Date.now();
let potentialNewDelta = "";
let currentContentAfterMarker = "";
// 2. Marker Check & Delta Calculation
const markerIndex = currentRawText.indexOf(startMarker);
if (markerIndex !== -1) {
if (!responseStarted) {
console.log(`[${reqId}] (流式 Simple) 检测到 ${startMarker},开始传输...`);
responseStarted = true;
}
// Content after marker in the current raw text
currentContentAfterMarker = currentRawText.substring(markerIndex + startMarker.length);
// Calculate new content since last *sent* content
potentialNewDelta = currentContentAfterMarker.substring(lastSentResponseContent.length);
} else if(responseStarted) {
// If marker was seen before, but now disappears (e.g., AI cleared output?), treat as no new delta.
potentialNewDelta = "";
console.warn(`[${reqId}] Marker disappeared after being seen. Raw: ${currentRawText.substring(0,100)}`);
}
// 3. Send Delta if found
if (potentialNewDelta) {
// console.log(`[${reqId}] (Send Stream Simple) Sending Delta (len: ${potentialNewDelta.length})`);
sendStreamChunk(res, potentialNewDelta, reqId);
lastSentResponseContent += potentialNewDelta; // Update tracking
}
// Update last raw text
lastRawText = currentRawText;
} // End if(currentRawText !== lastRawText)
// 4. Check Spinner status
if (!spinnerHasDisappeared) {
try {
await expect(loadingSpinner).toBeHidden({ timeout: 50 });
spinnerHasDisappeared = true;
lastTextChangeTimestamp = Date.now(); // Reset silence timer when spinner disappears
console.log(`[${reqId}] Spinner 已消失,进入静默期检测...`);
} catch (e) { /* Spinner still visible */ }
}
// 5. Silence Check (Standard)
const isSilent = spinnerHasDisappeared && (Date.now() - lastTextChangeTimestamp > SILENCE_TIMEOUT_MS);
if (isSilent) {
console.log(`[${reqId}] Silence detected. Finishing stream.`);
streamFinishedNaturally = true;
break; // Exit loop
}
// 6. Control polling interval
const loopEndTime = Date.now();
const loopDuration = loopEndTime - loopStartTime;
const waitTime = Math.max(0, POLLING_INTERVAL_STREAM - loopDuration);
await page.waitForTimeout(waitTime);
} // --- End main loop ---
// --- Cleanup and End --- (如果循环是因取消而退出,下面的代码不会执行)
clearTimeout(operationTimer); // Clear the specific timer for THIS request
if (!streamFinishedNaturally && Date.now() - startTime >= RESPONSE_COMPLETION_TIMEOUT) {
// Timeout case
console.warn(`[${reqId}] - 流式传输(Simple模式)因总超时 (${RESPONSE_COMPLETION_TIMEOUT / 1000}s) 结束。`);
await saveErrorSnapshot(`streaming_simple_timeout_${reqId}`);
if (!res.writableEnded) {
sendStreamError(res, "Stream processing timed out on server (Simple mode).", reqId);
}
} else if (streamFinishedNaturally && !res.writableEnded) {
// Natural end (Silence detected)
// --- Final Sync (Simple Mode) ---
// Check one last time for any content received after the last delta was sent but before silence was declared.
console.log(`[${reqId}] (Simple Stream) Loop ended naturally, performing final sync check...`);
const finalRawText = await getRawTextContent(responseElement, lastRawText, reqId);
console.log(`[${reqId}] (Simple Stream) Performing final marker check and delta calculation...`);
try {
let finalExtractedContent = ""; // Content after marker
const finalMarkerIndex = finalRawText.indexOf(startMarker);
if (finalMarkerIndex !== -1) {
finalExtractedContent = finalRawText.substring(finalMarkerIndex + startMarker.length);
}
const finalDelta = finalExtractedContent.substring(lastSentResponseContent.length);
if (finalDelta){
console.log(`[${reqId}] (Final Sync Simple) Sending final delta (len: ${finalDelta.length})`);
sendStreamChunk(res, finalDelta, reqId);
} else {
console.log(`[${reqId}] (Final Sync Simple) No final delta to send based on lastSent comparison.`);
}
} catch (e) { console.warn(`[${reqId}] (Simple Stream) Final sync error during marker/delta calc: ${e.message}`); }
// --- End Final Sync ---
res.write('data: [DONE]\n\n');
res.end();
console.log(`[${reqId}] ✅ 流式(Simple模式)响应 [DONE] 已发送。`);
} else if (res.writableEnded) {
console.log(`[${reqId}] 流(Simple模式)已提前结束 (writableEnded=true),不再发送 [DONE]。`);
} else {
console.log(`[${reqId}] 流(Simple模式)结束时状态异常 (finishedNaturally=${streamFinishedNaturally}, writableEnded=${res.writableEnded}),不再发送 [DONE]。`);
}
}
// --- 新增:处理非流式响应 --- vNEXT: Restore JSON Parsing
async function handleNonStreamingResponse(res, page, locators, operationTimer, reqId, isRequestCancelled) {
console.log(`[${reqId}] - 等待 AI 处理完成 (检查 Spinner 消失 + 输入框空 + 按钮禁用)...`);
let processComplete = false;
const nonStreamStartTime = Date.now();
let finalStateCheckInitiated = false;
const { inputField, submitButton, loadingSpinner } = locators;
// Completion check logic
while (!processComplete && Date.now() - nonStreamStartTime < RESPONSE_COMPLETION_TIMEOUT) {
// --- 添加检查:请求是否已取消 ---
if (isRequestCancelled()) {
console.log(`[${reqId}] (Non-Streaming) 检测到请求已取消,停止等待完成状态。`);
clearTimeout(operationTimer); // 确保定时器清除
if (!res.headersSent) {
// 如果头还没发送,可以发送一个取消错误
res.status(499).json({ error: { message: `[${reqId}] Client closed request`, type: 'client_error' } });
} else if (!res.writableEnded) {
res.end(); // 否则只结束响应
}
return; // 退出函数
}
// --- 结束检查 ---
let isSpinnerHidden = false;
let isInputEmpty = false;
let isButtonDisabled = false;
try {
await expect(loadingSpinner).toBeHidden({ timeout: SPINNER_CHECK_TIMEOUT_MS });
isSpinnerHidden = true;
} catch { /* Spinner still visible */ }
if (isSpinnerHidden) {
try {
await expect(inputField).toHaveValue('', { timeout: FINAL_STATE_CHECK_TIMEOUT_MS });
isInputEmpty = true;
} catch { /* Input not empty */ }
if (isInputEmpty) {
try {
await expect(submitButton).toBeDisabled({ timeout: FINAL_STATE_CHECK_TIMEOUT_MS });
isButtonDisabled = true;
} catch { /* Button not disabled */ }
}
}
if (isSpinnerHidden && isInputEmpty && isButtonDisabled) {
if (!finalStateCheckInitiated) {
finalStateCheckInitiated = true;
console.log(`[${reqId}] 检测到潜在最终状态。等待 ${POST_COMPLETION_BUFFER}ms 进行确认...`); // Use constant
await page.waitForTimeout(POST_COMPLETION_BUFFER); // Wait a bit first
console.log(`[${reqId}] ${POST_COMPLETION_BUFFER}ms 等待结束,重新检查状态...`);
try {
await expect(loadingSpinner).toBeHidden({ timeout: 500 });
await expect(inputField).toHaveValue('', { timeout: 500 });
await expect(submitButton).toBeDisabled({ timeout: 500 });
console.log(`[${reqId}] 状态确认成功。开始文本静默检查...`);
// --- NEW: Text Silence Check ---
let lastCheckText = '';
let currentCheckText = '';
let textStable = false;
const silenceCheckStartTime = Date.now();
// Re-locate response element here for the check
const { responseElement: checkResponseElement } = await locateResponseElements(page, locators, reqId);
while (Date.now() - silenceCheckStartTime < SILENCE_TIMEOUT_MS * 2) { // Check for up to 2*silence duration
lastCheckText = currentCheckText;
currentCheckText = await getRawTextContent(checkResponseElement, lastCheckText, reqId);
if (currentCheckText === lastCheckText) {
// Text hasn't changed since last check in this loop
if (Date.now() - silenceCheckStartTime >= SILENCE_TIMEOUT_MS) {
// And enough time has passed
console.log(`[${reqId}] 文本内容静默 ${SILENCE_TIMEOUT_MS}ms,确认处理完成。`);
textStable = true;
break;
}
} else {
// Text changed, reset silence timer within this check
// silenceCheckStartTime = Date.now(); // Option: Reset timer on any change
console.log(`[${reqId}] (静默检查) 文本仍在变化...`);
}
await page.waitForTimeout(POLLING_INTERVAL); // Use standard poll interval for checks
}
if (textStable) {
processComplete = true; // Mark process as complete
} else {
console.warn(`[${reqId}] 警告: 文本静默检查超时,可能仍在输出。将继续尝试解析。`);
processComplete = true; // Proceed anyway after timeout, but log warning
}
// --- END NEW: Text Silence Check ---
} catch (recheckError) {
console.log(`[${reqId}] 状态在确认期间发生变化 (${recheckError.message.split('\\n')[0]})。继续轮询...`);
finalStateCheckInitiated = false;
}
}
} else {
if (finalStateCheckInitiated) {
console.log(`[${reqId}] 最终状态不再满足,重置确认标志。`);
finalStateCheckInitiated = false;
}
await page.waitForTimeout(POLLING_INTERVAL * 2); // Longer wait if not in final state check
}
} // --- End Completion check logic loop ---
// --- 添加检查:如果在循环结束后发现请求已取消 ---
if (isRequestCancelled()) {
console.log(`[${reqId}] (Non-Streaming) 请求在等待完成后被取消,不再继续处理。`);
// 定时器和响应应该已经被上面的检查处理了,这里只退出
return;
}
// --- 结束检查 ---
// Check for Page Errors BEFORE attempting to parse JSON
console.log(`[${reqId}] - 检查页面上是否存在错误提示...`);
const pageError = await detectAndExtractPageError(page, reqId);
if (pageError) {
console.error(`[${reqId}] ❌ 检测到 AI Studio 页面错误: ${pageError}`);
await saveErrorSnapshot(`page_error_detected_${reqId}`);
throw new Error(`[${reqId}] AI Studio Error: ${pageError}`);
}
if (!processComplete) {
console.warn(`[${reqId}] 警告:等待最终完成状态超时或未能稳定确认 (${(Date.now() - nonStreamStartTime) / 1000}s)。将直接尝试获取并解析JSON。`);
await saveErrorSnapshot(`nonstream_final_state_timeout_${reqId}`);
} else {
console.log(`[${reqId}] - 开始获取并解析最终 JSON...`);
}
// Get and Parse JSON
let aiResponseText = null;
const maxRetries = 3;
let attempts = 0;
while (attempts < maxRetries && aiResponseText === null) {
attempts++;
console.log(`[${reqId}] - 尝试获取原始文本并解析 JSON (第 ${attempts} 次)...`);
try {
// Re-locate response element within the retry loop for robustness
const { responseElement: currentResponseElement } = await locateResponseElements(page, locators, reqId);
const rawText = await getRawTextContent(currentResponseElement, '', reqId);
if (!rawText || rawText.trim() === '') {
console.warn(`[${reqId}] - 第 ${attempts} 次获取的原始文本为空。`);
throw new Error("Raw text content is empty.");
}
console.log(`[${reqId}] - 获取到原始文本 (长度: ${rawText.length}): \"${rawText.substring(0,100)}...\"`);
const parsedJson = tryParseJson(rawText, reqId);
if (parsedJson) {
if (typeof parsedJson.response === 'string') {
aiResponseText = parsedJson.response;
console.log(`[${reqId}] - 成功解析 JSON 并提取 'response' 字段。`);
} else {
// JSON 有效但无 response 字段
try {
aiResponseText = JSON.stringify(parsedJson);
console.log(`[${reqId}] - 警告: 未找到 'response' 字段,但解析到有效 JSON。将整个 JSON 字符串化作为回复。`);
} catch (stringifyError) {
console.error(`[${reqId}] - 错误:无法将解析出的 JSON 字符串化: ${stringifyError.message}`);
aiResponseText = null;
throw new Error("Failed to stringify the parsed JSON object.");
}
}
} else {
// JSON 解析失败
console.warn(`[${reqId}] - 第 ${attempts} 次未能解析 JSON。`);
aiResponseText = null;
if (attempts >= maxRetries) {
await saveErrorSnapshot(`json_parse_fail_final_attempt_${reqId}`);
}
throw new Error("Failed to parse JSON from raw text.");
}
break;
} catch (e) {
console.warn(`[${reqId}] - 第 ${attempts} 次获取或解析失败: ${e.message.split('\n')[0]}`);
aiResponseText = null;
if (attempts >= maxRetries) {
console.error(`[${reqId}] - 多次尝试获取并解析 JSON 失败。`);
if (!e.message?.includes('snapshot')) await saveErrorSnapshot(`get_parse_json_failed_final_${reqId}`);
aiResponseText = ""; // Fallback to empty string
} else {
await new Promise(resolve => setTimeout(resolve, 1500 + attempts * 500));
}
}
}
if (aiResponseText === null) {
console.log(`[${reqId}] - JSON 解析失败,再次检查页面错误...`);
const finalCheckError = await detectAndExtractPageError(page, reqId);
if (finalCheckError) {
console.error(`[${reqId}] ❌ 检测到 AI Studio 页面错误 (在 JSON 解析失败后): ${finalCheckError}`);
await saveErrorSnapshot(`page_error_post_json_fail_${reqId}`);
throw new Error(`[${reqId}] AI Studio Error after JSON parse failed: ${finalCheckError}`);
}
console.warn(`[${reqId}] 警告:所有尝试均未能获取并解析出有效的 JSON 回复。返回空回复。`);
aiResponseText = "";
}
// Handle potential nested JSON
let cleanedResponse = aiResponseText;
try {
// Attempt to parse the potential stringified JSON again for nested 'response' check
// Only attempt if aiResponseText is likely a stringified JSON object/array
if (aiResponseText && aiResponseText.startsWith('{') || aiResponseText.startsWith('[')) {
const outerParsed = JSON.parse(aiResponseText); // Use JSON.parse directly here
const innerParsed = tryParseJson(outerParsed.response, reqId); // Try parsing the inner 'response' field if it exists
if (innerParsed && typeof innerParsed.response === 'string') {
console.log(`[${reqId}] (非流式) 检测到嵌套 JSON,使用内层 response 内容。`);
cleanedResponse = innerParsed.response;
} else if (typeof outerParsed.response === 'string') {
// If the *outer* 'response' was already a string (not nested JSON), use it directly
console.log(`[${reqId}] (非流式) 使用外层 'response' 字段内容。`);
cleanedResponse = outerParsed.response;
}
// If neither inner nor outer 'response' fields are relevant strings, keep the stringified JSON as cleanedResponse
}
} catch (e) {
// If parsing aiResponseText fails, it means it wasn't a stringified JSON in the first place,
// or it was malformed. Keep the original aiResponseText.
// console.warn(`[${reqId}] (Info) Post-processing check: aiResponseText ('${aiResponseText.substring(0,50)}...') is not a parseable JSON or lacks 'response'. Keeping original value. Error: ${e.message}`);
cleanedResponse = aiResponseText; // Keep original if parsing fails
}
console.log(`[${reqId}] ✅ 获取到解析后的 AI 回复 (来自JSON, 长度: ${cleanedResponse?.length ?? 0}): \"${cleanedResponse?.substring(0, 100)}...\"`);
// --- 新增步骤:在非流式响应中移除标记 ---
const startMarker = '<<<START_RESPONSE>>>';
let finalContentForUser = cleanedResponse; // 默认使用清理后的响应
// Check for and remove the starting marker if present
if (finalContentForUser?.startsWith(startMarker)) {
finalContentForUser = finalContentForUser.substring(startMarker.length);
console.log(`[${reqId}] (非流式 JSON) 移除前缀 ${startMarker},最终内容长度: ${finalContentForUser.length}`);
} else if (aiResponseText !== null && aiResponseText !== "") { // 仅在获取到非空文本但无标记时警告
console.warn(`[${reqId}] (非流式 JSON) 警告: 未在 response 字段中找到预期的 ${startMarker} 前缀。内容: \"${aiResponseText.substring(0,50)}...\"`);
}
// --- 结束新增步骤 ---
// 使用移除标记后的内容构建最终响应
const responsePayload = {
id: `${CHAT_COMPLETION_ID_PREFIX}${Date.now()}-${Math.random().toString(36).substring(2, 15)}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: MODEL_NAME,
choices: [{
index: 0,
message: { role: 'assistant', content: finalContentForUser }, // Use cleaned content
finish_reason: 'stop',
}],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
console.log(`[${reqId}] ✅ 返回 JSON 响应 (来自解析后的JSON)。`);
clearTimeout(operationTimer); // Clear the specific timer for THIS request
res.json(responsePayload);
}
// --- 新增:处理 /v1/models 请求以满足 Open WebUI 验证 ---
app.get('/v1/models', (req, res) => {
const modelId = 'aistudio-proxy'; // 您计划在 Open WebUI 中使用的模型名称
// 使用简短的日志ID或时间戳
const logPrefix = `[${Date.now().toString(36).slice(-5)}]`;
console.log(`${logPrefix} --- 收到 /v1/models 请求,返回模拟模型列表 ---`);
res.json({
object: "list",
data: [
{
id: modelId, // 返回您要用的那个名字
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "openai-proxy", // 可以随便写
permission: [],
root: modelId,
parent: null
}
// 如果需要添加更多名称指向同一个代理,可以在此添加
// ,{
// id: "gemini-pro-proxy",
// object: "model",
// created: Math.floor(Date.now() / 1000),
// owned_by: "openai-proxy",
// permission: [],
// root: "gemini-pro-proxy",
// parent: null
// }
]
});
});
// --- v2.18: 新增队列处理函数 ---
async function processQueue() {
if (isProcessing || requestQueue.length === 0) {
return;
}
isProcessing = true;
// 从队列头部取出包含状态的请求项
const queueItem = requestQueue.shift();
// 解构所需变量,包括取消标记和临时处理器
const { req, res, reqId, isCancelledByClient, preliminaryCloseHandler } = queueItem;
// --- 重要:立即移除临时监听器(如果存在且未被触发移除)---
// 因为我们要么跳过处理,要么添加新的主监听器
if (preliminaryCloseHandler) {
// 使用 removeListener 以防万一它已被触发并自我移除
res.removeListener('close', preliminaryCloseHandler);
}
// --- 结束移除临时监听器 ---
// --- 新增:检查请求是否在处理前已被取消 ---
if (isCancelledByClient) {
console.log(`[${reqId}] Request was cancelled by client before processing began. Skipping.`);
// 清理可能由其他地方(如主 close 事件处理器)设置的定时器,以防万一
if (operationTimer) clearTimeout(operationTimer);
// 标记处理结束(跳过),然后处理下一个
isProcessing = false;
processQueue(); // 尝试处理下一个请求
return; // 退出当前 processQueue 调用
}
// --- 结束新增检查 ---
console.log(`\n[${reqId}] ---开始处理队列中的请求 (剩余 ${requestQueue.length} 个)---`);
let operationTimer; // 主操作定时器
// *** 修改:将 isCancelledByClient 的状态传递给处理期间的 isCancelled 标志 ***
let isCancelled = isCancelledByClient;
// 如果在开始处理时就已经被取消,添加一条日志
if (isCancelled) {
console.log(`[${reqId}] Warning: Request was cancelled very shortly before processing logic started.`);
// 虽然上面的检查理论上会处理,但这里多一层保险
}
// *** 结束修改 ***
let closeEventHandler = null; // 主 close 事件处理器引用
try {
// 1. 检查 Playwright 状态 (现在可以安全地继续,因为请求未被提前取消)
// *** 新增:如果此时 isCancelled 已经是 true,则直接跳到 finally ***
if (isCancelled) {
console.log(`[${reqId}] Skipping Playwright interaction as request is already marked cancelled.`);
throw new Error(`[${reqId}] Request pre-cancelled`); // 抛出错误以跳到 catch/finally
}
// *** 结束新增检查 ***
if (!isPlaywrightReady && !isInitializing) {
console.warn(`[${reqId}] Playwright 未就绪,尝试重新初始化...`);
await initializePlaywright();
}
if (!isPlaywrightReady || !page || page.isClosed() || !browser?.isConnected()) {
console.error(`[${reqId}] API 请求失败:Playwright 未就绪、页面关闭或连接断开。`);
let detail = 'Unknown issue.';
if (!browser?.isConnected()) detail = "Browser connection lost.";
else if (!page || page.isClosed()) detail = "Target AI Studio page is not available or closed.";
else if (!isPlaywrightReady) detail = "Playwright initialization failed or incomplete.";
console.error(`[${reqId}] Playwright 连接不可用详情: ${detail}`);
// 直接为当前请求返回错误,不需要抛出,因为要继续处理队列
if (!res.headersSent) {
res.status(503).json({
error: { message: `[${reqId}] Playwright connection is not active. ${detail} Please ensure Chrome is running correctly, the AI Studio tab is open, and potentially restart the server.`, type: 'server_error' }
});
}
throw new Error("Playwright not ready for this request."); // Throw to skip further processing in try block
}
const { messages, stream, ...otherParams } = req.body;
const isStreaming = stream === true;
// --- 修改:基于消息数量启发式判断并执行清空操作 + 验证 ---
const isLikelyNewChat = Array.isArray(messages) && (messages.length === 1 || (messages.length === 2 && messages.some(m => m.role === 'system')));
if (isLikelyNewChat && CLEAR_CHAT_BUTTON_SELECTOR && CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR) {
console.log(`[${reqId}] 检测到可能是新对话 (消息数: ${messages.length}),尝试清空聊天记录...`);
try {
const clearButton = page.locator(CLEAR_CHAT_BUTTON_SELECTOR);
console.log(`[${reqId}] - 查找并点击"Clear chat"按钮...`);
await clearButton.waitFor({ state: 'visible', timeout: 7000 });
await clearButton.click({ timeout: 5000 });
console.log(`[${reqId}] - "Clear chat"按钮已点击。`);
console.log(`[${reqId}] - 等待确认对话框及"Continue"按钮出现...`);
const confirmButton = page.locator(CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR);
await confirmButton.waitFor({ state: 'visible', timeout: 5000 });
console.log(`[${reqId}] - 点击"Continue"按钮...`);
await confirmButton.click({ timeout: 5000 });
console.log(`[${reqId}] - "Continue"按钮已点击。开始验证清空效果...`);
// --- 新增:验证清空效果 ---
const checkStartTime = Date.now();
let cleared = false;
while (Date.now() - checkStartTime < CLEAR_CHAT_VERIFY_TIMEOUT_MS) {
// 定位所有 AI 回复容器
const modelTurns = page.locator(RESPONSE_CONTAINER_SELECTOR);
const count = await modelTurns.count();
if (count === 0) {
console.log(`[${reqId}] ✅ 验证成功: 页面上未找到之前的 AI 回复元素 (耗时 ${Date.now() - checkStartTime}ms)。`);
cleared = true;
break; // 验证成功,退出循环
}
// 稍微等待后再次检查
await page.waitForTimeout(CLEAR_CHAT_VERIFY_INTERVAL_MS);
}
if (!cleared) {
// 如果超时后仍然找到 AI 回复元素
console.warn(`[${reqId}] ⚠️ 验证超时: 在 ${CLEAR_CHAT_VERIFY_TIMEOUT_MS}ms 内仍能检测到之前的 AI 回复元素。上下文可能未完全清空。`);
// 保存快照以供调试
await saveErrorSnapshot(`clear_chat_verify_fail_${reqId}`);
}
// --- 结束:验证清空效果 ---
} catch (clearChatError) {
console.warn(`[${reqId}] ⚠️ 清空聊天记录或验证时出错: ${clearChatError.message.split('\n')[0]}. 将继续执行请求,但上下文可能未被清除。`);
if (clearChatError.message.includes('selector')) {
console.warn(` (请仔细检查选择器是否仍然有效: CLEAR_CHAT_BUTTON_SELECTOR='${CLEAR_CHAT_BUTTON_SELECTOR}', CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR='${CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR}')`);
}
await saveErrorSnapshot(`clear_chat_fail_or_verify_${reqId}`);
}
} else if (isLikelyNewChat && (!CLEAR_CHAT_BUTTON_SELECTOR || !CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR)) {
console.warn(`[${reqId}] 检测到可能是新对话,但未完整配置清空聊天相关的选择器常量,无法自动重置上下文。`);
}
// --- 结束:启发式新对话处理 ---
console.log(`[${reqId}] 请求模式: ${isStreaming ? '流式 (SSE)' : '非流式 (JSON)'}`);
// 2. 设置此请求的总操作超时
operationTimer = setTimeout(async () => {
await saveErrorSnapshot(`operation_timeout_${reqId}`);
console.error(`[${reqId}] Operation timed out after ${RESPONSE_COMPLETION_TIMEOUT / 1000} seconds.`);
if (!res.headersSent) {
res.status(504).json({ error: { message: `[${reqId}] Operation timed out`, type: 'timeout_error' } });
} else if (isStreaming && !res.writableEnded) {
sendStreamError(res, "Operation timed out on server.", reqId);
}
// Note: Timeout error now managed within processQueue, allowing next item to proceed
}, RESPONSE_COMPLETION_TIMEOUT);
// 3. 验证请求 (使用更新后的函数)
// Pass reqId to validation for better logging context
const validationMessages = messages.map(m => ({ ...m, reqId })); // Add reqId temporarily
const { userPrompt, systemPrompt: extractedSystemPrompt } = validateChatRequest(validationMessages);
// Combine system prompts if provided in multiple ways
const systemPrompt = extractedSystemPrompt || otherParams?.system_prompt;
// --- Logging (Now userPrompt is guaranteed to be a string) ---
const userPromptPreview = userPrompt.substring(0, 80);
console.log(`[${reqId}] 处理后的 User Prompt (用于提交, start): \"${userPromptPreview}...\" (Total length: ${userPrompt.length})`);
if (systemPrompt) {
// systemPrompt from validateChatRequest is also guaranteed string or null
const systemPromptPreview = systemPrompt.substring(0, 80);
console.log(`[${reqId}] 处理后的 System Prompt (用于提交, start): \"${systemPromptPreview}...\"`);
} else {
console.log(`[${reqId}] 无 System Prompt。`);
}
if (Object.keys(otherParams).length > 0) {
console.log(`[${reqId}] 记录到的额外参数: ${JSON.stringify(otherParams)}`);
}
// --- End Logging ---
// 4. 准备 Prompt (使用处理后的 userPrompt 和 systemPrompt)
let prompt;
if (isStreaming) {
prompt = prepareAIStudioPromptStream(userPrompt, systemPrompt); // Assumes prepare functions handle null systemPrompt
console.log(`[${reqId}] 构建的流式 Prompt (Raw): \"${prompt.substring(0, 200)}...\"`);
} else {
prompt = prepareAIStudioPrompt(userPrompt, systemPrompt); // Assumes prepare functions handle null systemPrompt
console.log(`[${reqId}] 构建的非流式 Prompt (JSON): \"${prompt.substring(0, 200)}...\"`);
}
// 5. 与页面交互并提交
const locators = await interactAndSubmitPrompt(page, prompt, reqId);
// --- 添加 'close' 事件监听器 ---
closeEventHandler = async () => {
console.log(`[${reqId}] 'close' event handler triggered.`); // <-- 新增日志
if (isCancelled) {
console.log(`[${reqId}] 'close' event handler: Already cancelled, doing nothing.`); // <-- 新增日志
return; // 防止重复执行
}
isCancelled = true;
console.log(`[${reqId}] Client disconnected ('close' event). Attempting to stop generation by clicking the run/stop button.`);
clearTimeout(operationTimer); // 清除主超时定时器
// 尝试点击运行/停止按钮 (因为它是同一个按钮)
try {
// 确保 locators, submitButton, inputField 存在
if (!locators || !locators.submitButton || !locators.inputField) {
console.warn(`[${reqId}] closeEventHandler: Cannot attempt to click stop button: locators (button or input) not available.`); // <-- 修改日志
return;
}
// 检查按钮是否仍然可用 (增加超时)
console.log(`[${reqId}] closeEventHandler: Checking button state (timeout: 2000ms)...`); // <-- 修改日志
const isEnabled = await locators.submitButton.isEnabled({ timeout: 2000 }); // <-- 增加超时
console.log(`[${reqId}] closeEventHandler: Button isEnabled result: ${isEnabled}`); // <-- 新增日志
if (isEnabled) {
// *** 新增:检查输入框是否为空 (增加超时) ***
console.log(`[${reqId}] closeEventHandler: Button enabled, checking input value (timeout: 2000ms)...`); // <-- 修改日志
const inputValue = await locators.inputField.inputValue({ timeout: 2000 }); // <-- 增加超时
console.log(`[${reqId}] closeEventHandler: Input value: "${inputValue}"`); // <-- 新增日志
if (inputValue === '') {
console.log(`[${reqId}] closeEventHandler: Run/Stop button is enabled AND input is empty. Clicking it to stop generation...`); // <-- 修改日志
// 使用 click({ force: true }) 可能更可靠
await locators.submitButton.click({ timeout: 5000, force: true });
console.log(`[${reqId}] closeEventHandler: Run/Stop button click attempted.`); // <-- 修改日志
} else {
console.log(`[${reqId}] closeEventHandler: Run/Stop button is enabled BUT input is NOT empty. Assuming user typed new input, not clicking stop.`); // <-- 修改日志
}
// *** 结束新增检查 ***
} else {
console.log(`[${reqId}] closeEventHandler: Run/Stop button is already disabled (generation likely finished or close event was late). No click needed.`); // <-- 修改日志
}
} catch (clickError) {
// 捕获检查或点击过程中的错误
console.warn(`[${reqId}] closeEventHandler: Error during stop button check/click: ${clickError.message.split('\n')[0]}`); // <-- 修改日志
// 添加更详细日志并尝试保存快照
console.error(`[${reqId}] closeEventHandler: Detailed error during check/click:`, clickError);
await saveErrorSnapshot(`close_handler_click_error_${reqId}`);
}
};
res.on('close', closeEventHandler);
// --- 结束添加监听器 ---
// 6. 定位响应元素
const { responseElement } = await locateResponseElements(page, locators, reqId);
// 7. 处理响应 (流式或非流式)
console.log(`[${reqId}] 处理 AI 回复...`);
if (isStreaming) {
// --- 设置流式响应头 ---
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
// 调用流式处理函数
// 传递检查函数 () => isCancelled
await handleStreamingResponse(res, responseElement, page, locators, operationTimer, reqId, () => isCancelled);
} else {
// 调用非流式处理函数
// 传递检查函数 () => isCancelled
await handleNonStreamingResponse(res, page, locators, operationTimer, reqId, () => isCancelled);
}
// --- 修改:仅在未被取消时记录成功 ---
if (!isCancelled) {
console.log(`[${reqId}] ✅ 请求处理成功完成。`);
clearTimeout(operationTimer); // 只有真正成功完成才清除计时器
} else {
console.log(`[${reqId}] ℹ️ 请求处理因客户端断开连接而被中止。`);
// operationTimer 应该已经在 closeEventHandler 中被清除了
}
// --- 结束修改 ---
} catch (error) {
// 确保在任何错误情况下都清除此请求的定时器 (如果 close 事件未触发)
if (!isCancelled) {
clearTimeout(operationTimer);
}
console.error(`[${reqId}] ❌ 处理队列中的请求时出错: ${error.message}\n${error.stack}`);
// --- 恢复:添加条件判断是否需要保存快照 ---
const shouldSaveSnapshot = !(
error.message?.includes('Invalid request') || // 跳过请求验证错误
error.message?.includes('Playwright not ready') // 跳过 Playwright 初始化/连接错误
// 未来可以根据需要添加其他不需要快照的错误类型
);
if (shouldSaveSnapshot && !error.message?.includes('snapshot') && !error.stack?.includes('saveErrorSnapshot')) {
// 避免在保存快照本身失败或已知Playwright问题时再次尝试保存
await saveErrorSnapshot(`general_api_error_${reqId}`);
} else if (!shouldSaveSnapshot) {
console.log(`[${reqId}] (Info) Skipping error snapshot for this type of error: ${error.message.split('\n')[0]}`);
}
// --- 结束恢复 ---
// 发送错误响应,如果尚未发送
if (!res.headersSent) {
let statusCode = 500;
let errorType = 'server_error';
if (error.message?.includes('timed out') || error.message?.includes('timeout')) {
statusCode = 504; // Gateway Timeout
errorType = 'timeout_error';
} else if (error.message?.includes('AI Studio Error')) {
statusCode = 502; // Bad Gateway (error from upstream)
errorType = 'upstream_error';
} else if (error.message?.includes('Invalid request')) {
statusCode = 400; // Bad Request
errorType = 'invalid_request_error';
} else if (error.message?.includes('Playwright not ready')) { // Specific handling for PW not ready here
statusCode = 503;
errorType = 'server_error';
}
res.status(statusCode).json({ error: { message: `[${reqId}] ${error.message}`, type: errorType } });
} else if (req.body.stream === true && !res.writableEnded) { // Check if it WAS a streaming request
// 如果是流式响应且头部已发送,则发送流式错误
sendStreamError(res, error.message, reqId);
}
else if (!res.writableEnded) {
// 对于非流式但已发送部分内容的罕见情况,或流式错误发送后的清理
res.end();
}
} finally {
// --- 添加清理逻辑 ---
if (closeEventHandler) {
res.removeListener('close', closeEventHandler);
// console.log(`[${reqId}] Removed 'close' event listener.`); // Optional debug log
}
// --- 结束清理逻辑 ---
isProcessing = false; // 标记处理已结束
console.log(`[${reqId}] ---结束处理队列中的请求---`);
// 触发处理下一个请求(如果队列中有)
processQueue();
}
}
// --- API 端点 (v2.18: 使用队列) ---
app.post('/v1/chat/completions', async (req, res) => {
const reqId = Math.random().toString(36).substring(2, 9); // 生成简短的请求 ID
console.log(`\n[${reqId}] === 收到 /v1/chat/completions 请求 ===`);
// 创建请求队列项,并添加取消标记和临时监听器引用
const queueItem = {
req,
res,
reqId,
isCancelledByClient: false,
preliminaryCloseHandler: null
};
// --- 添加临时的 'close' 事件监听器 ---
queueItem.preliminaryCloseHandler = () => {
if (!queueItem.isCancelledByClient) { // 避免重复标记
console.log(`[${reqId}] Client disconnected before processing started.`);
queueItem.isCancelledByClient = true;
// 从 res 对象移除自身,防止后续冲突
res.removeListener('close', queueItem.preliminaryCloseHandler);
}
};
res.once('close', queueItem.preliminaryCloseHandler); // 使用 once 确保最多触发一次
// --- 结束添加临时监听器 ---
// 将请求加入队列
requestQueue.push(queueItem); // <-- 推入包含标记的对象
console.log(`[${reqId}] 请求已加入队列 (当前队列长度: ${requestQueue.length})`);
// 尝试处理队列 (如果当前未在处理)
if (!isProcessing) {
console.log(`[Queue] 触发队列处理 (收到新请求 ${reqId} 时处于空闲状态)`);
processQueue();
} else {
console.log(`[Queue] 当前正在处理其他请求,请求 ${reqId} 已排队等待。`);
}
});
// --- Helper: 获取当前文本 (v2.14 - 获取原始文本) -> vNEXT: Try innerText
async function getRawTextContent(responseElement, previousText, reqId) {
try {
await responseElement.waitFor({ state: 'attached', timeout: 1500 });
const preElement = responseElement.locator('pre').last();
let rawText = null;
try {
await preElement.waitFor({ state: 'attached', timeout: 500 });
// 尝试使用 innerText 获取渲染后的文本,可能更好地保留换行
rawText = await preElement.innerText({ timeout: 1000 });
} catch {
// 如果 pre 元素获取失败,回退到 responseElement 的 innerText
console.warn(`[${reqId}] (Warn) Failed to get innerText from <pre>, falling back to parent.`);
rawText = await responseElement.innerText({ timeout: 2000 });
}
// 移除 trim(),直接返回获取到的文本
return rawText !== null ? rawText : previousText;
} catch (e) {
console.warn(`[${reqId}] (Warn) getRawTextContent (innerText) failed: ${e.message.split('\n')[0]}. Returning previous.`);
return previousText;
}
}
// --- Helper: 发送流式块 ---
function sendStreamChunk(res, delta, reqId) {
if (delta && !res.writableEnded) {
const chunk = {
id: `${CHAT_COMPLETION_ID_PREFIX}${Date.now()}-${Math.random().toString(36).substring(2, 15)}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: MODEL_NAME,
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }]
};
try {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
} catch (writeError) {
console.error(`[${reqId}] Error writing stream chunk:`, writeError.message);
if (!res.writableEnded) res.end(); // End stream on write error
}
}
}
// --- Helper: 发送流式错误块 ---
function sendStreamError(res, errorMessage, reqId) {
if (!res.writableEnded) {
const errorPayload = { error: { message: `[${reqId}] Server error during streaming: ${errorMessage}`, type: 'server_error' } };
try {
// Avoid writing multiple DONE messages if error occurs after normal DONE
if (!res.writableEnded) res.write(`data: ${JSON.stringify(errorPayload)}\n\n`);
if (!res.writableEnded) res.write('data: [DONE]\n\n');
} catch (e) {
console.error(`[${reqId}] Error writing stream error chunk:`, e.message);
} finally {
if (!res.writableEnded) res.end(); // Ensure stream ends
}
}
}
// --- Helper: 保存错误快照 ---
async function saveErrorSnapshot(errorName = 'error') {
// Extract reqId if present in the name
const nameParts = errorName.split('_');
const reqId = nameParts[nameParts.length - 1].length === 7 ? nameParts.pop() : null; // Simple check for likely reqId
const baseErrorName = nameParts.join('_');
const logPrefix = reqId ? `[${reqId}]` : '[No ReqId]';
if (!browser?.isConnected() || !page || page.isClosed()) {
console.log(`${logPrefix} 无法保存错误快照 (${baseErrorName}),浏览器或页面不可用。`);
return;
}
console.log(`${logPrefix} 尝试保存错误快照 (${baseErrorName})...`);
const timestamp = Date.now();
const errorDir = path.join(__dirname, 'errors');
try {
if (!fs.existsSync(errorDir)) fs.mkdirSync(errorDir, { recursive: true });
// Include reqId in filename if available
const filenameSuffix = reqId ? `${reqId}_${timestamp}` : `${timestamp}`;
const screenshotPath = path.join(errorDir, `${baseErrorName}_screenshot_${filenameSuffix}.png`);
const htmlPath = path.join(errorDir, `${baseErrorName}_page_${filenameSuffix}.html`);
try {
await page.screenshot({ path: screenshotPath, fullPage: true, timeout: 15000 });
console.log(`${logPrefix} 错误快照已保存到: ${screenshotPath}`);
} catch (screenshotError) {
console.error(`${logPrefix} 保存屏幕截图失败 (${baseErrorName}): ${screenshotError.message}`);
}
try {
const content = await page.content({timeout: 15000});
fs.writeFileSync(htmlPath, content);
console.log(`${logPrefix} 错误页面HTML已保存到: ${htmlPath}`);
} catch (htmlError) {
console.error(`${logPrefix} 保存页面HTML失败 (${baseErrorName}): ${htmlError.message}`);
}
} catch (dirError) {
console.error(`${logPrefix} 创建错误目录或保存快照时出错: ${dirError.message}`);
}
}
// v2.14: Helper to safely parse JSON, attempting to find the outermost object/array
function tryParseJson(text, reqId) {
if (!text || typeof text !== 'string') return null;
text = text.trim();
let startIndex = -1;
let endIndex = -1;
const firstBrace = text.indexOf('{');
const firstBracket = text.indexOf('[');
if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
startIndex = firstBrace;
endIndex = text.lastIndexOf('}');
} else if (firstBracket !== -1) {
startIndex = firstBracket;
endIndex = text.lastIndexOf(']');
}
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
// console.warn(`[${reqId}] (Warn) Could not find valid start/end braces/brackets for JSON parsing.`);
return null;
}
const jsonText = text.substring(startIndex, endIndex + 1);
try {
return JSON.parse(jsonText);
} catch (e) {
// console.warn(`[${reqId}] (Warn) JSON parse failed for extracted text: ${e.message}`);
return null;
}
}
// --- Helper: 检测并提取页面错误提示 ---
async function detectAndExtractPageError(page, reqId) {
const errorToastLocator = page.locator(ERROR_TOAST_SELECTOR).last();
try {
const isVisible = await errorToastLocator.isVisible({ timeout: 1000 });
if (isVisible) {
console.log(`[${reqId}] 检测到错误 Toast 元素。`);
const messageLocator = errorToastLocator.locator('span.content-text');
const errorMessage = await messageLocator.textContent({ timeout: 500 });
return errorMessage || "Detected error toast, but couldn't extract specific message.";
} else {
return null;
}
} catch (e) {
// console.warn(`[${reqId}] (Warn) Checking for error toast failed or timed out: ${e.message.split('\n')[0]}`);
return null;
}
}
// --- Helper: 快速检查结束条件 ---
async function checkEndConditionQuickly(page, spinnerLocator, inputLocator, buttonLocator, timeoutMs = 250, reqId) {
try {
const results = await Promise.allSettled([
expect(spinnerLocator).toBeHidden({ timeout: timeoutMs }),
expect(inputLocator).toHaveValue('', { timeout: timeoutMs }),
expect(buttonLocator).toBeDisabled({ timeout: timeoutMs })
]);
const allMet = results.every(result => result.status === 'fulfilled');
// console.log(`[${reqId}] (Quick Check) All met: ${allMet}`);
return allMet;
} catch (error) {
// console.warn(`[${reqId}] (Quick Check) Error during checkEndConditionQuickly: ${error.message}`);
return false;
}
}
// --- 启动服务器 ---
let serverInstance = null;
(async () => {
await initializePlaywright();
serverInstance = app.listen(SERVER_PORT, () => {
console.log("\n=============================================================");
// v2.18: Updated version marker
console.log(" 🚀 AI Studio Proxy Server (v2.18 - Queue) 🚀");
console.log("=============================================================");
console.log(`🔗 监听地址: http://localhost:${SERVER_PORT}`);
console.log(` - Web UI (测试): http://localhost:${SERVER_PORT}/`);
console.log(` - API 端点: http://localhost:${SERVER_PORT}/v1/chat/completions`);
console.log(` - 模型接口: http://localhost:${SERVER_PORT}/v1/models`);
console.log(` - 健康检查: http://localhost:${SERVER_PORT}/health`);
console.log("-------------------------------------------------------------");
if (isPlaywrightReady) {
console.log('✅ Playwright 连接成功,服务已准备就绪!');
} else {
console.warn('⚠️ Playwright 未就绪。请检查下方日志并确保 Chrome/AI Studio 正常运行。');
console.warn(' API 请求将失败,直到 Playwright 连接成功。');
}
console.log("-------------------------------------------------------------");
console.log(`⏳ 等待 Chrome 实例 (调试端口: ${CHROME_DEBUGGING_PORT})...`);
console.log(" 请确保已运行 auto_connect_aistudio.js 脚本,");
console.log(" 并且 Google AI Studio 页面已在浏览器中打开。 ");
console.log("=============================================================\n");
});
serverInstance.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
console.error("\n=============================================================");
console.error(`❌ 致命错误:端口 ${SERVER_PORT} 已被占用!`);
console.error(" 请关闭占用该端口的其他程序,或在 server.cjs 中修改 SERVER_PORT。 ");
console.error("=============================================================\n");
} else {
console.error('❌ 服务器启动失败:', error);
}
process.exit(1);
});
})();
// --- 优雅关闭处理 ---
let isShuttingDown = false;
async function shutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
console.log(`当前队列中有 ${requestQueue.length} 个请求等待处理。将不再接受新请求。`);
// Option: Wait for the current request to finish?
// For now, we'll just close the server, potentially interrupting the current request.
if (serverInstance) {
serverInstance.close(async (err) => {
if (err) console.error("关闭 HTTP 服务器时出错:", err);
else console.log("HTTP 服务器已关闭。");
console.log("Playwright connectOverCDP 将自动断开。");
// No need to explicitly disconnect browser in connectOverCDP mode
console.log('服务器优雅关闭完成。');
process.exit(err ? 1 : 0);
});
// Force exit after timeout
setTimeout(() => {
console.error("优雅关闭超时,强制退出进程。");
process.exit(1);
}, 10000); // 10 seconds timeout
} else {
console.log("服务器实例未找到,直接退出。");
process.exit(0);
}
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));