import asyncio import random import time from typing import AsyncGenerator, List, Optional, Tuple from loguru import logger from playwright.async_api import Locator, Page, TimeoutError as PWTimeout from .config import settings from .models import ChatMessage FIND_INPUT_JS = """ () => { const selectors = [ '.tiptap.ProseMirror[contenteditable="true"]', 'div[contenteditable="true"]', 'textarea[placeholder*="发"]', 'textarea[placeholder*="输入"]', 'textarea[placeholder*="问"]', 'textarea', '[role="textbox"]', ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el && el.offsetParent !== null) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) return sel; } } return null; } """ FIND_SEND_BTN_JS = """ () => { const selectors = [ 'button[aria-label*="发送"]', 'button[aria-label*="Send"]', 'button[type="submit"]', 'button[class*="send"]', 'button[class*="submit"]', '[data-testid*="send"]', 'button[class*="Send"]', ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el && el.offsetParent !== null) return sel; } return null; } """ GET_RESPONSE_TEXT_JS = """ () => { const messageList = document.querySelector('[class*="message-list"]'); if (messageList) { const msgs = Array.from(messageList.children).filter(el => { const cls = el.className || ''; return el.offsetParent !== null && !cls.includes('action-bar') && !cls.includes('suggest-') && el.innerText.trim(); }); if (msgs.length > 0) { return msgs[msgs.length - 1].innerText.trim(); } } return null; } """ IS_GENERATING_JS = """ () => { const stopSelectors = [ '[class*="stop-generate"]', '[class*="stop_generate"]', '[aria-label*="停止"]', '[aria-label*="Stop"]', '[data-testid*="stop"]', 'button[disabled][class*="send"]', ]; for (const sel of stopSelectors) { const el = document.querySelector(sel); if (el && el.offsetParent !== null) return true; } const spinnerSelectors = [ '[class*="typing-indicator"]', '[class*="thinking"]', '[class*="loading-dot"]', 'svg[class*="loading"]', '[class*="generating"]', ]; for (const sel of spinnerSelectors) { const el = document.querySelector(sel); if (el && el.offsetParent !== null) return true; } return false; } """ NEW_CHAT_BTN_JS = """ () => { const selectors = [ 'button[aria-label*="新对话"]', 'button[aria-label*="New"]', 'a[aria-label*="新对话"]', 'a[aria-label*="New"]', '[class*="new-chat"]', '[class*="new_chat"]', '[data-testid*="new-chat"]', '[data-testid*="new_chat"]', 'a[href*="/chat"][class*="new"]', ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el && el.offsetParent !== null) return sel; } return null; } """ INSPECT_DOM_JS = """ () => { const result = { url: window.location.href, title: document.title, textareas: [], contentEditables: [], buttons: [], messageContainers: [], }; document.querySelectorAll('textarea').forEach(el => { result.textareas.push({ placeholder: el.placeholder, className: el.className.slice(0, 80), visible: el.offsetParent !== null, }); }); document.querySelectorAll('[contenteditable="true"]').forEach(el => { result.contentEditables.push({ className: el.className.slice(0, 80), visible: el.offsetParent !== null, }); }); document.querySelectorAll('button').forEach(el => { if (el.offsetParent !== null) { result.buttons.push({ text: el.innerText.slice(0, 40), ariaLabel: el.getAttribute('aria-label'), className: el.className.slice(0, 80), }); } }); const msgPatterns = ['message', 'chat-item', 'conversation', 'receive', 'bot', 'ai-msg']; msgPatterns.forEach(pattern => { document.querySelectorAll(`[class*="${pattern}"]`).forEach(el => { if (el.offsetParent !== null && result.messageContainers.length < 20) { result.messageContainers.push({ pattern: pattern, className: el.className.slice(0, 80), text: el.innerText.slice(0, 100), childCount: el.children.length, }); } }); }); return result; } """ class DoubaoChat: def __init__(self, page: Page): self.page = page async def start_new_chat(self) -> bool: """Try to start a new chat conversation.""" try: selector = await self.page.evaluate(NEW_CHAT_BTN_JS) if selector: btn = self.page.locator(selector).first await btn.click() await self.page.wait_for_timeout(2000) logger.info("Started new chat via button") return True except Exception as e: logger.debug(f"New chat button not found: {e}") try: await self.page.goto(settings.doubao_url, wait_until="domcontentloaded") await self.page.wait_for_timeout(3000) logger.info("Navigated to fresh chat URL") return True except Exception as e: logger.warning(f"Failed to navigate to chat URL: {e}") return False async def _find_input(self) -> Optional[Tuple[Locator, str]]: """Find the input element, returns (locator, selector).""" selector = await self.page.evaluate(FIND_INPUT_JS) if not selector: return None loc = self.page.locator(selector).first try: await loc.wait_for(state="visible", timeout=5000) return loc, selector except PWTimeout: return None async def _find_send_button(self) -> Optional[Locator]: """Find the send button.""" selector = await self.page.evaluate(FIND_SEND_BTN_JS) if not selector: return None loc = self.page.locator(selector).first try: await loc.wait_for(state="visible", timeout=3000) return loc except PWTimeout: return None async def _type_message(self, input_loc: Locator, text: str): """Type a message with human-like delays.""" await input_loc.click() await asyncio.sleep(0.2) if len(text) > 1000: await self.page.keyboard.insert_text(text) await asyncio.sleep(0.3) else: delay = random.randint(settings.typing_delay_min, settings.typing_delay_max) await self.page.keyboard.type(text, delay=delay) await asyncio.sleep(settings.inter_message_delay) async def _send_via_enter(self, input_loc: Locator) -> bool: """Try sending by pressing Enter.""" await input_loc.press("Enter") await asyncio.sleep(0.5) return True async def _send_via_button(self) -> bool: """Try sending by clicking the send button.""" btn = await self._find_send_button() if btn: await btn.click() await asyncio.sleep(0.5) return True return False async def _is_input_empty(self, input_loc: Locator, selector: str) -> bool: """Check if the input element is empty (message was sent).""" try: if "textarea" in selector: val = await input_loc.input_value() else: val = await input_loc.inner_text() return not val.strip() except Exception: return True async def send_message(self, text: str) -> str: """Send a message to the Doubao chat. Returns the previous response text (for change detection). """ result = await self._find_input() if not result: logger.error("Input element not found on page") raise RuntimeError("无法找到输入框,请通过VNC检查页面状态") input_loc, selector = result old_response = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or "" await self._type_message(input_loc, text) sent = await self._send_via_enter(input_loc) if sent: await asyncio.sleep(0.8) if not await self._is_input_empty(input_loc, selector): logger.info("Enter didn't send, trying send button") sent = await self._send_via_button() if not sent: logger.error("Failed to send message") raise RuntimeError("消息发送失败,请通过VNC检查页面状态") return old_response async def _wait_for_response_start( self, old_text: str, timeout: int = 30 ) -> Optional[str]: """Wait for the response to start appearing. Uses two-phase detection: Phase 1: wait for user message to appear (text changes from old_text) Phase 2: wait for AI response to start (text changes from baseline) Returns baseline text (after user message, before AI response), or None on timeout. """ start = time.time() phase = 1 baseline = None while time.time() - start < timeout: current = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or "" if phase == 1: if current and current != old_text: baseline = current phase = 2 await asyncio.sleep(0.5) elif phase == 2: if current != baseline: logger.debug("Response started") return baseline await asyncio.sleep(0.3) if baseline is not None: logger.warning("Response may have started during baseline capture") return baseline logger.warning("Response did not start within timeout") return None async def stream_response(self, old_text: str = "") -> AsyncGenerator[str, None]: """Yield response text chunks as they appear. Args: old_text: Previous response text from before the message was sent. Used to detect when the new response starts. """ baseline = await self._wait_for_response_start(old_text) if baseline is None: return last_text = baseline stable_count = 0 STABLE_THRESHOLD = 6 POLL_INTERVAL = 0.15 start_time = time.time() while True: current_text = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or "" if current_text and len(current_text) > len(last_text): new_chunk = current_text[len(last_text):] last_text = current_text stable_count = 0 if new_chunk.strip(): yield new_chunk elif current_text and current_text == last_text: stable_count += 1 if stable_count >= STABLE_THRESHOLD: is_gen = await self.page.evaluate(IS_GENERATING_JS) if not is_gen: logger.debug("Response complete (stable + not generating)") break elapsed = time.time() - start_time if elapsed > settings.response_timeout: logger.warning(f"Response timeout after {elapsed:.1f}s") break await asyncio.sleep(POLL_INTERVAL) async def get_response_full(self, old_text: str = "") -> str: """Get the complete response text (non-streaming).""" chunks = [] async for chunk in self.stream_response(old_text): chunks.append(chunk) return "".join(chunks) async def inspect(self) -> dict: """Return DOM structure info for debugging.""" return await self.page.evaluate(INSPECT_DOM_JS) @staticmethod def messages_to_prompt(messages: List[ChatMessage]) -> str: """Convert OpenAI messages array to a single text prompt.""" if len(messages) == 1 and messages[0].role == "user": return messages[0].content parts: List[str] = [] for msg in messages: if msg.role == "system": parts.append(f"【系统设定】\n{msg.content}") elif msg.role == "user": parts.append(f"【用户】\n{msg.content}") elif msg.role == "assistant": parts.append(f"【AI回复】\n{msg.content}") return "\n\n---\n\n".join(parts)