Skip to content
Merged
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ chain = [
patchright = [
"patchright==1.60.1",
]
# Camoufox engine extra — opt-in, stealth-oriented Firefox build that avoids
# bot detection and WAFs. Selected via GFLOW_CLI_BROWSER_ENGINE=camoufox.
# Requires separate installation: `uv pip install gflow-cli[camoufox]`.
camoufox = [
"camoufox",
]

[project.scripts]
gflow = "gflow_cli.cli:main"
Expand Down
16 changes: 13 additions & 3 deletions src/gflow_cli/api/_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,20 @@ def retryable_engine_errors() -> tuple[type[BaseException], ...]:
errs: list[type[BaseException]] = [PwError, PwTimeout]
try:
mod = importlib.import_module("patchright.async_api")
errs.append(cast("type[BaseException]", mod.Error))
errs.append(cast("type[BaseException]", mod.TimeoutError))
except ImportError:
return tuple(errs)
errs.append(cast("type[BaseException]", mod.Error))
errs.append(cast("type[BaseException]", mod.TimeoutError))
pass

try:
c_mod = importlib.import_module("camoufox.async_api")
if hasattr(c_mod, "Error"):
errs.append(cast("type[BaseException]", c_mod.Error))
if hasattr(c_mod, "TimeoutError"):
errs.append(cast("type[BaseException]", c_mod.TimeoutError))
except ImportError:
pass

return tuple(errs)


Expand Down
221 changes: 175 additions & 46 deletions src/gflow_cli/api/client.py

Large diffs are not rendered by default.

29 changes: 28 additions & 1 deletion src/gflow_cli/api/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ class GeneratedImage:
fife_url: str # CDN URL — usually expires after ~6 hours
dimensions: tuple[int, int] # (width, height)
media_generation_id: str | None = None
display_name: str | None = None

@property
def is_signed_url(self) -> bool:
Expand Down Expand Up @@ -172,8 +173,34 @@ def from_response_dict(cls, data: dict[str, Any]) -> list[GeneratedImage]:
if not isinstance(media, list):
msg = "unexpected batchGenerateImages response shape: media is not a list"
raise ValueError(msg)
workflows = data.get("workflows", [])
workflow_map: dict[str, str] = {}
if isinstance(workflows, list):
workflows_list = cast("list[Any]", workflows)
for w in workflows_list:
if not isinstance(w, dict):
continue
w_dict = cast("dict[str, Any]", w)
w_id = w_dict.get("name")
if isinstance(w_id, str):
metadata = w_dict.get("metadata")
if isinstance(metadata, dict):
metadata_dict = cast("dict[str, Any]", metadata)
display_name = metadata_dict.get("displayName")
if isinstance(display_name, str):
workflow_map[w_id.split("/")[-1]] = display_name

items = cast("list[dict[str, Any]]", media)
return [cls.from_response_item(item) for item in items]
result: list[GeneratedImage] = []
for item in items:
img = cls.from_response_item(item)
w_id = img.workflow_id.split("/")[-1]
if w_id in workflow_map:
from dataclasses import replace

img = replace(img, display_name=workflow_map[w_id])
result.append(img)
return result


@dataclass(frozen=True)
Expand Down
3 changes: 3 additions & 0 deletions src/gflow_cli/api/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ class GenerateImageRequest:
aspect: Aspect = Aspect.PORTRAIT
model: Model = Model.NARWHAL
refs: tuple[ImageRef, ...] = ()
# Display names corresponding to the pre-uploaded UUID `refs`. Attached via the
# Flow UI picker by the ui_automation transport.
ref_names: tuple[str, ...] = ()
# Local reference-image files for I2I — attached through the editor's media
# dialog by the ui_automation transport (the REST uploadImage path 401s).
# The common i2i case; `refs` (pre-uploaded UUIDs) would need library-select.
Expand Down
9 changes: 8 additions & 1 deletion src/gflow_cli/api/transports/drivers/agentic.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,14 @@ async def send_prompt(
# is the proven Slate path (mirrors classic _send_prompt).
await page.keyboard.press("Control+a")
await page.keyboard.press("Delete")
await page.keyboard.insert_text(directive)

from gflow_cli.config import BrowserEngine, get_settings
is_camoufox = get_settings().browser_engine == BrowserEngine.CAMOUFOX

if is_camoufox:
await page.keyboard.type(directive)
else:
await page.keyboard.insert_text(directive)

log.debug(
"agentic_driver.send_prompt.typed",
Expand Down
Loading