#!/usr/bin/env python3 """ GitLab Duo Proxy — Browser Chat Driver ======================================= 用 Playwright 驱动真实的 GitLab Duo Chat UI 发送消息,绕过"未知发送 mutation"问题。 工作原理: 1. 用账号 Cookie 打开 Duo Chat 页面 (https://gitlab.com/-/duo_chat) 2. 监听 /api/graphql 响应,拦截包含 workflow gid 的返回 3. 在真实输入框里输入消息并点发送 (GitLab 前端自己处理发送协议) 4. 拿到 workflow_id 后,用【已抓包验证可用】的 getWorkflowLatestCheckpoint 查询轮询 5. 检测到新的 agent 消息 → 转成 OpenAI SSE chunk yield 6. 工作流终态 → yield done 依赖: playwright (已在 browser_login 部署时装好) """ from __future__ import annotations import asyncio import json import logging import re import time import uuid from typing import AsyncGenerator, Dict, List, Optional logger = logging.getLogger("chat_driver") WORKFLOW_GID_RE = re.compile(r"gid://gitlab/Ai::DuoWorkflows::Workflow/\d+") DUO_CHAT_URL_PATH = "/-/duo_chat" DASHBOARD_URL = "/dashboard/home" # 已抓包验证可用的查询 QUERY_GET_WORKFLOW_CHECKPOINT = """ query getWorkflowLatestCheckpoint($workflowId: AiDuoWorkflowsWorkflowID!) { duoWorkflowWorkflows(workflowId: $workflowId) { nodes { id status workflowDefinition latestCheckpoint { workflowStatus errors duoMessages { content messageType messageId status timestamp __typename } __typename } __typename } __typename } } """ class BrowserChatDriver: """每次聊天创建一个临时 Playwright 页面,发完即销毁。""" def __init__(self, base_url: str = "https://gitlab.com", timeout: int = 120): self.base_url = base_url.rstrip("/") self.timeout = timeout self._pw = None self._browser = None async def _ensure_browser(self): if self._browser is None: from playwright.async_api import async_playwright self._pw = await async_playwright().start() self._browser = await self._pw.chromium.launch( headless=True, args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"], ) return self._browser async def close(self): try: if self._browser: await self._browser.close() except Exception: pass try: if self._pw: await self._pw.stop() except Exception: pass self._browser = None self._pw = None def _cookies_from_str(self, cookie_str: str, domain: str) -> List[Dict]: cookies = [] for pair in cookie_str.split(";"): pair = pair.strip() if not pair or "=" not in pair: continue name, _, value = pair.partition("=") name, value = name.strip(), value.strip() if not name: continue cookies.append({ "name": name, "value": value, "domain": domain, "path": "/", "httpOnly": False, "secure": True, "sameSite": "Lax", }) return cookies async def chat_stream( self, cookie_str: str, prompt: str, model_name: str = "claude-opus-4.8", ) -> AsyncGenerator[str, None]: """ 发送 prompt 并流式 yield OpenAI 兼容 SSE 字符串。 """ import httpx completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}" created_ts = int(time.time()) def chunk(delta_content: str = "", finish_reason: Optional[str] = None, role: bool = False): d = {} if role: d["role"] = "assistant" if delta_content: d["content"] = delta_content return f"data: {json.dumps({'id':completion_id,'object':'chat.completion.chunk','created':created_ts,'model':model_name,'choices':[{'index':0,'delta':d,'finish_reason':finish_reason}]})}\n\n" yield chunk(role=True) # 解析域名 from urllib.parse import urlparse host = urlparse(self.base_url).hostname or "gitlab.com" domain = "." + host.split(".", 1)[-1] if host.count(".") >= 1 else host page = None context = None captured_workflow_id = None captured_csrf = "" async def on_response(resp): nonlocal captured_workflow_id try: if "/api/graphql" not in resp.url: return if resp.request.method != "POST": return body = await resp.text() m = WORKFLOW_GID_RE.search(body) if m: gid = m.group(0) # 只取第一个(发送动作产生的 workflow) if captured_workflow_id is None: captured_workflow_id = gid logger.info("[chat_driver] captured workflow_id=%s from graphql response", gid) except Exception: pass try: await self._ensure_browser() context = await self._browser.new_context( viewport={"width": 1100, "height": 760}, user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"), locale="zh-CN", ignore_https_errors=True, ) cookies = self._cookies_from_str(cookie_str, domain) if cookies: await context.add_cookies(cookies) page = await context.new_page() page.on("response", on_response) # 打开 Duo Chat 全屏页 chat_url = self.base_url + DUO_CHAT_URL_PATH try: await page.goto(chat_url, wait_until="domcontentloaded", timeout=30000) except Exception: await page.goto(self.base_url + DASHBOARD_URL, wait_until="domcontentloaded", timeout=30000) # 抓 CSRF token try: captured_csrf = await page.evaluate( "() => (document.querySelector('meta[name=csrf-token]')||{}).content || ''" ) except Exception: captured_csrf = "" # 等待并定位输入框 textarea = await self._find_input(page) if textarea is None: yield chunk("[Proxy Error] 找不到 Duo Chat 输入框,可能未登录或页面结构变化", finish_reason="error") yield "data: [DONE]\n\n" return # 输入并发送 await textarea.fill(prompt) await asyncio.sleep(0.3) sent = await self._send(page, textarea) if not sent: yield chunk("[Proxy Error] 发送失败", finish_reason="error") yield "data: [DONE]\n\n" return # 等 workflow_id 被拦截(最多 15s) for _ in range(30): if captured_workflow_id: break await asyncio.sleep(0.5) if not captured_workflow_id: yield chunk("[Proxy Error] 未能捕获 workflow_id", finish_reason="error") yield "data: [DONE]\n\n" return # 用已知可用查询轮询回复 async for evt in self._poll_response(captured_workflow_id, captured_csrf, cookie_str, host): if evt["type"] == "content": yield chunk(evt["text"]) elif evt["type"] == "done": yield chunk(finish_reason="stop") yield "data: [DONE]\n\n" return elif evt["type"] == "error": yield chunk("[Proxy Error] " + evt["text"], finish_reason="error") yield "data: [DONE]\n\n" return # 超时兜底 yield chunk(finish_reason="stop") yield "data: [DONE]\n\n" except Exception as e: logger.exception("chat_driver error") yield chunk(f"[Proxy Error] {e}", finish_reason="error") yield "data: [DONE]\n\n" finally: try: if page: await page.close() if context: await context.close() except Exception: pass async def _find_input(self, page): """定位 Duo Chat 输入框,兼容多种选择器。""" selectors = [ "[data-testid='duo-chat-question-input']", "textarea[placeholder*='ask']", "textarea[placeholder*='GitLab Duo']", "textarea[aria-label*='Duo']", "#duo-chat-question-input", "[contenteditable='true'][role='textbox']", "textarea", ] for sel in selectors: try: el = await page.wait_for_selector(sel, timeout=4000) if el: # 确认可见 box = await el.bounding_box() if box: return el except Exception: continue return None async def _send(self, page, textarea) -> bool: """尝试多种发送方式。""" # 方式1: 点发送按钮 for sel in ["[data-testid='duo-chat-send-button']", "button[type='submit']", "button[aria-label*='Send']", "button[aria-label*='send']"]: try: btn = await page.query_selector(sel) if btn: await btn.click() return True except Exception: continue # 方式2: 在 textarea 上按 Enter try: await textarea.press("Enter") return True except Exception: return False async def _poll_response( self, workflow_id: str, csrf: str, cookie_str: str, host: str ) -> AsyncGenerator[Dict, None]: """用 getWorkflowLatestCheckpoint 轮询,yield 事件。""" import httpx url = self.base_url + "/api/graphql" headers = { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "Mozilla/5.0", "Origin": self.base_url, "Referer": self.base_url + "/-/duo_chat", "X-Gitlab-Feature-Category": "duo_agent_platform", "Cookie": cookie_str, } if csrf: headers["X-Csrf-Token"] = csrf seen: set = set() deadline = time.time() + self.timeout payload = { "operationName": "getWorkflowLatestCheckpoint", "query": QUERY_GET_WORKFLOW_CHECKPOINT.strip(), "variables": {"workflowId": workflow_id}, } rounds = 0 while time.time() < deadline: rounds += 1 await asyncio.sleep(1.0) try: async with httpx.AsyncClient(timeout=30, follow_redirects=True, verify=False) as client: resp = await client.post(url, json=payload, headers=headers) data = resp.json() except Exception as e: logger.debug("poll error r%d: %s", rounds, e) continue nodes = (data.get("data", {}) or {}).get("duoWorkflowWorkflows", {}).get("nodes", []) if not nodes: continue node = nodes[0] cp = node.get("latestCheckpoint") or {} messages = cp.get("duoMessages", []) or [] status = cp.get("workflowStatus", "") or node.get("status", "") for msg in messages: mid = msg.get("messageId", "") or str(msg.get("timestamp", "")) + msg.get("messageType", "") if mid in seen: continue seen.add(mid) mtype = msg.get("messageType", "") content = msg.get("content", "") if mtype == "agent" and content: yield {"type": "content", "text": content} if status in ("COMPLETED", "FINISHED", "FAILED", "ERROR"): errs = cp.get("errors", []) if errs: yield {"type": "error", "text": "; ".join(map(str, errs))} yield {"type": "done"} return yield {"type": "error", "text": "轮询超时"} # 全局单例(复用 browser 进程) _driver: Optional[BrowserChatDriver] = None _driver_lock = asyncio.Lock() async def get_driver(base_url: str = "https://gitlab.com") -> BrowserChatDriver: global _driver async with _driver_lock: if _driver is None: _driver = BrowserChatDriver(base_url=base_url, timeout=120) return _driver async def close_driver(): global _driver async with _driver_lock: if _driver: await _driver.close() _driver = None