From b13b6267d6496b5389af75f6dcd5709ff973afe7 Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Wed, 9 Sep 2026 19:53:05 +0800 Subject: [PATCH 1/5] feat(webui): trace SSR runtime closure to slim the production image - add `scripts/traceRuntime.ts` and a `build:image` script that assemble `build-runtime/` via @vercel/nft - add `@vercel/nft`; drop `@react-router/serve` - gate tracing behind `MS_AGENT_WEBUI_TRACE_RUNTIME` in `ms-agent ui --prepare-only` and stamp the cache as `traced` - add a `--frontend-dir` launcher option that rejects a mismatched traced tree - enable tracing in the Docker build and ignore local `build-runtime/` --- .gitignore | 5 + docker/webui.Dockerfile | 11 +- ms_agent/cli/ui.py | 29 ++- ms_agent/cli/ui_resources.py | 90 ++++++++- tests/cli/test_ui.py | 65 ++++++ tests/cli/test_ui_resources.py | 91 +++++++++ webui/AGENTS.md | 55 ++++++ webui/backend/app/launcher.py | 47 ++++- webui/backend/tests/test_launcher.py | 33 ++++ webui/frontend/package.json | 3 +- webui/frontend/pnpm-lock.yaml | 264 ++++++++++++++++++++++++- webui/frontend/scripts/traceRuntime.ts | 224 +++++++++++++++++++++ 12 files changed, 894 insertions(+), 23 deletions(-) create mode 100644 webui/frontend/scripts/traceRuntime.ts diff --git a/.gitignore b/.gitignore index abe29a63a..cad6e4583 100644 --- a/.gitignore +++ b/.gitignore @@ -187,6 +187,11 @@ webui/frontend/.react-router # file it asks for so a local 404-silencer never gets committed again. webui/frontend/public/.well-known/appspecific/com.chrome.devtools.json +# The traced runtime tree `pnpm build:image` assembles; `build/` above does not +# match it. The image builds its own copy inside the WebUI cache rather than here, +# but a local `pnpm build:image` lands one in the checkout. +webui/frontend/build-runtime/ + # Frontend CSS and dependency caches are generated during build and preparation. webui/frontend/public/antd/ webui/frontend/public/assets/ diff --git a/docker/webui.Dockerfile b/docker/webui.Dockerfile index 25c72efce..4c5f87772 100644 --- a/docker/webui.Dockerfile +++ b/docker/webui.Dockerfile @@ -50,8 +50,17 @@ RUN mkdir -p /data /opt/ms-agent-webui-cache WORKDIR /app # This uses the installed wheel and the final runtime's Node version. No source # checkout or frontend compilation is performed inside the image. +# +# MS_AGENT_WEBUI_TRACE_RUNTIME keeps only the dependency closure the SSR entries +# can actually reach: `pnpm install --prod` lands ~430 MB in the cache against a +# real closure of ~51 MB, the waste sitting inside the packages rather than in a +# list of unneeded ones. Tracing imports `tsx` and `@vercel/nft`, both +# devDependencies, so the install it runs on is a full one -- transient, and +# discarded within this single RUN, so no layer retains it. The tracer boots the +# closure and renders a page before the full tree goes away, which is why a +# dependency it could not see fails HERE instead of a request in production. RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ - ms-agent ui --prepare-only --no-browser + MS_AGENT_WEBUI_TRACE_RUNTIME=1 ms-agent ui --prepare-only --no-browser EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ CMD ["curl", "--noproxy", "*", "--fail", "--silent", "http://127.0.0.1:8000/api/health"] diff --git a/ms_agent/cli/ui.py b/ms_agent/cli/ui.py index 97c0ba542..82864afe4 100644 --- a/ms_agent/cli/ui.py +++ b/ms_agent/cli/ui.py @@ -21,6 +21,13 @@ MIN_UV_VERSION = (0, 5, 0) IS_WINDOWS = os.name == 'nt' +#: Opt in to keeping only the traced SSR dependency closure instead of everything +#: `pnpm install` resolved. Deliberately not a CLI flag: the image sets it while +#: building, and the first preparation it triggers costs a full devDependency +#: install plus a tracing pass. Advertising it in `--help` would invite that onto +#: laptops, where the 430 MB it saves is a cache directory nobody ships. +TRACE_RUNTIME_ENV = 'MS_AGENT_WEBUI_TRACE_RUNTIME' + def _port_number(value): try: @@ -121,20 +128,38 @@ def install_node(frontend, production=False): def build_frontend(frontend): pnpm = _require_executable('pnpm') - _check_tool_versions({'node': node, 'pnpm': pnpm}, frontend) + _check_tool_versions({ + 'node': node, + 'pnpm': pnpm + }, frontend) print( '[setup] Building WebUI (including generated CSS)...', flush=True) _run_setup([pnpm, 'build'], frontend, 'frontend build') + def trace_runtime(frontend): + pnpm = _require_executable('pnpm') + _check_tool_versions({ + 'node': node, + 'pnpm': pnpm + }, frontend) + print( + '[setup] Tracing WebUI runtime dependencies...', + flush=True) + _run_setup( + [pnpm, 'exec', 'tsx', 'scripts/traceRuntime.ts'], + frontend, 'runtime dependency tracing') + if installed: + tracing = os.environ.get(TRACE_RUNTIME_ENV) == '1' webui = prepare_installed( webui, common, node_version, skip_install=self.args.skip_install, install_node=install_node, - build_frontend=build_frontend) + build_frontend=build_frontend, + trace_runtime=trace_runtime if tracing else None) python = Path(sys.executable) else: backend = webui / 'backend' diff --git a/ms_agent/cli/ui_resources.py b/ms_agent/cli/ui_resources.py index 9a9aeec21..15917f7e7 100644 --- a/ms_agent/cli/ui_resources.py +++ b/ms_agent/cli/ui_resources.py @@ -128,7 +128,14 @@ def materialize(bundled, manifest, destination): shutil.rmtree(temporary) -def node_stamp(frontend, node_version, *, production=True): +def node_stamp(frontend, node_version, *, production=True, traced=False): + """Describe the prepared dependency tree, so a stale one is replaced. + + ``production`` describes the wheel's own mode, not the install that produced + the tree: tracing needs devDependencies, but that full install is scaffolding + for the tracer and is gone before this is written. ``traced`` is what tells a + closure apart from everything ``pnpm install`` resolved. + """ return { 'node': list(node_version), @@ -142,9 +149,51 @@ def node_stamp(frontend, node_version, *, production=True): json.loads((frontend / 'package.json').read_text())['packageManager'], 'production': production, + 'traced': + traced, } +def _stamp_matches(stamp, expected): + """Decide whether an already prepared tree satisfies this start. + + Tracing is a property of how the cache was built, not of how it is launched: + the image traces once while building and the container then starts from a + plain ``CMD`` that carries no such request. A traced tree therefore answers a + start that did not ask for one -- otherwise every container start would + reject the cache it was shipped with. The reverse must not hold: asking for + tracing when the cache holds the full tree has to re-prepare it, or a + regression in that step would quietly ship the whole 430 MB again. + """ + if stamp is None: + return False + if stamp.get('traced') and not expected['traced']: + stamp = {**stamp, 'traced': False} + return stamp == expected + + +def _prune_to_traced_closure(frontend): + """Replace the installed tree with the closure the tracer just verified. + + ``scripts/traceRuntime.ts`` assembles ``build-runtime/`` -- the build output, + the entries and only the ``node_modules`` files those entries can reach -- and + boots it to render a page before it returns. That order is the point: the full + tree is still intact while the closure proves itself, so a dependency the + tracer could not see fails before anything has been destroyed. + """ + traced = frontend / 'build-runtime' + closure = traced / 'node_modules' + if not closure.is_dir(): + raise UIError('Runtime tracing produced no dependency closure at ' + + str(closure)) + shutil.rmtree(frontend / 'node_modules') + # A rename, not a copy: pnpm's layout is relative symlinks into `.pnpm/` and + # the tracer recreated them as such, so moving the directory keeps every one + # of them resolving inside it. + closure.rename(frontend / 'node_modules') + shutil.rmtree(traced) + + def check_backend_dependencies(): modules = [ 'anthropic', 'exa_py', 'fastapi', 'httpx', 'ipykernel', @@ -164,8 +213,14 @@ def check_backend_dependencies(): ) -def prepare_installed(bundled, common, node_version, *, skip_install, - install_node, build_frontend=None): +def prepare_installed(bundled, + common, + node_version, + *, + skip_install, + install_node, + build_frontend=None, + trace_runtime=None): from ms_agent.version import __version__ manifest_file = bundled / 'RESOURCE-MANIFEST.json' @@ -186,20 +241,30 @@ def prepare_installed(bundled, common, node_version, *, skip_install, if prebuilt: common.validate_build(frontend) marker = frontend / '.node-dependencies.json' - expected = node_stamp(frontend, node_version, production=prebuilt) + expected = node_stamp( + frontend, + node_version, + production=prebuilt, + traced=bool(trace_runtime)) try: - ready = json.loads(marker.read_text()) == expected and ( - frontend / 'node_modules').is_dir() + stamp = json.loads(marker.read_text()) except (OSError, ValueError): - ready = False + stamp = None + ready = _stamp_matches( + stamp, expected) and (frontend / 'node_modules').is_dir() if not ready: if skip_install: raise UIError( 'WebUI Node dependencies need preparation. Run once without --skip-install.' ) marker.unlink(missing_ok=True) - install_node(frontend, production=prebuilt) - marker.write_text(json.dumps(expected, sort_keys=True) + '\n') + # The tracer runs out of this tree and imports `tsx` and + # `@vercel/nft` from it, so tracing cannot start from a production + # install. What it leaves behind is narrower than either. + install_node( + frontend, production=prebuilt and trace_runtime is None) + if trace_runtime is None: + marker.write_text(json.dumps(expected, sort_keys=True) + '\n') if not prebuilt: try: common.validate_build(frontend) @@ -212,4 +277,11 @@ def prepare_installed(bundled, common, node_version, *, skip_install, raise UIError('No WebUI frontend builder is available') build_frontend(frontend) common.validate_build(frontend) + if not ready and trace_runtime is not None: + # After any build, because the tracer follows what the SSR bundle + # imports. The stamp lands only once the closure is in place: an + # interrupted trace must not leave one claiming this tree is traced. + trace_runtime(frontend) + _prune_to_traced_closure(frontend) + marker.write_text(json.dumps(expected, sort_keys=True) + '\n') return destination diff --git a/tests/cli/test_ui.py b/tests/cli/test_ui.py index 913b97e4b..02759e33c 100644 --- a/tests/cli/test_ui.py +++ b/tests/cli/test_ui.py @@ -261,3 +261,68 @@ def test_reload_fails_with_development_instructions(capsys): ui.UICMD(_parse_ui_args('--reload')).execute() assert error.value.code == 1 assert 'pnpm dev' in capsys.readouterr().err + + +def _prepare_installed_kwargs(monkeypatch, trace_env=None): + """Drive execute() far enough to capture what it hands prepare_installed. + + `--prepare-only` returns before ports and the launcher, so the WebUI backend + never has to be importable here: a stub `app.processes` satisfies the single + import execute() makes for its own signal handling. + """ + import contextlib + import sys + import types + from pathlib import Path + + package = types.ModuleType('app') + package.__path__ = [] + + @contextlib.contextmanager + def interruptible(): + yield + + processes = types.ModuleType('app.processes') + processes.interruptible = interruptible + monkeypatch.setitem(sys.modules, 'app', package) + monkeypatch.setitem(sys.modules, 'app.processes', processes) + + if trace_env is None: + monkeypatch.delenv(ui.TRACE_RUNTIME_ENV, raising=False) + else: + monkeypatch.setenv(ui.TRACE_RUNTIME_ENV, trace_env) + + captured = {} + monkeypatch.setattr(ui, 'find_webui', lambda: (Path('/webui'), True)) + monkeypatch.setattr(ui, 'load_common', lambda webui: SimpleNamespace()) + monkeypatch.setattr(ui, '_require_executable', lambda name: '/tools/' + name) + monkeypatch.setattr(ui, '_check_tool_versions', + lambda tools, frontend=None: None) + monkeypatch.setattr(ui, '_read_semantic_version', + lambda *args, **kwargs: (22, 22, 0)) + + def fake_prepare(webui, common, node_version, **kwargs): + captured.update(kwargs) + return webui + + monkeypatch.setattr(ui, 'prepare_installed', fake_prepare) + ui.UICMD(_parse_ui_args('--prepare-only', '--no-browser')).execute() + return captured + + +def test_runtime_tracing_is_off_unless_the_environment_asks(monkeypatch): + assert _prepare_installed_kwargs(monkeypatch)['trace_runtime'] is None + + +def test_runtime_tracing_env_reaches_preparation(monkeypatch): + kwargs = _prepare_installed_kwargs(monkeypatch, trace_env='1') + assert callable(kwargs['trace_runtime']) + + +@pytest.mark.parametrize('value', ['0', '', 'true', 'yes', 'false']) +def test_only_an_exact_1_enables_runtime_tracing(monkeypatch, value): + """A truthiness check here would trace on `=0`, and the cost of that (a full + devDependency install plus a tracing pass) lands on whoever set the variable + specifically to turn the feature off.""" + kwargs = _prepare_installed_kwargs(monkeypatch, trace_env=value) + assert kwargs['trace_runtime'] is None diff --git a/tests/cli/test_ui_resources.py b/tests/cli/test_ui_resources.py index 5fcb43663..9edf7fe75 100644 --- a/tests/cli/test_ui_resources.py +++ b/tests/cli/test_ui_resources.py @@ -162,3 +162,94 @@ def fail(frontend): resources.prepare_installed(root, common, (22, 23, 2), skip_install=False, install_node=lambda *a, **kw: pytest.fail('must not install'), build_frontend=lambda *a: pytest.fail('must not rebuild')) + + +@pytest.fixture +def traced(bundle, tmp_path, monkeypatch): + """A prebuilt cache whose Node preparation can be traced down to its closure.""" + root, _ = bundle + monkeypatch.setenv('MS_AGENT_WEBUI_CACHE', str(tmp_path / 'runtime')) + monkeypatch.setattr(resources, 'check_backend_dependencies', lambda: None) + return SimpleNamespace( + bundled=root, + common=SimpleNamespace(validate_build=lambda directory: None), + cache=tmp_path / 'runtime') + + +def _install_everything(frontend, production): + """Stand in for `pnpm install`: a tree carrying a file no closure can reach.""" + modules = frontend / 'node_modules' + modules.mkdir() + (modules / 'unreachable.js').write_text('// the 430 MB nobody loads') + + +def _fake_trace(frontend): + """Stand in for traceRuntime.ts: assemble build-runtime/ the way it does, + including pnpm's relative symlink layout, which is what the move must keep.""" + closure = frontend / 'build-runtime/node_modules' + (closure / '.pnpm/antd@6/node_modules/antd').mkdir(parents=True) + (closure / '.pnpm/antd@6/node_modules/antd/index.js').write_text('// SSR') + (closure / 'antd').symlink_to('.pnpm/antd@6/node_modules/antd') + (frontend / 'build-runtime/server.js').write_text('// entry') + + +def test_tracing_replaces_the_installed_tree_with_its_closure(traced): + installs = [] + + def install(frontend, production): + # Tracing imports tsx and @vercel/nft out of this very tree, so it can + # never be a production install. + assert production is False + installs.append(frontend) + _install_everything(frontend, production) + + prepared = resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=False, install_node=install, + trace_runtime=_fake_trace) + modules = prepared / 'frontend/node_modules' + assert len(installs) == 1 + assert not (modules / 'unreachable.js').exists() + # Moved, not copied: the relative symlink still resolves inside the tree. + assert (modules / 'antd').is_symlink() + assert (modules / 'antd/index.js').read_text() == '// SSR' + assert not (prepared / 'frontend/build-runtime').exists() + + +def test_traced_cache_is_reused_by_a_plain_start(traced): + prepared = resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=False, install_node=_install_everything, + trace_runtime=_fake_trace) + # Exactly what the container does: CMD carries --skip-install and no tracing + # request, because tracing already happened while the image was built. This + # start must accept the tree it was shipped, not reject it as unprepared. + reused = resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=True, + install_node=lambda *a, **kw: pytest.fail('must not install')) + assert reused == prepared + + +def test_untraced_cache_is_re_prepared_when_tracing_is_requested(traced): + resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=False, install_node=_install_everything) + # The reverse of the case above must NOT hold: accepting the full tree here + # is how the image would quietly go back to shipping 430 MB. + with pytest.raises(resources.UIError, match='need preparation'): + resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=True, + install_node=lambda *a, **kw: pytest.fail('must not install'), + trace_runtime=lambda frontend: pytest.fail('must not trace')) + + +def test_tracing_that_produced_no_closure_keeps_the_installed_tree(traced): + def trace_without_closure(frontend): + (frontend / 'build-runtime').mkdir() + + with pytest.raises(resources.UIError, match='no dependency closure'): + resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=False, install_node=_install_everything, + trace_runtime=trace_without_closure) + frontend = next(traced.cache.glob('*/frontend')) + # Nothing half-pruned, and no stamp claiming this tree is a closure: the next + # attempt has to find it unprepared rather than serve an incomplete tree. + assert (frontend / 'node_modules/unreachable.js').exists() + assert not (frontend / '.node-dependencies.json').exists() diff --git a/webui/AGENTS.md b/webui/AGENTS.md index 72ae60a11..d0f2863e0 100644 --- a/webui/AGENTS.md +++ b/webui/AGENTS.md @@ -67,9 +67,64 @@ cd frontend pnpm install --frozen-lockfile pnpm dev # http://localhost:5173, proxies /api/* to :8000 pnpm build # production build +pnpm build:image # the above, then assemble build-runtime/ — a traced runtime tree pnpm start # serve the build: SSR + /api proxy on one port (PORT, default: API port + 1) ``` +**`pnpm build:image`** is `pnpm build` plus `scripts/traceRuntime.ts`. Vite leaves +SSR dependencies external, so `node_modules` has to exist at runtime, but a +`--prod` install is 430 MB against a real closure of ~51 MB, the waste sitting +*inside* the packages rather than in a list of unneeded ones (antd ships 58 MB of +`es/` + `lib/` + every locale, of which SSR touches 2.3 MB). The script traces +`build/server/index.js` and `server.js` with `@vercel/nft` and copies only what +they can reach, next to the build output, `server.js` and `package.json`, into +`build-runtime/`. Keep the two steps in one script: tracing a build it did not +just produce is how the two come to describe different builds. + +`../docker/webui.Dockerfile` consumes this through the SDK rather than by copying +a directory out of a build stage. It installs the verified wheel and runs +`ms-agent ui --prepare-only` with `MS_AGENT_WEBUI_TRACE_RUNTIME=1`, which makes +`prepare_installed` install devDependencies (the tracer imports `tsx` and +`@vercel/nft`), run this script, then move `build-runtime/node_modules` over the +installed tree and delete the rest — see `../ms_agent/cli/ui_resources.py`. The +cache ends up with the ordinary layout and the closure inside it, so nothing +passes `--frontend-dir`, and the full devDependency install is discarded within +that single `RUN`. + +Doing the pruning in Python is what keeps this script byte-identical to the +standalone `ms-agent-webui` repository `webui/` mirrors. Do not give the script an +in-place mode to save the temporary copy: that copy is also what makes the order +safe, since the closure renders a page while the tree it came from is still there. + +The variable is deliberately not a CLI flag (`../ms_agent/cli/ui.py`). The first +preparation it triggers replaces a `--prod` install with a full one plus the +tracing pass, which is a bad trade on a laptop, where the cache is not something +you ship. `frontend/.node-dependencies.json` records `traced`, so the container's +plain `--skip-install` start accepts the tree it was shipped without repeating the +variable — while requesting tracing over an untraced cache re-prepares it, or a +regression there would silently ship the full tree again. + +Tracing costs ~17s and a 50 MB copy, and it boots a server to check itself, so +`pnpm build` stays hermetic and fast (8s) for the paths that never consume +`build-runtime` — `uv run webui`, a plain `ms-agent ui`, typecheck, a local +production check. + +What tracing cannot see is a specifier computed at runtime, so the script boots +the assembled tree and renders `/settings/mcp-skills` through it before it +returns: a dependency it missed fails the build instead of a request in +production. That route is the smoke target because its loader degrades an +unreachable API to empty lists, so it renders with no backend at all — keep that +property, or move `SMOKE_PATH` to another route that has it. + +To run that tree locally rather than the checkout, pass it to the launcher: +`uv run webui --frontend-dir ../frontend/build-runtime` (from `backend/`). It is +opt-in on purpose. A traced tree has no `app/`, so `validate_build` skips the +source comparison and the copied manifest always agrees with the copied outputs — +auto-preferring the directory when present would serve a stale UI with every +check green. `_check_traced_tree` compares the two manifests while the checkout +is next door, so a `pnpm build` that landed after the last `pnpm build:image` is +refused instead of ignored. + **Frontend configuration** is declared in `backend/.env.example` and read by application code through `frontend/app/lib/env.ts`. Its `SERVER_*` exports are server-side values: the browser build replaces `process.env` with `{}`. Importing diff --git a/webui/backend/app/launcher.py b/webui/backend/app/launcher.py index b22988da5..b7360b010 100644 --- a/webui/backend/app/launcher.py +++ b/webui/backend/app/launcher.py @@ -167,6 +167,28 @@ def filter(self, record): ) +def _check_traced_tree(frontend: Path) -> None: + """Reject a `pnpm build:image` tree that no longer matches the checkout. + + Such a tree is a copy of `build/` next to a traced `node_modules`, and it has no + `app/` — so `validate_build` skips the source comparison and finds the copied + manifest agreeing with the copied outputs, which it always will. Editing a + component, rebuilding, and forgetting to re-trace would then serve the previous + UI with every check green. The checkout's manifest is the second opinion, and it + is only consulted when it exists: the image ships the traced tree alone. + """ + if frontend == FRONTEND_DIR or (frontend / "app").is_dir(): + return + manifest = FRONTEND_DIR / "build/webui-build.json" + traced = frontend / "build/webui-build.json" + if not manifest.is_file() or not traced.is_file(): + return + if sha256(manifest) != sha256(traced): + raise BuildError( + f"{frontend} was traced from a different build than {FRONTEND_DIR}/build" + ) + + def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser( description="Run the MS-Agent WebUI on one public port" @@ -183,6 +205,13 @@ def main(argv: list[str] | None = None) -> None: help="Internal loopback port; by default choose the next free port", ) parser.add_argument("--no-open", action="store_true") + parser.add_argument( + "--frontend-dir", + type=Path, + default=FRONTEND_DIR, + help="Frontend tree to serve; pass frontend/build-runtime to run what the " + "image ships instead of the checkout (see `pnpm build:image`)", + ) parser.add_argument("--startup-timeout", type=float, default=READY_TIMEOUT_S) args = parser.parse_args(argv) if not math.isfinite(args.startup_timeout) or args.startup_timeout <= 0: @@ -190,13 +219,20 @@ def main(argv: list[str] | None = None) -> None: host = args.host.strip().removeprefix("[").removesuffix("]") if not host: parser.error("--host cannot be empty") + frontend = Path(args.frontend_dir).expanduser().resolve() processes = [] exit_code = 0 with interruptible(): try: public, backend = select_ports(host, args.port, args.backend_port) + # A traced tree carries no `app/`, so validate_build cannot tell whether + # it still matches the sources — it only sees its own copied manifest and + # calls itself consistent. Compare the two manifests while the checkout is + # next door, or an edited component would be served from a stale copy with + # every check passing. + _check_traced_tree(frontend) css_href = validate_build( - FRONTEND_DIR, check_sources=(FRONTEND_DIR / "app").is_dir() + frontend, check_sources=(frontend / "app").is_dir() ) node = _require_node() # Load source dotenv before copying the child environment; installed @@ -239,8 +275,8 @@ def main(argv: list[str] | None = None) -> None: ( "frontend", _spawn( - [node, str(FRONTEND_DIR / "server.js")], - cwd=FRONTEND_DIR, + [node, str(frontend / "server.js")], + cwd=frontend, env=env, ), ) @@ -255,7 +291,7 @@ def main(argv: list[str] | None = None) -> None: processes, deadline, kind="css", - expected=sha256(FRONTEND_DIR / "build/client" / css_href.lstrip("/")), + expected=sha256(frontend / "build/client" / css_href.lstrip("/")), ) _check_children(processes) print(f"\nMS-Agent WebUI ready: {url}\n", flush=True) @@ -278,7 +314,8 @@ def main(argv: list[str] | None = None) -> None: ) as exc: print(f"Cannot start WebUI: {exc}", file=sys.stderr, flush=True) if isinstance(exc, BuildError): - print(f"Run `pnpm build` in {FRONTEND_DIR}", file=sys.stderr) + hint = "build:image" if frontend != FRONTEND_DIR else "build" + print(f"Run `pnpm {hint}` in {FRONTEND_DIR}", file=sys.stderr) exit_code = 1 finally: for _label, process in reversed(processes): diff --git a/webui/backend/tests/test_launcher.py b/webui/backend/tests/test_launcher.py index 9e194f60a..db72e803e 100644 --- a/webui/backend/tests/test_launcher.py +++ b/webui/backend/tests/test_launcher.py @@ -144,6 +144,39 @@ def test_changed_css_fails_even_when_sources_unchanged(built_frontend): validate_build(built_frontend) +def _traced_copy(frontend, tmp_path): + """What `pnpm build:image` assembles: build/ copied verbatim beside the server + entry, with no app/ — the sources stay behind in the checkout.""" + traced = tmp_path / "build-runtime" + shutil.copytree(frontend / "build", traced / "build") + shutil.copyfile(frontend / "server.js", traced / "server.js") + shutil.copyfile(frontend / "package.json", traced / "package.json") + return traced + + +def test_traced_tree_serves_without_the_sources_it_was_built_from( + built_frontend, tmp_path, monkeypatch +): + traced = _traced_copy(built_frontend, tmp_path) + monkeypatch.setattr(launcher, "FRONTEND_DIR", built_frontend) + launcher._check_traced_tree(traced) + assert validate_build(traced, check_sources=False) == "/assets/antd.test.css" + + +def test_traced_tree_from_an_older_build_is_rejected( + built_frontend, tmp_path, monkeypatch +): + traced = _traced_copy(built_frontend, tmp_path) + (built_frontend / "app/root.tsx").write_text("// changed") + _record_build_manifest(built_frontend) + monkeypatch.setattr(launcher, "FRONTEND_DIR", built_frontend) + # The blind spot this guard covers: on its own the stale copy passes, because + # the manifest it is checked against was copied along with the outputs. + assert validate_build(traced, check_sources=False) == "/assets/antd.test.css" + with pytest.raises(BuildError, match="traced from a different build"): + launcher._check_traced_tree(traced) + + def test_installed_settings_do_not_load_parent_dotenv(tmp_path): settings_file = Path(launcher.REPO_DIR) / "backend/app/core/settings.py" target = tmp_path / "webui/backend/app/core/settings.py" diff --git a/webui/frontend/package.json b/webui/frontend/package.json index b8cf0ce20..bd3edadd1 100644 --- a/webui/frontend/package.json +++ b/webui/frontend/package.json @@ -12,6 +12,7 @@ "check:frames": "tsx scripts/checkThoughtFrames.ts", "dev": "pnpm gen:antd-css && react-router dev", "build": "pnpm gen:antd-css && react-router build && tsx scripts/buildManifest.ts", + "build:image": "pnpm build && tsx scripts/traceRuntime.ts", "start": "node ./server.js", "typecheck": "react-router typegen && node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit" }, @@ -23,7 +24,6 @@ "@ant-design/x-sdk": "^2.8.0", "@monaco-editor/react": "^4.7.0", "@react-router/node": "^8.1.0", - "@react-router/serve": "^8.1.0", "antd": "^6.5.0", "compression": "1.8.1", "express": "5.2.1", @@ -44,6 +44,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", + "@vercel/nft": "^1.11.0", "tailwindcss": "^4.3.2", "tsx": "^4.23.12", "typescript": "^6.0.3", diff --git a/webui/frontend/pnpm-lock.yaml b/webui/frontend/pnpm-lock.yaml index a791e54e3..2b6443bfa 100644 --- a/webui/frontend/pnpm-lock.yaml +++ b/webui/frontend/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: '@react-router/node': specifier: ^8.1.0 version: 8.1.0(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3) - '@react-router/serve': - specifier: ^8.1.0 - version: 8.1.0(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3) antd: specifier: ^6.5.0 version: 6.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -87,6 +84,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 + '@vercel/nft': + specifier: ^1.11.0 + version: 1.11.0(rollup@4.60.4) tailwindcss: specifier: ^4.3.2 version: 4.3.2 @@ -545,6 +545,10 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -561,6 +565,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} + engines: {node: '>=18'} + hasBin: true + '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} @@ -1442,10 +1451,33 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/nft@1.11.0': + resolution: {integrity: sha512-m1QFg+U+3yPOnP1xSYJ73UIRxLOXdts1JOhiOiyPYqEsALgrXFFINvgUaD6R6iNvaBFAjHllBCbkfx4FuOdpaA==} + engines: {node: '>=20'} + hasBin: true + + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + antd@6.5.0: resolution: {integrity: sha512-9zbVc9UukfGuqCvIAov01nlpDQWfARNmZQyt21ZhqLX7ilXmi4cdkp12xA48WEmXRXwZvno8A03qQuGE9JG8fg==} peerDependencies: @@ -1455,9 +1487,16 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.31: resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} engines: {node: '>=6.0.0'} @@ -1467,10 +1506,17 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1515,6 +1561,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1544,6 +1594,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -1897,6 +1951,9 @@ packages: picomatch: optional: true + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1933,6 +1990,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1982,6 +2043,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2172,6 +2237,10 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2216,6 +2285,18 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} @@ -2245,9 +2326,27 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.44: resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + nprogress@0.2.0: resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} @@ -2295,6 +2394,10 @@ packages: path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -2399,6 +2502,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -2519,6 +2626,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + throttle-debounce@5.0.2: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} @@ -2539,6 +2650,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -2637,12 +2751,22 @@ packages: yaml: optional: true + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + snapshots: '@ant-design/colors@8.0.1': @@ -3131,6 +3255,10 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3150,6 +3278,19 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mapbox/node-pre-gyp@2.0.3': + dependencies: + consola: 3.4.2 + detect-libc: 2.1.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.8.5 + tar: 7.5.22 + transitivePeerDependencies: + - encoding + - supports-color + '@mermaid-js/parser@1.1.1': dependencies: '@chevrotain/types': 11.1.2 @@ -3562,6 +3703,7 @@ snapshots: react-router: 8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) optionalDependencies: typescript: 6.0.3 + optional: true '@react-router/node@8.1.0(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)': dependencies: @@ -3583,6 +3725,7 @@ snapshots: transitivePeerDependencies: - supports-color - typescript + optional: true '@remix-run/node-fetch-server@0.13.3': {} @@ -4020,11 +4163,40 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/nft@1.11.0(rollup@4.60.4)': + dependencies: + '@mapbox/node-pre-gyp': 2.0.3 + '@rollup/pluginutils': 5.4.0(rollup@4.60.4) + acorn: 8.18.0 + acorn-import-attributes: 1.9.5(acorn@8.18.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + node-gyp-build: 4.8.4 + picomatch: 4.0.4 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + + abbrev@3.0.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + antd@6.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@ant-design/colors': 8.0.1 @@ -4083,6 +4255,8 @@ snapshots: argparse@2.0.1: {} + async-sema@3.1.1: {} + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.7 @@ -4092,12 +4266,18 @@ snapshots: transitivePeerDependencies: - supports-color + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.31: {} basic-auth@2.0.1: dependencies: safe-buffer: 5.1.2 + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -4112,6 +4292,10 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.31 @@ -4120,7 +4304,8 @@ snapshots: node-releases: 2.0.44 update-browserslist-db: 1.2.3(browserslist@4.28.2) - buffer-from@1.1.2: {} + buffer-from@1.1.2: + optional: true bytes@3.1.2: {} @@ -4150,6 +4335,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@3.0.0: {} + clsx@2.1.1: {} comma-separated-tokens@2.0.3: {} @@ -4178,6 +4365,8 @@ snapshots: confbox@0.2.4: {} + consola@3.4.2: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -4569,6 +4758,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-uri-to-path@1.0.0: {} + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -4611,6 +4802,12 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -4669,6 +4866,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -4809,6 +5013,8 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -4859,6 +5065,16 @@ snapshots: dependencies: mime-db: 1.54.0 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + monaco-editor@0.55.1: dependencies: dompurify: 3.2.7 @@ -4889,8 +5105,18 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: {} + node-releases@2.0.44: {} + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + nprogress@0.2.0: {} object-inspect@1.13.4: {} @@ -4938,6 +5164,11 @@ snapshots: path-data-parser@0.1.0: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -5032,6 +5263,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + robust-predicates@3.0.3: {} rolldown@1.1.4: @@ -5188,8 +5421,10 @@ snapshots: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 + optional: true - source-map@0.6.1: {} + source-map@0.6.1: + optional: true space-separated-tokens@2.0.2: {} @@ -5215,6 +5450,14 @@ snapshots: tapable@2.3.3: {} + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + throttle-debounce@5.0.2: {} tinyexec@1.1.2: {} @@ -5231,6 +5474,8 @@ snapshots: toidentifier@1.0.1: {} + tr46@0.0.3: {} + ts-dedent@2.2.0: {} tslib@2.8.1: {} @@ -5292,6 +5537,15 @@ snapshots: jiti: 2.7.0 tsx: 4.23.12 + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + wrappy@1.0.2: {} yallist@3.1.1: {} + + yallist@5.0.0: {} diff --git a/webui/frontend/scripts/traceRuntime.ts b/webui/frontend/scripts/traceRuntime.ts new file mode 100644 index 000000000..f066b2371 --- /dev/null +++ b/webui/frontend/scripts/traceRuntime.ts @@ -0,0 +1,224 @@ +/** + * Assemble `build-runtime/` — the exact tree the production image runs. + * + * It holds the build output, `server.js`, `package.json`, and ONLY the + * `node_modules` files reachable from the two entries Node actually loads in + * production: `build/server/index.js` (the SSR bundle) and `server.js`. The + * image used to ship `pnpm prune --prod`'s whole tree, 430 MB, because Vite + * leaves SSR dependencies external and they therefore have to exist at runtime. + * Almost none of those bytes are reachable, though — the waste is INSIDE the + * packages, not a list of packages nobody asked for: antd ships 58 MB of `es/` + * plus `lib/` plus every locale, of which SSR touches 2.3 MB; mermaid 75 MB of + * which 2.3 MB; `typescript` (24 MB) is only a peer of `@react-router/node` and + * is never imported. Tracing the real closure instead lands around 51 MB. + * + * `@vercel/nft` does the tracing (the same resolver Vercel packages functions + * with), so `require`/`import`/`import()` with a static specifier, subpath + * exports and pnpm's symlink layout are all handled. What it CANNOT see is a + * specifier computed at runtime, and a package missing for that reason would + * surface as a 500 on whichever page needs it. Hence `verify()`: this script + * boots the assembled tree and renders a page through it before returning, so + * that failure lands on whoever builds the image instead of on a user. + * + * Not part of `pnpm build`: it costs a copy of ~50 MB and 17s of tracing, and + * only the image consumes it. `pnpm build:image` is `pnpm build` plus this, and + * it is what the Dockerfile's frontend stage runs. + */ +import { nodeFileTrace } from '@vercel/nft' +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import net from 'node:net' +import path from 'node:path' + +const root = process.cwd() +const out = path.join(root, 'build-runtime') + +/** What Node loads at runtime. `server.js` is the process entry; it imports the + * SSR bundle dynamically (`await import(SERVER_BUILD.href)`) from a URL built + * at runtime, which is exactly the shape nft cannot follow — so the bundle is + * named here as a second entry rather than left to be discovered. */ +const ENTRIES = ['build/server/index.js', 'server.js'] + +/** Copied whole, not traced: `build/client` is served to browsers as opaque + * files (nft has no reason to know they exist), and `package.json` is what + * makes Node read `server.js` as ESM — `"type": "module"` lives there. `public/` + * is deliberately absent, as it is from today's image: `react-router build` + * already copied it into `build/client`, and server.js's own fallback for it + * passes `fallthrough: true`, so a missing directory is a no-op. */ +const VERBATIM = ['build', 'server.js', 'package.json'] + +/** Rendered by the smoke check. Chosen because it needs NO reachable backend: + * its loader degrades an API failure to empty lists (the project-wide + * convention), so the page still renders and a 200 means the whole antd + + * @ant-design/x + react-router import graph resolved and React ran. The home + * route would answer 500 here — it lets the API error through on purpose. */ +const SMOKE_PATH = '/settings/mcp-skills' + +const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1) + ' MB' + +/** Distinct free loopback ports. Every probe is held open until the last one is + * bound, because the OS happily hands back a port it released a millisecond + * ago: probing them one at a time returned the SAME number twice, which aimed + * the app's API base at the port it was serving on and had server.js proxy + * `/api` to itself. */ +function freePorts(count: number): Promise { + return new Promise((resolve, reject) => { + const probes: net.Server[] = [] + const ports: number[] = [] + const next = (): void => { + if (ports.length === count) { + let pending = probes.length + for (const probe of probes) probe.close(() => --pending || resolve(ports)) + return + } + const probe = net.createServer() + probes.push(probe) + probe.once('error', reject) + probe.listen(0, '127.0.0.1', () => { + ports.push((probe.address() as net.AddressInfo).port) + next() + }) + } + next() + }) +} + +function connects(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: '127.0.0.1' }) + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', () => { + socket.destroy() + resolve(false) + }) + }) +} + +/** Boot the assembled tree and render one page through it. */ +async function verify(): Promise { + // Two ports: one to serve on, one that is guaranteed to refuse connections so + // the API is *reachably absent*. Pointing the app at a port something else + // happens to hold would have it render whatever that answers. + const [port, closed] = await freePorts(2) + const child = spawn(process.execPath, ['./server.js'], { + cwd: out, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + NODE_ENV: 'production', + HOST: '127.0.0.1', + PORT: String(port), + MS_AGENT_API_BASE_URL: `http://127.0.0.1:${closed}`, + MS_AGENT_FRONTEND_API_BASE_URL: `http://127.0.0.1:${closed}`, + MS_AGENT_WEBUI_BANNER: '0' + } + }) + let log = '' + child.stdout.on('data', (chunk: Buffer) => (log += chunk)) + child.stderr.on('data', (chunk: Buffer) => (log += chunk)) + let exited: number | null = null + child.once('exit', (code) => (exited = code ?? 0)) + + const fail = (message: string): never => { + child.kill('SIGKILL') + throw new Error(`${message}\n--- server output ---\n${log.trim()}`) + } + + try { + // Importing the SSR bundle takes a second or two before the socket is bound; + // a container under load takes longer, so the budget is generous. + const deadline = Date.now() + 60_000 + while (!(await connects(port))) { + if (exited !== null) fail(`server.js exited with ${exited} before listening`) + if (Date.now() > deadline) fail('server.js never accepted a connection') + await new Promise((resolve) => setTimeout(resolve, 200)) + } + + const response = await fetch(`http://127.0.0.1:${port}${SMOKE_PATH}`) + const body = await response.text() + if (response.status === 404) { + fail(`${SMOKE_PATH} is gone — point SMOKE_PATH at a route that renders without a backend`) + } + if (response.status !== 200) { + fail(`${SMOKE_PATH} answered ${response.status}, expected 200`) + } + // A 200 carrying an empty shell would mean antd never rendered: this class + // is the css-var key `getMsaAntdTheme()` pins, so it only appears once the + // theme and the component tree actually made it into the HTML. + if (!/msa-theme-(?:light|dark)/.test(body)) { + fail(`${SMOKE_PATH} rendered without the antd theme class — the SSR output is not usable`) + } + // The two ways a dependency nft could not see reports itself. + const missing = log.match(/Cannot find (?:package|module) [^\n]+/) + if (missing) fail(`a dependency is missing from the traced tree: ${missing[0]}`) + + console.log(`[webui] Verified ${SMOKE_PATH} renders from build-runtime (${body.length} bytes)`) + } finally { + if (exited === null) { + child.kill('SIGTERM') + await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill('SIGKILL') + resolve(null) + }, 5_000) + child.once('exit', () => { + clearTimeout(timer) + resolve(null) + }) + }) + } + } +} + +for (const entry of ENTRIES) { + if (!fs.existsSync(path.join(root, entry))) { + throw new Error(`Missing ${entry} — run \`pnpm build\` first`) + } +} + +// Retries, because the default of none makes this a coin flip on macOS: a +// recursive remove scans a directory, unlinks what it saw, then rmdir's it, and +// Finder drops a `.DS_Store` into `build-runtime` the moment it is looked at — +// arriving after the scan, that turns the rmdir into ENOTEMPTY and fails the +// whole build on nothing. Node retries exactly this class when asked to. +fs.rmSync(out, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) +for (const rel of VERBATIM) { + fs.cpSync(path.join(root, rel), path.join(out, rel), { recursive: true }) +} + +const { fileList } = await nodeFileTrace( + ENTRIES.map((rel) => path.join(root, rel)), + { base: root } +) + +let files = 0 +let bytes = 0 +for (const rel of fileList) { + // Only dependencies: everything else the tree needs is in VERBATIM, and + // leaving the rest out keeps stray sources (`app/`, `public/`) from riding + // along just because something referenced their path. + if (!rel.startsWith('node_modules/')) continue + const src = path.join(root, rel) + const dst = path.join(out, rel) + fs.mkdirSync(path.dirname(dst), { recursive: true }) + // pnpm's layout is symlinks into `.pnpm/`, and nft lists both the link and + // its target. Recreating the link (rather than following it) keeps the + // resolution behaviour identical to the tree this was traced from — and keeps + // one physical copy of a package shared by several dependents. + const stat = fs.lstatSync(src) + if (stat.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(src), dst) + } else if (stat.isFile()) { + fs.copyFileSync(src, dst) + bytes += stat.size + } + files += 1 +} +if (files === 0) throw new Error('Traced no dependencies at all — the entries cannot be right') + +await verify() + +console.log(`[webui] build-runtime carries ${files} dependency entries, ${mb(bytes)}`) From 066a1ea6d3ef13ae0132fdf9488a5349ac18797f Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Wed, 9 Sep 2026 19:53:15 +0800 Subject: [PATCH 2/5] fix(webui): keep the active command suggestion in view Scroll the highlighted suggestion into view on keyboard navigation, reset the selection when the filtered list changes, and guard Enter against an empty list. --- .../app/components/common/Composer.tsx | 65 ++++++++++++++++--- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/webui/frontend/app/components/common/Composer.tsx b/webui/frontend/app/components/common/Composer.tsx index 4e05f7fa9..18de0bc7c 100644 --- a/webui/frontend/app/components/common/Composer.tsx +++ b/webui/frontend/app/components/common/Composer.tsx @@ -309,11 +309,7 @@ export function Composer({ // path; this only covers a Composer mounted outside that layout, where // there is no loader data to seed from. if (hasAppData) return - Promise.all([ - api.listProviders(), - api.listModels(), - api.getAgentSettings() - ]) + Promise.all([api.listProviders(), api.listModels(), api.getAgentSettings()]) .then(([ps, ms, s]) => { setProviders(ps) setModels(ms) @@ -417,6 +413,49 @@ export function Composer({ suggestOpenRef.current = false }, []) + // The panel scrolls (it is height-capped), so arrowing past its edge would + // move the highlighted row out of sight. Keep the active row in view — but + // only for keyboard moves: hover updates the index too, and nudging the list + // under a resting pointer would fight the mouse. + const suggestListRef = useRef(null) + const activeSuggestRef = useRef(null) + const suggestKeyNavRef = useRef(false) + + useEffect(() => { + if (!suggestKeyNavRef.current) return + suggestKeyNavRef.current = false + const list = suggestListRef.current + const item = activeSuggestRef.current + if (!list || !item) return + // Both ends snap all the way, so wrapping around lands on a clean edge with + // the panel's own padding visible instead of the row flush against it. + if (suggestIndex === 0) { + list.scrollTop = 0 + return + } + if (suggestIndex === filteredSuggestions.length - 1) { + list.scrollTop = list.scrollHeight + return + } + // Scrolled by hand rather than `scrollIntoView`: the panel lives in a body + // portal, so the browser would happily scroll the page behind it too. + const listBox = list.getBoundingClientRect() + const itemBox = item.getBoundingClientRect() + if (itemBox.top < listBox.top) { + list.scrollTop -= listBox.top - itemBox.top + } else if (itemBox.bottom > listBox.bottom) { + list.scrollTop += itemBox.bottom - listBox.bottom + } + }, [suggestIndex, filteredSuggestions.length]) + + // Editing the query reshuffles the list, so an index carried over from the + // previous set can point past its end (Enter would then pick nothing). Snap + // the selection — and the scroll position — back to the top. + useEffect(() => { + setSuggestIndex(0) + if (suggestListRef.current) suggestListRef.current.scrollTop = 0 + }, [filteredSuggestions]) + const selectSuggestion = useCallback( (item: { id: string; name: string; value: string }) => { // Replace the trailing `/query` the user was typing with an inline tag @@ -455,20 +494,25 @@ export function Composer({ switch (e.key) { case 'ArrowDown': e.preventDefault() + suggestKeyNavRef.current = true setSuggestIndex((i) => (i + 1) % filteredSuggestions.length) break case 'ArrowUp': e.preventDefault() + suggestKeyNavRef.current = true setSuggestIndex( (i) => (i - 1 + filteredSuggestions.length) % filteredSuggestions.length ) break - case 'Enter': + case 'Enter': { + const picked = filteredSuggestions[suggestIndex] + if (!picked) return e.preventDefault() e.stopPropagation() - selectSuggestion(filteredSuggestions[suggestIndex]) + selectSuggestion(picked) break + } case 'Escape': e.preventDefault() closeSuggestions() @@ -1033,12 +1077,15 @@ export function Composer({ 0} placement="top" - autoAdjustOverflow={false} popupRender={() => ( -
+
{filteredSuggestions.map((item, idx) => (
Date: Wed, 9 Sep 2026 20:21:39 +0800 Subject: [PATCH 3/5] docs(webui): trim runtime build guidance --- webui/AGENTS.md | 62 +++++++------------------------------------------ 1 file changed, 9 insertions(+), 53 deletions(-) diff --git a/webui/AGENTS.md b/webui/AGENTS.md index d0f2863e0..5ceb9d890 100644 --- a/webui/AGENTS.md +++ b/webui/AGENTS.md @@ -71,59 +71,15 @@ pnpm build:image # the above, then assemble build-runtime/ — a traced pnpm start # serve the build: SSR + /api proxy on one port (PORT, default: API port + 1) ``` -**`pnpm build:image`** is `pnpm build` plus `scripts/traceRuntime.ts`. Vite leaves -SSR dependencies external, so `node_modules` has to exist at runtime, but a -`--prod` install is 430 MB against a real closure of ~51 MB, the waste sitting -*inside* the packages rather than in a list of unneeded ones (antd ships 58 MB of -`es/` + `lib/` + every locale, of which SSR touches 2.3 MB). The script traces -`build/server/index.js` and `server.js` with `@vercel/nft` and copies only what -they can reach, next to the build output, `server.js` and `package.json`, into -`build-runtime/`. Keep the two steps in one script: tracing a build it did not -just produce is how the two come to describe different builds. - -`../docker/webui.Dockerfile` consumes this through the SDK rather than by copying -a directory out of a build stage. It installs the verified wheel and runs -`ms-agent ui --prepare-only` with `MS_AGENT_WEBUI_TRACE_RUNTIME=1`, which makes -`prepare_installed` install devDependencies (the tracer imports `tsx` and -`@vercel/nft`), run this script, then move `build-runtime/node_modules` over the -installed tree and delete the rest — see `../ms_agent/cli/ui_resources.py`. The -cache ends up with the ordinary layout and the closure inside it, so nothing -passes `--frontend-dir`, and the full devDependency install is discarded within -that single `RUN`. - -Doing the pruning in Python is what keeps this script byte-identical to the -standalone `ms-agent-webui` repository `webui/` mirrors. Do not give the script an -in-place mode to save the temporary copy: that copy is also what makes the order -safe, since the closure renders a page while the tree it came from is still there. - -The variable is deliberately not a CLI flag (`../ms_agent/cli/ui.py`). The first -preparation it triggers replaces a `--prod` install with a full one plus the -tracing pass, which is a bad trade on a laptop, where the cache is not something -you ship. `frontend/.node-dependencies.json` records `traced`, so the container's -plain `--skip-install` start accepts the tree it was shipped without repeating the -variable — while requesting tracing over an untraced cache re-prepares it, or a -regression there would silently ship the full tree again. - -Tracing costs ~17s and a 50 MB copy, and it boots a server to check itself, so -`pnpm build` stays hermetic and fast (8s) for the paths that never consume -`build-runtime` — `uv run webui`, a plain `ms-agent ui`, typecheck, a local -production check. - -What tracing cannot see is a specifier computed at runtime, so the script boots -the assembled tree and renders `/settings/mcp-skills` through it before it -returns: a dependency it missed fails the build instead of a request in -production. That route is the smoke target because its loader degrades an -unreachable API to empty lists, so it renders with no backend at all — keep that -property, or move `SMOKE_PATH` to another route that has it. - -To run that tree locally rather than the checkout, pass it to the launcher: -`uv run webui --frontend-dir ../frontend/build-runtime` (from `backend/`). It is -opt-in on purpose. A traced tree has no `app/`, so `validate_build` skips the -source comparison and the copied manifest always agrees with the copied outputs — -auto-preferring the directory when present would serve a stale UI with every -check green. `_check_traced_tree` compares the two manifests while the checkout -is next door, so a `pnpm build` that landed after the last `pnpm build:image` is -refused instead of ignored. +**Runtime image build:** `pnpm build:image` runs the normal build, then +`scripts/traceRuntime.ts` traces the SSR entries with `@vercel/nft` into +`build-runtime/` and verifies the assembled tree with a smoke render. + +`../docker/webui.Dockerfile` enables the same flow for installed packages with +`MS_AGENT_WEBUI_TRACE_RUNTIME=1`. The prepared cache records whether its runtime +is traced. To run the traced tree locally from `backend/`, use +`uv run webui --frontend-dir ../frontend/build-runtime`; the launcher rejects it +when its manifest does not match the checkout build. **Frontend configuration** is declared in `backend/.env.example` and read by application code through `frontend/app/lib/env.ts`. Its `SERVER_*` exports are From d8cc75ecb8ce80cdba0a8a1631731b819d77cca2 Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Thu, 10 Sep 2026 20:18:56 +0800 Subject: [PATCH 4/5] fix(config): stop add_model from seeding provider name and protocol Create a new provider entry with only its `models` list, and cover adding a model to a built-in provider in the SDK and backend tests. --- ms_agent/config/model_settings.py | 6 +---- tests/config/test_model_settings.py | 13 +++++++++++ webui/backend/tests/test_providers.py | 32 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/ms_agent/config/model_settings.py b/ms_agent/config/model_settings.py index 7125a0459..ecd39980f 100644 --- a/ms_agent/config/model_settings.py +++ b/ms_agent/config/model_settings.py @@ -100,11 +100,7 @@ def remove_provider(self, provider_id: str) -> None: def add_model(self, provider_id: str, model: str) -> None: data = self._load_raw() providers = data.setdefault('providers', {}) - entry = providers.setdefault(provider_id, { - 'name': provider_id, - 'protocol': 'openai', - 'models': [] - }) + entry = providers.setdefault(provider_id, {}) models = entry.setdefault('models', []) if model not in models: models.append(model) diff --git a/tests/config/test_model_settings.py b/tests/config/test_model_settings.py index 953fd6b0d..2baabd3d2 100644 --- a/tests/config/test_model_settings.py +++ b/tests/config/test_model_settings.py @@ -29,6 +29,19 @@ def test_models_and_default(tmp_path): assert 'a-2' not in m.list_custom_providers()['acme']['models'] +def test_add_model_materializes_a_provider_without_inventing_metadata(tmp_path): + """The entry a first model creates must describe nothing but that model. + + Readers merge these entries over the built-in registry, so a stored field is + taken as the user's own override. Seeding a name and a protocol here made + adding the first model to a built-in provider an unasked-for rename (to its + id) and an unasked-for protocol change. + """ + m = ModelSettingsManager(global_dir=str(tmp_path)) + m.add_model('google', 'gemini-3-pro') + assert m.list_custom_providers()['google'] == {'models': ['gemini-3-pro']} + + def test_preserves_other_sections(tmp_path): p = tmp_path / 'settings.json' p.write_text(json.dumps({'theme': 'dark', 'llm': {'provider': 'x'}})) diff --git a/webui/backend/tests/test_providers.py b/webui/backend/tests/test_providers.py index ec5e35f3b..15fdff3cb 100644 --- a/webui/backend/tests/test_providers.py +++ b/webui/backend/tests/test_providers.py @@ -239,6 +239,38 @@ def test_clearing_a_builtin_display_name_restores_the_spec_default(): P.update_provider("openai", ProviderUpdate(api_key="", name="")) +def test_adding_a_model_does_not_relabel_a_builtin_provider(): + """Adding a model must not double as an edit of the provider itself. + + A built-in provider has no settings.json entry until something writes one, + and `add_model` writes the first one. That entry is merged over the registry + on read, so the SDK seeding it with `name: ` and + `protocol: "openai"` reached the UI as the user's own overrides: the first + model added to `google` renamed it to "google" for good (deleting the model + leaves the entry, and the label, behind) and `anthropic` was reported — and + configured — as an OpenAI-protocol endpoint. + """ + from app.backends.ms_agent import models as M + from app.backends.ms_agent import providers as P + from app.schemas.model import ModelCreate + + before = P.get_provider("anthropic") + assert before.name == "Anthropic" and before.protocol == "anthropic" + + created = M.create_model( + ModelCreate(provider_id="anthropic", name="claude-4-opus")) + try: + after = P.get_provider("anthropic") + assert after.name == before.name + assert after.protocol == before.protocol + assert after.base_url == before.base_url + finally: + M.delete_model(created.id) + # The materialized entry outlives the model it was created for; drop it + # so the built-in is back to "never configured" for later tests. + P.delete_provider("anthropic") + + def test_provider_id_accepts_letters_of_either_case(): """The id only has to be an identifier, not a lowercase slug.""" from app.schemas.provider import ProviderCreate From 4100f2b1654e56a120b037dd4a0525cc994b3b73 Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Thu, 10 Sep 2026 20:19:50 +0800 Subject: [PATCH 5/5] feat(webui): add models from an empty model picker - add an `xs` size to `EmptyState` and a `renderAntdEmpty` handler, wired into `ConfigProvider.renderEmpty` in `root.tsx` - add the `common.noData` string to the en and zh locales - add an "add model" action to the model selector's empty pane, linking to model settings with `?provider=` for the browsed provider - read `?provider=` on the model settings page as the initial selection - add the same action to the default-model picker, opening the model modal and selecting the saved model --- .../app/components/common/EmptyState.tsx | 58 +++++++++++++- .../app/components/common/ModelSelector.tsx | 38 ++++++++- webui/frontend/app/lib/locales/en.json | 3 + webui/frontend/app/lib/locales/zh.json | 3 + webui/frontend/app/root.tsx | 6 ++ webui/frontend/app/routes/settings/models.tsx | 77 +++++++++++++++++-- 6 files changed, 176 insertions(+), 9 deletions(-) diff --git a/webui/frontend/app/components/common/EmptyState.tsx b/webui/frontend/app/components/common/EmptyState.tsx index f985262df..6d161236c 100644 --- a/webui/frontend/app/components/common/EmptyState.tsx +++ b/webui/frontend/app/components/common/EmptyState.tsx @@ -1,12 +1,14 @@ +import type { ConfigProviderProps } from 'antd' import type { ReactNode } from 'react' import emptyLight from '~/assets/images/empty-light.png' import emptyDark from '~/assets/images/empty-dark.png' import chatEmptyLight from '~/assets/images/chat-empty-light.png' import chatEmptyDark from '~/assets/images/chat-empty-dark.png' +import { useT } from '~/lib/i18n' import { useTheme } from '~/lib/theme' import { MsaButton, type MsaButtonProps } from './MsaButton' -export type EmptyStateSize = 'sm' | 'md' | 'lg' +export type EmptyStateSize = 'xs' | 'sm' | 'md' | 'lg' /** Which illustration to show. `box` is the generic "nothing here"; `chat` is for * conversation lists, where a speech bubble reads better than a crate. */ @@ -18,12 +20,14 @@ const ART: Record = { } const IMG_SIZE: Record = { + xs: 'h-[96px]', sm: 'h-[160px]', md: 'h-[200px]', lg: 'h-[240px]' } const PADDING: Record = { + xs: 'py-3', sm: 'py-6', md: 'py-10', lg: 'py-16' @@ -32,11 +36,21 @@ const PADDING: Record = { /** The description tracks the size variant: at `sm` (a sidebar group, a popover) * the body text sits next to 12px UI copy, where `text-sm` reads oversized. */ const TEXT_SIZE: Record = { + xs: 'text-xs', sm: 'text-xs', md: 'text-sm', lg: 'text-sm' } +/** The gap under the illustration shrinks with it — `xs` lives inside a dropdown + * panel, where 16px of air makes the two-line block look unanchored. */ +const TEXT_GAP: Record = { + xs: 'mt-1', + sm: 'mt-4', + md: 'mt-4', + lg: 'mt-4' +} + interface Props { /** Image & spacing size variant */ size?: EmptyStateSize @@ -72,7 +86,7 @@ export function EmptyState({ > {description && ( -

+

{description}

)} @@ -101,3 +115,43 @@ export function EmptyStateAction({ /> ) } + +/** The components antd asks `renderEmpty` about (Select, Table, Cascader, …). */ +type AntdEmptyComponent = Parameters< + NonNullable +>[0] + +/** + * The project empty state, in the shape antd's `ConfigProvider.renderEmpty` + * wants — wired once in `root.tsx`. + * + * antd's data components render their own empty state (its grey crate plus + * "No data") whenever a caller passes no `notFoundContent` / `locale.emptyText`, + * so the default kept surfacing in Select dropdowns and table bodies no matter + * how many call sites were converted by hand. Overriding it centrally is the + * only version of this that stays fixed as new Selects and Tables get written. + * + * Sizing follows antd's own split: list and table bodies have room for the + * illustration, popup panels get the compact one. + */ +export function AntdRenderEmpty({ + componentName +}: { + componentName?: AntdEmptyComponent +}) { + const { t } = useT() + // A filter dropdown supplies its own empty state and antd's default renders + // nothing here on purpose (the call site coalesces on nullish) — returning an + // illustration would stack a second one inside the filter panel. + if (componentName === 'Table.filter') return null + const inList = componentName === 'Table' || componentName === 'List' + return +} + +/** Ready to hand to `ConfigProvider.renderEmpty`. A module-level constant + * because antd keys its config context on this function's identity — an inline + * lambda would rebuild the context, and every consumer with it, on each render + * of the provider. */ +export const renderAntdEmpty: NonNullable< + ConfigProviderProps['renderEmpty'] +> = (componentName) => diff --git a/webui/frontend/app/components/common/ModelSelector.tsx b/webui/frontend/app/components/common/ModelSelector.tsx index efc247b8c..0490656e6 100644 --- a/webui/frontend/app/components/common/ModelSelector.tsx +++ b/webui/frontend/app/components/common/ModelSelector.tsx @@ -1,11 +1,12 @@ import { CheckOutlined } from '@ant-design/icons' import { Popover } from 'antd' import { Fragment, useMemo, useState } from 'react' +import { useNavigate } from 'react-router' import { ProviderTags } from '~/components/models/ProviderTags' import { useT } from '~/lib/i18n' import type { AgentSettings, Model, Provider } from '~/lib/types' import { PillButton } from './PillButton' -import { EmptyState } from './EmptyState' +import { EmptyState, EmptyStateAction } from './EmptyState' import { DeferredSkeleton } from './DeferredSkeleton' import './ModelSelector.css' import JumpIcon from '~/assets/icons/jump.svg?react' @@ -27,6 +28,7 @@ export function ModelSelector({ onSelectModel }: ModelSelectorProps) { const { t } = useT() + const navigate = useNavigate() const [open, setOpen] = useState(false) const [activeProviderId, setActiveProviderId] = useState(null) // Below `sm` the two panes cannot both fit: the panel is capped at the viewport @@ -70,6 +72,19 @@ export function ModelSelector({ setOpen(false) } + /** A provider with no models is a dead end here — models are added in global + * settings, never from this picker. The provider being browsed rides along in + * the URL so the page opens on it instead of on its own first one, which is + * rarely the one the user just found empty. */ + const goAddModels = () => { + setOpen(false) + navigate( + activeProvider + ? `/settings/models?provider=${encodeURIComponent(activeProvider.id)}` + : '/settings/models' + ) + } + return ( - {activeProvider.name} + + {activeProvider.name} +
@@ -161,7 +178,24 @@ export function ModelSelector({
+ {t.modelsAdmin.addModel} + + } />
) : ( diff --git a/webui/frontend/app/lib/locales/en.json b/webui/frontend/app/lib/locales/en.json index 9c57b9e69..5362f27e9 100644 --- a/webui/frontend/app/lib/locales/en.json +++ b/webui/frontend/app/lib/locales/en.json @@ -1,5 +1,8 @@ { "brand": "MS Agent", + "common": { + "noData": "No data" + }, "nav": { "newChat": "Start new chat", "newProject": "Start new project", diff --git a/webui/frontend/app/lib/locales/zh.json b/webui/frontend/app/lib/locales/zh.json index 6032e2a56..6e770f4e2 100644 --- a/webui/frontend/app/lib/locales/zh.json +++ b/webui/frontend/app/lib/locales/zh.json @@ -1,5 +1,8 @@ { "brand": "MS Agent", + "common": { + "noData": "暂无数据" + }, "nav": { "newChat": "开启新对话", "newProject": "开启新项目", diff --git a/webui/frontend/app/root.tsx b/webui/frontend/app/root.tsx index 37d5c25cd..ee18d8a65 100644 --- a/webui/frontend/app/root.tsx +++ b/webui/frontend/app/root.tsx @@ -15,6 +15,7 @@ import { import './app.css' import { NProgressHandler } from '~/components/common/NProgressHandler' +import { renderAntdEmpty } from '~/components/common/EmptyState' import { ErrorState } from '~/components/common/ErrorState' import { ApiError, registerApiErrorReporter } from '~/lib/api' import { getAntdCssHref } from '~/lib/antdStyle.server' @@ -197,6 +198,11 @@ function ThemedRoot({ children }: { children: React.ReactNode }) { locale={antdLocale} theme={getMsaAntdTheme(theme)} modal={msaModalProps} + // Every antd data component falls back to its own "No data" illustration + // when the call site names no empty content; this replaces all of them + // with the project's, so a new Select or Table is themed by default + // instead of by whoever remembers to pass `notFoundContent`. + renderEmpty={renderAntdEmpty} > diff --git a/webui/frontend/app/routes/settings/models.tsx b/webui/frontend/app/routes/settings/models.tsx index 7bc7d5327..48cd56755 100644 --- a/webui/frontend/app/routes/settings/models.tsx +++ b/webui/frontend/app/routes/settings/models.tsx @@ -1,9 +1,10 @@ import { Button, Popconfirm, Select, Tooltip } from 'antd' import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router' import { AddProviderModal } from '~/components/models/AddProviderModal' import { ModelEditModal } from '~/components/models/ModelEditModal' import { ProviderTags } from '~/components/models/ProviderTags' -import { EmptyState } from '~/components/common/EmptyState' +import { EmptyState, EmptyStateAction } from '~/components/common/EmptyState' import { KeyStatusTag } from '~/components/common/KeyStatus' import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' import { api } from '~/lib/api' @@ -22,12 +23,18 @@ export function meta({ matches }: Route.MetaArgs) { export default function ModelsSettings() { const { t } = useT() + const [searchParams] = useSearchParams() // null = not loaded yet (skeleton), [] = genuinely no providers (empty // state). Collapsing the two would flash "no providers" on every visit. const [providers, setProviders] = useState(null) const [models, setModels] = useState([]) const [settings, setSettings] = useState(null) - const [activeProviderId, setActiveProviderId] = useState(null) + // `?provider=` lets a caller open this page on the provider it was talking + // about — the composer's model picker sends the one whose list it found empty. + // A seed only: the selection is the user's from here on. + const [activeProviderId, setActiveProviderId] = useState(() => + searchParams.get('provider') + ) // Provider add/edit share one modal: null = closed, { provider: null } = add, // { provider } = edit. @@ -37,7 +44,14 @@ export default function ModelsSettings() { const [modelEdit, setModelEdit] = useState<{ provider: Provider model: Model | null + /** Opened from the default-model picker: the model it creates is the one the + * user was trying to pick, so it becomes the selection on save. */ + asDefault?: boolean } | null>(null) + // Controlled so the empty state's "add model" button can close the panel: + // the select popup outranks the modal mask, and would otherwise float on top + // of the dialog it just opened. + const [defaultModelOpen, setDefaultModelOpen] = useState(false) const refresh = () => Promise.all([ @@ -49,8 +63,12 @@ export default function ModelsSettings() { setProviders(ps) setModels(ms) setSettings(s) - // Default-select the first provider when nothing is selected. - setActiveProviderId((prev) => prev ?? ps[0]?.id ?? null) + // Default-select the first provider when nothing is selected. A seed + // naming a provider this instance does not have is dropped here rather + // than left selected, which would render as a blank detail pane. + setActiveProviderId((prev) => + prev && ps.some((p) => p.id === prev) ? prev : (ps[0]?.id ?? null) + ) }) // `null` gates the skeletons on this page, so a failure has to settle the // lists to `[]` or they stay skeletons for good. `Promise.all` means any @@ -76,6 +94,11 @@ export default function ModelsSettings() { [models, activeProviderId] ) + const defaultProvider = useMemo( + () => providers?.find((p) => p.id === settings?.default_provider_id) ?? null, + [providers, settings?.default_provider_id] + ) + const updateSettings = async (patch: Partial) => { if (!settings) return const next = await api.putAgentSettings({ ...settings, ...patch }) @@ -94,6 +117,12 @@ export default function ModelsSettings() { ) } + const addDefaultModel = () => { + if (!defaultProvider) return + setDefaultModelOpen(false) + setModelEdit({ provider: defaultProvider, model: null, asDefault: true }) + } + const defaultModelOptions = useMemo( () => models @@ -172,6 +201,27 @@ export default function ModelsSettings() { value={resolvedDefaultModelId} onChange={(v) => updateSettings({ default_model_id: v })} options={defaultModelOptions} + open={defaultModelOpen} + onOpenChange={setDefaultModelOpen} + // A provider with no models leaves this picker with nothing to + // offer, and the models list that fixes it is further down the + // page — behind a provider selection of its own. Adding from here + // opens the same modal that pane uses, on the provider this + // picker is already pointed at. + notFoundContent={ + + {t.modelsAdmin.addModel} + + } + /> + } className="w-full" placeholder="—" disabled={!settings?.default_provider_id} @@ -309,8 +359,25 @@ export default function ModelsSettings() { model={modelEdit?.model ?? null} providers={providers ?? []} onClose={() => setModelEdit(null)} - onSaved={() => { + onSaved={async (m) => { + const asDefault = modelEdit?.asDefault ?? false setModelEdit(null) + // Coming from the default-model picker, the new model is what the + // user was there to choose. The provider rides along because picking + // one is local state until a model is saved with it — the reload + // below would otherwise restore the previously persisted provider and + // drop the model out of sight. Awaited, since a concurrent GET can + // still answer with the pre-save settings. + if (asDefault) { + try { + await updateSettings({ + default_provider_id: m.provider_id, + default_model_id: m.id + }) + } catch { + // API errors surface via the global toast. + } + } refresh() }} />