Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/chatgpt_web2api/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,17 @@ async def _handle_chat(self, request: web.Request) -> web.Response:

model = body.get("model", self._config.chatgpt.default_model)
stream = body.get("stream", False)
apps = body.get("apps")
if apps is not None and (
not isinstance(apps, list)
or any(not isinstance(name, str) or not name.strip() for name in apps)
):
return web.json_response(
{"error": {"message": "apps must be a list of non-empty app names", "type": "invalid_request_error"}},
status=400,
)
if apps is not None:
apps = [name.strip() for name in apps]
project_id = (
body.get("project_id")
or body.get("gizmo_id")
Expand Down Expand Up @@ -388,9 +399,13 @@ async def _handle_chat(self, request: web.Request) -> web.Response:
self._last_project_id = project_id

if stream:
return await self._stream_response(request, model_slug, full_text, timeout)
return await self._stream_response(
request, model_slug, full_text, timeout, **({"apps": apps} if apps else {}),
)
else:
return await self._full_response(request, model_slug, full_text, timeout)
return await self._full_response(
request, model_slug, full_text, timeout, **({"apps": apps} if apps else {}),
)

except Exception as e:
logger.error("Chat error: %s", e, exc_info=True)
Expand Down Expand Up @@ -521,7 +536,8 @@ def _error_response(self, exc: Exception) -> web.Response:
# ── Response formatters ───────────────────────────────────

async def _full_response(
self, request: web.Request, model: str, text: str, timeout: float
self, request: web.Request, model: str, text: str, timeout: float,
apps: list[str] | None = None,
) -> web.Response:
"""Non-streaming: collect all chunks, return one JSON.

Expand All @@ -539,6 +555,7 @@ async def _send_and_collect() -> str:
collected = ""
async for chunk in self._driver.send_and_stream(
text, timeout=timeout, budgets=budgets, model=model,
**({"apps": apps} if apps else {}),
):
collected += chunk.delta
return collected
Expand Down Expand Up @@ -568,7 +585,8 @@ async def _send_and_collect() -> str:
)

async def _stream_response(
self, request: web.Request, model: str, text: str, timeout: float
self, request: web.Request, model: str, text: str, timeout: float,
apps: list[str] | None = None,
) -> web.Response:
"""Streaming: SSE chunks as they arrive.

Expand Down Expand Up @@ -650,6 +668,7 @@ async def _preflight() -> None:
try:
async for chunk in self._driver.send_and_stream(
text, timeout=timeout, budgets=budgets, model=model,
**({"apps": apps} if apps else {}),
):
if chunk.delta:
await self._send_sse(
Expand Down
4 changes: 3 additions & 1 deletion src/chatgpt_web2api/backend_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,9 @@ async def _conversation_id_from_url(self) -> str:
return ""
if not url or "/c/" not in url:
return ""
return url.split("/c/")[1].split("/")[0].split("?")[0]
conversation_id = url.split("/c/")[1].split("/")[0].split("?")[0]
# New chats can briefly expose a frontend-only id before persistence.
return "" if conversation_id.startswith("WEB:") else conversation_id

async def _get_live_conversation_id_best_effort(self) -> str:
"""Resolve the in-flight conversation id by cheapest available source.
Expand Down
50 changes: 33 additions & 17 deletions src/chatgpt_web2api/cdp_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class StreamChunk:
# (is_rate_limited_text is imported from cdp_driver by api_server, chatgpt_dom,
# and tests). _RATE_LIMIT_PHRASES stays private to completion_detector.
from .completion_detector import ( # noqa: E402,F401
ASSISTANT_ROOT_SELECTOR,
PHASE_STALL_SECONDS,
is_rate_limited_text,
)
Expand All @@ -72,6 +73,13 @@ class StreamChunk:
# matches ``navigate_new_chat``.
_CONNECT_READY_TIMEOUT = 10

# Shared by the pre-send baseline and acknowledgment probe. querySelectorAll
# deduplicates a node even when it matches multiple selectors in this list.
_USER_MESSAGE_SELECTOR = (
'[data-message-author-role="user"], '
'[data-markdown-text-tone="user-message"], .rich-text-user-turn'
)

# ChatGPT composer / send-button selectors.
#
# Canonical home moved to chatgpt_dom.py in Phase 5 PR3; re-exported here for
Expand Down Expand Up @@ -1458,6 +1466,10 @@ async def type_message(self, text: str) -> None:
"""
await self._dom.type_message(text)

async def type_message_with_apps(self, text: str, apps: list[str]) -> None:
"""Select app mentions and append text without clearing their chips."""
await self._dom.type_message_with_apps(text, apps)

async def _detect_select_all_modifier(self) -> int:
"""Return the CDP modifiers value for select-all on the live platform.

Expand Down Expand Up @@ -1499,12 +1511,12 @@ async def _read_assistant_count_baseline(self) -> int:

selector = (
"document.querySelectorAll("
"'[data-message-author-role=\"assistant\"]'"
f"'{ASSISTANT_ROOT_SELECTOR}'"
").length"
)
user_selector = (
"document.querySelectorAll("
"'[data-message-author-role=\"user\"]'"
f"'{_USER_MESSAGE_SELECTOR}'"
").length"
)
max_attempts = 3
Expand Down Expand Up @@ -1610,7 +1622,7 @@ async def _verify_send_acknowledged(self) -> bool | None:
result = await self._js_strict(
"(function() {"
" var userMsgs = document.querySelectorAll("
" '[data-message-author-role=\"user\"]').length;"
f" '{_USER_MESSAGE_SELECTOR}').length;"
f" var composer = document.querySelector('{COMPOSER_SELECTOR}')"
f" || document.querySelector('{COMPOSER_FALLBACK_SELECTOR}');"
" var composerPresent = !!composer;"
Expand Down Expand Up @@ -1724,6 +1736,7 @@ async def send_and_stream(
*,
budgets=None,
model: str | None = None,
apps: list[str] | None = None,
) -> AsyncIterator[StreamChunk]:
"""Send a message and yield streaming response chunks.

Expand All @@ -1742,7 +1755,7 @@ async def send_and_stream(
terminal path — success, timeout, exception, cancellation).
"""
from .identity_listener import hash_sent_text
from .turn_anchor import TurnReconciliationError
from .turn_anchor import TurnReconciliationError, normalize_text

# PR4 belt-and-suspenders: refuse to mutate the DOM in parallel mode.
self._assert_owned_tab_required()
Expand All @@ -1757,17 +1770,21 @@ async def send_and_stream(
# A2 Step 3+4: arm capture scope + build fallback anchor.
# The fallback anchor captures pre-send state (backend node-ids/times
# or wall-clock) for dual-anchor correlation if UUID capture fails.
# Correlate logical text; the listener/matcher decode observed serialization.
fallback_anchor = await self._capture_pre_send_fallback_anchor(text)
if self._identity_listener is not None and self._identity_listener.is_alive():
capture_scope = self._identity_listener.arm_capture_scope(
expected_text_hash=hash_sent_text(text),
expected_text_hash=hash_sent_text(normalize_text(text)),
conversation_id=self._current_conv_id,
target_id=self._target_id,
)

try:
# Type and send.
await self.type_message(text)
if apps:
await self.type_message_with_apps(text, apps)
else:
await self.type_message(text)
await self.click_send()

# A2 Step 6: wait for the IdentityListener to capture the UUID.
Expand Down Expand Up @@ -1826,13 +1843,8 @@ async def send_and_stream(
# Wait for URL to become /c/{id}
conv_id = ""
for _ in range(30):
try:
url = await self._js_strict("window.location.href")
except CDPJSError:
await asyncio.sleep(0.5)
continue
if "/c/" in url:
conv_id = url.split("/c/")[1].split("/")[0].split("?")[0]
conv_id = await self._conversation_id_from_url()
if conv_id:
break
await asyncio.sleep(0.5)

Expand All @@ -1852,10 +1864,14 @@ async def send_and_stream(
last_status = result.status
last_diagnostic = result.diagnostic or {}
if result.status == "matched" and result.text:
if len(result.text) > len(last_dom_text):
yield StreamChunk(delta=result.text[len(last_dom_text):])
last_dom_text = result.text
break
if result.text.startswith(last_dom_text):
if result.text != last_dom_text:
yield StreamChunk(delta=result.text[len(last_dom_text):])
last_dom_text = result.text
break
# A rewritten/lagging backend text cannot extend the
# emitted prefix. Wait for reconciliation, never splice.
last_status = "text_mismatch"
if result.status == "non_text":
# P2.5 RCA fix: non_text is NOT terminal here. The backend
# propagates intermediary nodes (reasoning_recap, thoughts,
Expand Down
121 changes: 121 additions & 0 deletions src/chatgpt_web2api/chatgpt_dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import asyncio
import json
import logging
import re
import time
import unicodedata

Expand Down Expand Up @@ -104,6 +105,7 @@
# indefinitely on a genuinely broken composer.
SEND_BUTTON_POLL_INTERVAL_S = 0.3
SEND_BUTTON_POLL_MAX_WAIT_S = 10.0
APP_MENTION_MAX_WAIT_S = 8.0


class ChatGPTDom:
Expand Down Expand Up @@ -303,6 +305,125 @@ async def type_message(self, text: str) -> None:
)
logger.info("Typed: %s", text[:80])

async def _type_app_query(self, query: str) -> None:
"""Trigger mention handling with layout-independent keyboard text."""
d = self._driver
for char in query:
await d._cdp("Input.dispatchKeyEvent", {"type": "keyDown", "key": char, "text": char})
await d._cdp("Input.dispatchKeyEvent", {"type": "keyUp", "key": char})

async def type_message_with_apps(self, text: str, apps: list[str]) -> None:
"""Rebuild the composer with ordered app chips, then append the prompt.

Keep type_message's plain-text verification out of the populated
editor: mention nodes are structured UI metadata, not prompt text.
A click alone is insufficient; require a matching chip before sending.
"""
from .cdp_driver import CDPJSError, SendReadinessError

d = self._driver
focus_end_js = (
"(function(){"
f" const el = document.querySelector('{COMPOSER_SELECTOR}');"
" if (!el || !el.isContentEditable) return false;"
" el.focus();"
" const range = document.createRange();"
" range.selectNodeContents(el); range.collapse(false);"
" const selection = window.getSelection();"
" selection.removeAllRanges(); selection.addRange(range);"
" return true;"
"})()"
)
app_name = apps[0]
try:
# Also clears stale chips/prompt after a rate-limit retry, using
# the existing clear + verification seam before adding any apps.
await d.type_message("")
for index, app_name in enumerate(apps):
if not await d._js_strict(focus_end_js):
raise SendReadinessError("No editable app composer found")
word = re.search(r"[^\W_]+", app_name)
query = (word.group() if word else app_name)[:3]
await self._type_app_query("@" + query)
clicked = False
deadline = time.monotonic() + APP_MENTION_MAX_WAIT_S
while time.monotonic() < deadline:
result = await d._js_strict(
"(function(){"
f" const composer = document.querySelector('{COMPOSER_SELECTOR}');"
" if (!composer) return 'missing composer';"
f" const name = {json.dumps(app_name)};"
" function exact(el, name) {"
" const text = (el.innerText || el.textContent || '').trim();"
" return text === name || text === '@' + name;"
" }"
" function visible(el) {"
" const r = el.getBoundingClientRect(), s = getComputedStyle(el);"
" return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < innerHeight"
" && s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0';"
" }"
# Confirm selection in the editor, not merely in the
# popup (which can retain the same display name).
f" if ({json.dumps(clicked)}) {{"
" const chips = Array.from(composer.querySelectorAll("
" '[contenteditable=\"false\"], [data-type=\"mention\"]'));"
" const outer = chips.filter(el => visible(el)"
" && !chips.some(parent => parent !== el && parent.contains(el))"
" );"
f" const names = {json.dumps(apps[:index + 1])};"
" return names.every(name => outer.filter(el => exact(el, name)"
" || Array.from(el.querySelectorAll('*')).some(child => exact(child, name))).length"
" >= names.filter(n => n === name).length) ? 'selected' : 'waiting';"
" }"
" const c = composer.getBoundingClientRect();"
# Portalled popups need not be descendants of the
# form. Scope by popup semantics AND composer geometry.
" const popups = document.querySelectorAll("
" '[role=\"listbox\"], [role=\"menu\"], [data-radix-popper-content-wrapper], .popover, .composer-home-top-menu, [data-composer-overlay-floating-ui=\"true\"]');"
" const candidates = new Set();"
" for (const popup of popups) {"
" const r = popup.getBoundingClientRect();"
" if (!visible(popup) || r.right < c.left || r.left > c.right"
" || Math.max(0, c.top - r.bottom, r.top - c.bottom) > 120) continue;"
" for (const el of popup.querySelectorAll('*')) {"
# Display names are exact; the internal prefix never
# decides which app is selected.
" if (!visible(el) || (el.innerText || '').trim() !== name) continue;"
" const interactive = el.closest('button, [role=\"option\"], [role=\"menuitem\"], [role=\"button\"], .__menu-item');"
# Live app suggestions can be plain clickable DIVs.
" const item = interactive && popup.contains(interactive) ? interactive : el;"
" if (!item || !popup.contains(item) || !visible(item) || item.disabled"
" || item.getAttribute('aria-disabled') === 'true' || composer.contains(item)"
" || item.closest('nav, aside, article, [data-message-author-role]')) continue;"
" candidates.add(item);"
" }"
" }"
" const items = Array.from(candidates).filter(item =>"
" !Array.from(candidates).some(child => child !== item && item.contains(child)));"
" if (items.length > 1) return 'ambiguous app suggestions';"
" if (!items.length) return 'waiting';"
" items[0].click();"
" return 'clicked';"
"})()"
)
if result == "selected":
break
if result == "clicked":
clicked = True
elif result != "waiting":
raise SendReadinessError(f"App autocomplete: {result}")
await asyncio.sleep(0.1)
else:
raise SendReadinessError("App suggestion or selected chip not found")
if not await d._js_strict(focus_end_js):
raise SendReadinessError("No editable app composer found")
await d._cdp("Input.insertText", {"text": text})
except (CDPJSError, SendReadinessError) as exc:
await d._capture_selector_diagnostic(f"app mention ({app_name})")
if d._breakers:
d._breakers.record_failure(BreakerKind.COMPOSER_SEND_READINESS)
raise SendReadinessError(f"Could not select app {app_name!r}: {exc}") from exc

async def _detect_select_all_modifier(self) -> int:
"""Return the CDP modifiers value for select-all on the live platform.

Expand Down
Loading