diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index fd1cb2988..5178e2084 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -665,6 +665,8 @@ def dev( wait_for_client=wait_for_client, auth=config_json.get("auth"), http=config_json.get("http"), + ui=config_json.get("ui"), + ui_config=config_json.get("ui_config"), studio_url=studio_url, allow_blocking=allow_blocking, ) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index f1308c989..7c91c4e9e 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -915,6 +915,66 @@ def _update_http_app_path( http_config["app"] = f"{module_str}:{attr_str}" +def _get_node_pm_install_cmd(config_path: pathlib.Path, config: Config) -> str: + def test_file(file_name): + full_path = config_path.parent / file_name + try: + return full_path.is_file() + except OSError: + return False + + # inspired by `package-manager-detector` + def get_pkg_manager_name(): + try: + with open(config_path.parent / "package.json") as f: + pkg = json.load(f) + + if (pkg_manager_name := pkg.get("packageManager")) and isinstance( + pkg_manager_name, str + ): + return pkg_manager_name.lstrip("^").split("@")[0] + + if ( + dev_engine_name := ( + (pkg.get("devEngines") or {}).get("packageManager") or {} + ).get("name") + ) and isinstance(dev_engine_name, str): + return dev_engine_name + + return None + except Exception: + return None + + npm, yarn, pnpm, bun = [ + test_file("package-lock.json"), + test_file("yarn.lock"), + test_file("pnpm-lock.yaml"), + test_file("bun.lockb"), + ] + + if yarn: + install_cmd = "yarn install --frozen-lockfile" + elif pnpm: + install_cmd = "pnpm i --frozen-lockfile" + elif npm: + install_cmd = "npm ci" + elif bun: + install_cmd = "bun i" + else: + pkg_manager_name = get_pkg_manager_name() + + if pkg_manager_name == "yarn": + install_cmd = "yarn install" + elif pkg_manager_name == "pnpm": + install_cmd = "pnpm i" + elif pkg_manager_name == "bun": + install_cmd = "bun i" + else: + install_cmd = "npm i" + + return install_cmd + + def python_config_to_docker( config_path: pathlib.Path, config: Config, base_image: str ) -> tuple[str, dict[str, str]]: @@ -995,10 +1055,32 @@ ADD {relpath} /deps/{name} for fullpath, (relpath, name) in local_deps.real_pkgs.items() ) + ui_inst_str: str = "" + install_node_str: str = "" + + if config.get("ui") and local_deps.working_dir: + install_node_str = "RUN /storage/install-node.sh" + + ui_inst: list[str] = [] + ui_inst.append(f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'") + if config.get("ui_config"): + ui_inst.append( + f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'" + ) + + ui_inst.append( + f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts", + ) + + ui_inst_str = f"""# -- Installing UI dependencies -- +{os.linesep.join(ui_inst)} +# -- End of UI dependencies install --""" + installs = f"{os.linesep}{os.linesep}".join( filter( None, [ + install_node_str, pip_config_file_str, pip_pkgs_str, pip_reqs_str, @@ -1039,6 +1121,8 @@ ADD {relpath} /deps/{name} "# -- End of local dependencies install --", os.linesep.join(env_vars), "", + ui_inst_str, + "", f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "", ] @@ -1059,62 +1143,7 @@ def node_config_to_docker( config_path: pathlib.Path, config: Config, base_image: str ) -> tuple[str, dict[str, str]]: faux_path = f"/deps/{config_path.parent.name}" - - def test_file(file_name): - full_path = config_path.parent / file_name - try: - return full_path.is_file() - except OSError: - return False - - # inspired by `package-manager-detector` - def get_pkg_manager_name(): - try: - with open(config_path.parent / "package.json") as f: - pkg = json.load(f) - - if (pkg_manager_name := pkg.get("packageManager")) and isinstance( - pkg_manager_name, str - ): - return pkg_manager_name.lstrip("^").split("@")[0] - - if ( - dev_engine_name := ( - (pkg.get("devEngines") or {}).get("packageManager") or {} - ).get("name") - ) and isinstance(dev_engine_name, str): - return dev_engine_name - - return None - except Exception: - return None - - npm, yarn, pnpm, bun = [ - test_file("package-lock.json"), - test_file("yarn.lock"), - test_file("pnpm-lock.yaml"), - test_file("bun.lockb"), - ] - - if yarn: - install_cmd = "yarn install --frozen-lockfile" - elif pnpm: - install_cmd = "pnpm i --frozen-lockfile" - elif npm: - install_cmd = "npm ci" - elif bun: - install_cmd = "bun i" - else: - pkg_manager_name = get_pkg_manager_name() - - if pkg_manager_name == "yarn": - install_cmd = "yarn install" - elif pkg_manager_name == "pnpm": - install_cmd = "pnpm i" - elif pkg_manager_name == "bun": - install_cmd = "bun i" - else: - install_cmd = "npm i" + install_cmd = _get_node_pm_install_cmd(config_path, config) store_config = config.get("store") env_additional_config = ( "" diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index b205b1204..dfa8cbbef 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -494,6 +494,49 @@ RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not foun assert additional_contexts == {} +def test_config_to_docker_gen_ui_python(): + graphs = {"agent": "./agent.py:graph"} + actual_docker_stdin, additional_contexts = config_to_docker( + PATH_TO_CONFIG, + validate_config( + { + "dependencies": ["."], + "graphs": graphs, + "ui": {"agent": "./graphs/agent.ui.jsx"}, + "ui_config": {"shared": ["nuqs"]}, + } + ), + "langchain/langgraph-api", + ) + + expected_docker_stdin = """FROM langchain/langgraph-api:3.11 +RUN /storage/install-node.sh +# -- Adding non-package dependency unit_tests -- +ADD . /deps/__outer_unit_tests/unit_tests +RUN set -ex && \\ + for line in '[project]' \\ + 'name = "unit_tests"' \\ + 'version = "0.1"' \\ + '[tool.setuptools.package-data]' \\ + '"*" = ["**/*"]'; do \\ + echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\ + done +# -- End of non-package dependency unit_tests -- +# -- Installing all local dependencies -- +RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/* +# -- End of local dependencies install -- +ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}' +# -- Installing UI dependencies -- +ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}' +ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}' +RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts +# -- End of UI dependencies install -- +WORKDIR /deps/__outer_unit_tests/unit_tests""" + + assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin + assert additional_contexts == {} + + # config_to_compose def test_config_to_compose_simple_config(): graphs = {"agent": "./agent.py:graph"}