gitlab-duo / chat_driver.py
chinazhv's picture
Upload 13 files
205e29d verified
Raw
History Blame Contribute Delete
13.1 kB
#!/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