From b4ce8faa701f647330184acdd5583649f29f0460 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Wed, 1 Jul 2026 17:55:06 +0800 Subject: [PATCH 1/7] Switch default frontend to Next.js app --- .github/workflows/python.yaml | 12 ++--- MANIFEST.in | 4 +- .../development/contributing_environment.rst | 10 +++- .../development/xinference_internals.rst | 2 +- .../getting_started/using_xinference.rst | 17 +++++-- frontend/.gitignore | 2 + frontend/README.md | 48 ++++++++++--------- frontend/next.config.ts | 25 +++++++--- frontend/package.json | 2 +- .../launch-dialog/launch-dialog.tsx | 2 +- frontend/src/lib/utils.ts | 8 +++- setup.py | 15 ++++-- xinference/api/restful_api.py | 23 +++++++-- xinference/deploy/docker/Dockerfile | 2 +- xinference/deploy/docker/Dockerfile.aarch64 | 2 +- xinference/deploy/docker/Dockerfile.cpu | 2 +- 16 files changed, 118 insertions(+), 58 deletions(-) diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index 759905d95b..e556fca71a 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -36,14 +36,14 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 16 + node-version: 20.19.0 # ESLint and Prettier must be in `package.json` - name: Install Node.js dependencies - run: cd xinference/ui/web/ui && npm ci + run: cd frontend && npm ci - name: ESLint Check - run: cd xinference/ui/web/ui && npx eslint . - - name: Prettier Check - run: cd xinference/ui/web/ui && ./node_modules/.bin/prettier --check . + run: cd frontend && npx eslint . + - name: Build frontend + run: cd frontend && npm run build build_test_job: runs-on: ${{ matrix.os }} @@ -335,4 +335,4 @@ jobs: xinference fi - working-directory: . \ No newline at end of file + working-directory: . diff --git a/MANIFEST.in b/MANIFEST.in index 3a5995af61..a6b5b19034 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -13,4 +13,6 @@ include xinference/locale/*.json include xinference/model/llm/*.json include xinference/model/embedding/*.json graft xinference/thirdparty -global-include xinference/ui/web/ui/build/**/* \ No newline at end of file +global-include frontend/.next/standalone/**/* +global-include frontend/.next/static/**/* +global-include frontend/public/**/* diff --git a/doc/source/development/contributing_environment.rst b/doc/source/development/contributing_environment.rst index 6f4b084250..e299bf78ab 100644 --- a/doc/source/development/contributing_environment.rst +++ b/doc/source/development/contributing_environment.rst @@ -86,7 +86,7 @@ is to compile the frontend. Frontend Compilation -------------------- -Navigate to the ``inference/xinference/ui/web/ui`` directory. Then, execute the following command +Navigate to the ``inference/frontend`` directory. Then, execute the following command to clear the cache: :: @@ -104,11 +104,17 @@ frontend: :: - npm install + npm ci npm run build Still, if the first command fails to execute, you can try adding the ``--force`` option. +For local frontend development, start the Xinference backend separately and then run: + +:: + + XINFERENCE_API_URL=http://127.0.0.1:9997 npm run dev + After compiling the frontend, you can ``cd`` back to the directory where the ``setup.cfg`` and ``setup.py`` files are located, and install Xinference via ``pip install -e .``. diff --git a/doc/source/development/xinference_internals.rst b/doc/source/development/xinference_internals.rst index 4bdb8fc57a..6f52a2312f 100644 --- a/doc/source/development/xinference_internals.rst +++ b/doc/source/development/xinference_internals.rst @@ -279,4 +279,4 @@ The main code is located in the `xinference/ `_: It provides a structure for model descriptions, creation, and caching. See `Model`_ for more information. -- `web/ui/ `_: The js code of the frontend (Web UI). +- `frontend/ `_: The Next.js code of the frontend (Web UI). diff --git a/doc/source/getting_started/using_xinference.rst b/doc/source/getting_started/using_xinference.rst index d396fb3be9..0f132149a6 100644 --- a/doc/source/getting_started/using_xinference.rst +++ b/doc/source/getting_started/using_xinference.rst @@ -46,11 +46,22 @@ To start a local instance of Xinference, run the following command: XINFERENCE_HOME=/tmp/xinference xinference-local --host 0.0.0.0 --port 9997 -Congrats! You now have Xinference running on your local machine. Once Xinference is running, there are multiple ways +Congrats! You now have the Xinference backend running on your local machine. Once Xinference is running, there are multiple ways we can try it: via the web UI, via cURL, via the command line, or via the Xinference's python client. -You can visit the web UI at `http://127.0.0.1:9997/ui `_ and visit `http://127.0.0.1:9997/docs `_ -to inspect the API docs. +The default web UI is a separate frontend application. Start it from the repository root: + +.. code-block:: bash + + cd frontend + npm ci + npm run dev + +Then visit `http://127.0.0.1:3999 `_. By default, the frontend proxies API requests to +``http://127.0.0.1:9997``. If your backend is on another endpoint, start the frontend with +``XINFERENCE_API_URL= npm run dev``. + +You can visit `http://127.0.0.1:9997/docs `_ to inspect the API docs. You can install the Xinference command line tool and Python client using the following command: diff --git a/frontend/.gitignore b/frontend/.gitignore index 5ef6a52078..14b1ecdaef 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -29,6 +29,8 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +.npm-cache/ +.npm-logs/ # env files (can opt-in for committing if needed) .env* diff --git a/frontend/README.md b/frontend/README.md index e215bc4ccf..0883f17f46 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,36 +1,38 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Xinference Frontend -## Getting Started +This is the default Xinference Web UI. It runs as a separate Next.js app and +talks to a running Xinference backend. -First, run the development server: +## Local Development + +Start the backend first: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +xinference-local --host 127.0.0.1 --port 9997 ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Then start the frontend: -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More +```bash +cd frontend +npm ci +npm run dev +``` -To learn more about Next.js, take a look at the following resources: +Open http://127.0.0.1:3999. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +By default, the frontend proxies API requests to `http://127.0.0.1:9997`. To +use a different backend endpoint: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +XINFERENCE_API_URL=http://127.0.0.1:6735 npm run dev +``` -## Deploy on Vercel +For direct browser requests to a non-default backend, use +`NEXT_PUBLIC_API_URL` instead. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Build -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +npm run build +``` diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e38ea6b904..021d3eae64 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -21,6 +21,20 @@ const nextConfig = { eslint: { ignoreDuringBuilds: false, }, + async rewrites() { + const apiUrl = + process.env.XINFERENCE_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:9997'; + return [ + { + source: '/v1/:path*', + destination: `${apiUrl}/v1/:path*`, + }, + { + source: '/token', + destination: `${apiUrl}/token`, + }, + ]; + }, async redirects() { return [ { @@ -31,22 +45,19 @@ const nextConfig = { ]; }, webpack(config: any) { - const fileLoaderRule = config.module.rules.find( - (rule: any) => - rule.test?.test?.('.svg') - ) + const fileLoaderRule = config.module.rules.find((rule: any) => rule.test?.test?.('.svg')); if (fileLoaderRule) { - fileLoaderRule.exclude = /\.svg$/i + fileLoaderRule.exclude = /\.svg$/i; } config.module.rules.push({ test: /\.svg$/i, issuer: /\.[jt]sx?$/, use: ['@svgr/webpack'], - }) + }); - return config + return config; }, }; diff --git a/frontend/package.json b/frontend/package.json index b5de8c2fe9..5baa804d82 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 3999", + "dev": "next dev -H 127.0.0.1 -p 3999", "build": "next build", "start": "next start", "lint": "eslint .", diff --git a/frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx b/frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx index 300632fba6..e3940d32df 100644 --- a/frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx +++ b/frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx @@ -1367,7 +1367,7 @@ export default function LaunchDialog({ {loading && }
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index d4a48b2af6..504564cf02 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -9,8 +9,12 @@ export function cn(...inputs: ClassValue[]) { } export function getApiUrl(): string { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://10.1.0.45:4466'; - return apiUrl; + if (typeof window === 'undefined') { + return ( + process.env.XINFERENCE_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:9997' + ); + } + return process.env.NEXT_PUBLIC_API_URL || ''; } export function formatFileSize(bytes: number): string { diff --git a/setup.py b/setup.py index 45a3a21a52..1e1a1a999d 100644 --- a/setup.py +++ b/setup.py @@ -77,10 +77,10 @@ class BuildWeb(Command): """build_web command""" user_options = [] - _web_src_path = "xinference/ui/web/ui" - _web_dest_path = "xinference/ui/web/ui/build/index.html" + _web_src_path = "frontend" + _web_dest_path = "frontend/.next/standalone/server.js" _commands = [ - ["npm", "install"], + ["npm", "ci"], ["npm", "run", "build"], ] @@ -104,10 +104,17 @@ def run(cls): return else: replacements = {"npm": npm_path} + npm_env = os.environ.copy() + npm_env.setdefault( + "npm_config_cache", os.path.join(web_src_path, ".npm-cache") + ) + npm_env.setdefault( + "npm_config_logs_dir", os.path.join(web_src_path, ".npm-logs") + ) cmd_errored = False for cmd in cls._commands: cmd = [replacements.get(c, c) for c in cmd] - proc_result = subprocess.run(cmd, cwd=web_src_path) + proc_result = subprocess.run(cmd, cwd=web_src_path, env=npm_env) if proc_result.returncode != 0: warnings.warn(f'Failed when running `{" ".join(cmd)}`') cmd_errored = True diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index eea59ba503..f63b7804e1 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -569,7 +569,21 @@ async def get_response(self, path: str, scope): except ImportError as e: raise ImportError(f"Xinference is imported incorrectly: {e}") - if os.path.exists(ui_location): + frontend_endpoint = os.environ.get("XINFERENCE_FRONTEND_ENDPOINT") + if frontend_endpoint: + frontend_endpoint = frontend_endpoint.rstrip("/") + + @self._app.get("/") + def read_main(): + response = RedirectResponse(url=frontend_endpoint) + return response + + @self._app.get("/ui/") + def read_ui(): + response = RedirectResponse(url=frontend_endpoint) + return response + + elif os.path.exists(ui_location): @self._app.get("/") def read_main(): @@ -583,9 +597,10 @@ def read_main(): else: warnings.warn( f""" - Xinference ui is not built at expected directory: {ui_location} - To resolve this warning, navigate to {os.path.join(lib_location, "ui/web/ui/")} - And build the Xinference ui by running "npm run build" + The legacy Xinference ui is not built at expected directory: {ui_location} + The default frontend now runs as a separate Next.js application. + Start the backend with "xinference-local" and start the frontend from + the repository "frontend/" directory with "npm run dev". """ ) diff --git a/xinference/deploy/docker/Dockerfile b/xinference/deploy/docker/Dockerfile index b510dff503..fa12cecda0 100644 --- a/xinference/deploy/docker/Dockerfile +++ b/xinference/deploy/docker/Dockerfile @@ -4,7 +4,7 @@ COPY . /opt/inference WORKDIR /opt/inference ENV NVM_DIR=/usr/local/nvm -ENV NODE_VERSION=14.21.1 +ENV NODE_VERSION=20.19.0 # Install system dependencies and Node.js (libfst-dev should be able to solve the errors of pyini) RUN apt-get -y update \ diff --git a/xinference/deploy/docker/Dockerfile.aarch64 b/xinference/deploy/docker/Dockerfile.aarch64 index 837cb7abf6..03e3bab9db 100644 --- a/xinference/deploy/docker/Dockerfile.aarch64 +++ b/xinference/deploy/docker/Dockerfile.aarch64 @@ -5,7 +5,7 @@ COPY . /opt/inference WORKDIR /opt/inference ENV NVM_DIR /usr/local/nvm -ENV NODE_VERSION 18.20.4 +ENV NODE_VERSION 20.19.0 RUN apt-get -y update \ && apt install -y wget curl procps git libgl1 libfst-dev cmake libssl-dev \ diff --git a/xinference/deploy/docker/Dockerfile.cpu b/xinference/deploy/docker/Dockerfile.cpu index 61300d95b6..fc7c6bcf54 100644 --- a/xinference/deploy/docker/Dockerfile.cpu +++ b/xinference/deploy/docker/Dockerfile.cpu @@ -3,7 +3,7 @@ FROM continuumio/miniconda3:23.10.0-1 COPY . /opt/inference ENV NVM_DIR /usr/local/nvm -ENV NODE_VERSION 14.21.1 +ENV NODE_VERSION 20.19.0 RUN apt-get -y update \ && apt install -y build-essential curl procps git libgl1 \ From 98414ad3bb4b408aa5b727714c2bae954ec3e44a Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Thu, 2 Jul 2026 10:30:43 +0800 Subject: [PATCH 2/7] Redirect frontend root to launch page --- frontend/src/app/page.tsx | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9ca08e7293..8ffc12240b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,20 +1,5 @@ -// "use client"; +import { redirect } from 'next/navigation'; -// import { useGlobal } from '@/contexts/global-context'; -// import { Loader2 } from 'lucide-react'; - -// export default function Home() { -// const { globalReady } = useGlobal(); -// if(!globalReady) { -// return ( -//
-// -//
-// ) -// } -// return null; -// } -// next.config: redirect root '/' to '/workbench' export default function Home() { - return null; -} \ No newline at end of file + redirect('/launch-model'); +} From fd3daae15a6ffb5c228e0b06aac894e9c4662220 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Thu, 2 Jul 2026 10:46:58 +0800 Subject: [PATCH 3/7] Address frontend review feedback --- MANIFEST.in | 8 +++++--- frontend/next.config.ts | 5 +++-- frontend/src/lib/utils.ts | 4 ++-- setup.py | 2 -- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index a6b5b19034..898ee8d3be 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -13,6 +13,8 @@ include xinference/locale/*.json include xinference/model/llm/*.json include xinference/model/embedding/*.json graft xinference/thirdparty -global-include frontend/.next/standalone/**/* -global-include frontend/.next/static/**/* -global-include frontend/public/**/* +graft frontend +prune frontend/.next +prune frontend/node_modules +prune frontend/.npm-cache +prune frontend/.npm-logs diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 021d3eae64..e300510892 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -22,8 +22,9 @@ const nextConfig = { ignoreDuringBuilds: false, }, async rewrites() { - const apiUrl = - process.env.XINFERENCE_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:9997'; + const apiUrl = ( + process.env.XINFERENCE_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:9997' + ).replace(/\/+$/, ''); return [ { source: '/v1/:path*', diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 504564cf02..deea972860 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -12,9 +12,9 @@ export function getApiUrl(): string { if (typeof window === 'undefined') { return ( process.env.XINFERENCE_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:9997' - ); + ).replace(/\/+$/, ''); } - return process.env.NEXT_PUBLIC_API_URL || ''; + return (process.env.NEXT_PUBLIC_API_URL || '').replace(/\/+$/, ''); } export function formatFileSize(bytes: number): string { diff --git a/setup.py b/setup.py index 1e1a1a999d..2993b9e492 100644 --- a/setup.py +++ b/setup.py @@ -123,8 +123,6 @@ def run(cls): assert os.path.exists(web_dest_path) -CustomInstall.register_pre_command("build_web") -CustomDevelop.register_pre_command("build_web") CustomSDist.register_pre_command("build_web") sys.path.append(repo_root) From 9a5fc519acb2372aea8ceb641217b5a219708c2c Mon Sep 17 00:00:00 2001 From: OliverBryant <50265724+OliverBryant@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:01:06 +0800 Subject: [PATCH 4/7] Update xinference/api/restful_api.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- xinference/api/restful_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index f63b7804e1..e9ce0dc289 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -569,7 +569,7 @@ async def get_response(self, path: str, scope): except ImportError as e: raise ImportError(f"Xinference is imported incorrectly: {e}") - frontend_endpoint = os.environ.get("XINFERENCE_FRONTEND_ENDPOINT") + frontend_endpoint = os.environ.get("XINFERENCE_FRONTEND_ENDPOINT", "http://127.0.0.1:3999") if frontend_endpoint: frontend_endpoint = frontend_endpoint.rstrip("/") From e30b97815739f88074a7397820efd2dbc41ea781 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Thu, 2 Jul 2026 11:13:48 +0800 Subject: [PATCH 5/7] Format frontend endpoint default --- xinference/api/restful_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index e9ce0dc289..b114b4c66a 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -569,7 +569,9 @@ async def get_response(self, path: str, scope): except ImportError as e: raise ImportError(f"Xinference is imported incorrectly: {e}") - frontend_endpoint = os.environ.get("XINFERENCE_FRONTEND_ENDPOINT", "http://127.0.0.1:3999") + frontend_endpoint = os.environ.get( + "XINFERENCE_FRONTEND_ENDPOINT", "http://127.0.0.1:3999" + ) if frontend_endpoint: frontend_endpoint = frontend_endpoint.rstrip("/") From 88bd676f730241cbe58a15aac1013018134bd236 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Wed, 8 Jul 2026 15:07:37 +0800 Subject: [PATCH 6/7] Serve bundled Next.js static export from the backend Rework the frontend switch to a single-process deployment, following the approach used in xagent#732: - next.config: env-driven output (default static export; NEXT_OUTPUT=standalone keeps the Node server; dev mode sets none). Rewrites are dev/standalone only. - Dynamic routes split into server wrappers (generateStaticParams __shell__ placeholder) + client pages reading params from the URL. - layout drops request-time cookies()/headers() and server-side cluster auth fetch; AppInit resolves cluster auth client-side, I18nProvider falls back to the browser language. - New xinference/api/frontend_static.py serves the export from FastAPI: /_next assets, SPA catch-all with __shell__ route table, RSC .txt payloads, JSON 404 for unmatched API prefixes, /ui legacy redirect, and ensure_spa_fallback_last() so runtime Gradio mounts are not shadowed. - Packaging: npm postbuild stages the export at xinference/ui/web/dist, which ships in the wheel/sdist; build_web hooks restored for install/develop/sdist. - Tests: synthetic-export unit tests plus real-export integration tests; CI lint job builds the export, verifies its shape, and serves it through the backend. --- .github/workflows/python.yaml | 14 +- .gitignore | 4 +- MANIFEST.in | 2 + .../development/contributing_environment.rst | 4 + .../getting_started/using_xinference.rst | 21 +- frontend/README.md | 25 +- frontend/next.config.ts | 71 +++-- frontend/package.json | 1 + frontend/scripts/stage-export.mjs | 22 ++ .../launch-model/[modelType]/page-client.tsx | 33 +++ .../src/app/launch-model/[modelType]/page.tsx | 28 +- frontend/src/app/layout.tsx | 61 +---- frontend/src/app/page.tsx | 15 +- .../[modelType]/[modelName]/page-client.tsx | 14 + .../[modelType]/[modelName]/page.tsx | 19 +- .../[modelType]/page-client.tsx | 13 + .../app/register-model/[modelType]/page.tsx | 17 +- .../running-model/[modelUid]/page-client.tsx | 12 + .../src/app/running-model/[modelUid]/page.tsx | 17 +- frontend/src/contexts/app-init.tsx | 41 +-- frontend/src/contexts/global-context.tsx | 4 +- frontend/src/contexts/i18n-context.tsx | 9 + frontend/src/lib/route-params.ts | 15 ++ setup.py | 13 +- xinference/api/frontend_static.py | 194 ++++++++++++++ xinference/api/restful_api.py | 57 ++-- xinference/api/tests/test_frontend_static.py | 248 ++++++++++++++++++ .../tests/test_frontend_static_real_export.py | 132 ++++++++++ 28 files changed, 901 insertions(+), 205 deletions(-) create mode 100644 frontend/scripts/stage-export.mjs create mode 100644 frontend/src/app/launch-model/[modelType]/page-client.tsx create mode 100644 frontend/src/app/register-model/[modelType]/[modelName]/page-client.tsx create mode 100644 frontend/src/app/register-model/[modelType]/page-client.tsx create mode 100644 frontend/src/app/running-model/[modelUid]/page-client.tsx create mode 100644 frontend/src/lib/route-params.ts create mode 100644 xinference/api/frontend_static.py create mode 100644 xinference/api/tests/test_frontend_static.py create mode 100644 xinference/api/tests/test_frontend_static_real_export.py diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index e556fca71a..6a7e07b819 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -42,8 +42,20 @@ jobs: run: cd frontend && npm ci - name: ESLint Check run: cd frontend && npx eslint . - - name: Build frontend + - name: Build frontend (static export) run: cd frontend && npm run build + - name: Verify static export output + run: | + test -f frontend/out/index.html + test -f frontend/out/404.html + # Dynamic routes must emit __shell__ placeholder pages. + find frontend/out -name '__shell__*' | grep -q . + - name: Serve static export from backend + run: | + pip install fastapi httpx pytest xoscar + pytest --noconftest -vv \ + xinference/api/tests/test_frontend_static.py \ + xinference/api/tests/test_frontend_static_real_export.py build_test_job: runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 91a9d0e3c7..c36c1f3fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -162,5 +162,7 @@ asv/results !SECURITY.md .history/ -# frontend +# frontend **/pnpm-lock.yaml +# staged frontend static export served by the backend +xinference/ui/web/dist/ diff --git a/MANIFEST.in b/MANIFEST.in index 898ee8d3be..41f571fb24 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -13,8 +13,10 @@ include xinference/locale/*.json include xinference/model/llm/*.json include xinference/model/embedding/*.json graft xinference/thirdparty +graft xinference/ui/web/dist graft frontend prune frontend/.next +prune frontend/out prune frontend/node_modules prune frontend/.npm-cache prune frontend/.npm-logs diff --git a/doc/source/development/contributing_environment.rst b/doc/source/development/contributing_environment.rst index e299bf78ab..347810345b 100644 --- a/doc/source/development/contributing_environment.rst +++ b/doc/source/development/contributing_environment.rst @@ -109,6 +109,10 @@ frontend: Still, if the first command fails to execute, you can try adding the ``--force`` option. +The build emits a static export and stages it at ``xinference/ui/web/dist``, +where the Xinference backend serves it directly — no Node.js runtime is needed +after building. + For local frontend development, start the Xinference backend separately and then run: :: diff --git a/doc/source/getting_started/using_xinference.rst b/doc/source/getting_started/using_xinference.rst index 0f132149a6..fede89c397 100644 --- a/doc/source/getting_started/using_xinference.rst +++ b/doc/source/getting_started/using_xinference.rst @@ -46,22 +46,19 @@ To start a local instance of Xinference, run the following command: XINFERENCE_HOME=/tmp/xinference xinference-local --host 0.0.0.0 --port 9997 -Congrats! You now have the Xinference backend running on your local machine. Once Xinference is running, there are multiple ways +Congrats! You now have Xinference running on your local machine. Once Xinference is running, there are multiple ways we can try it: via the web UI, via cURL, via the command line, or via the Xinference's python client. -The default web UI is a separate frontend application. Start it from the repository root: +You can visit the web UI at `http://127.0.0.1:9997 `_ and visit `http://127.0.0.1:9997/docs `_ +to inspect the API docs. -.. code-block:: bash - - cd frontend - npm ci - npm run dev - -Then visit `http://127.0.0.1:3999 `_. By default, the frontend proxies API requests to -``http://127.0.0.1:9997``. If your backend is on another endpoint, start the frontend with -``XINFERENCE_API_URL= npm run dev``. +.. note:: -You can visit `http://127.0.0.1:9997/docs `_ to inspect the API docs. + The web UI is a Next.js application bundled into the ``xinference`` package as a static + export and served by the backend itself, so no Node.js runtime is required at runtime. + When developing Xinference from a source checkout, build it once with + ``cd frontend && npm ci && npm run build``, or work on the frontend live with + ``npm run dev`` (see the "Creating a development environment" page). You can install the Xinference command line tool and Python client using the following command: diff --git a/frontend/README.md b/frontend/README.md index 0883f17f46..817b42f9dc 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,7 +1,8 @@ # Xinference Frontend -This is the default Xinference Web UI. It runs as a separate Next.js app and -talks to a running Xinference backend. +This is the Xinference Web UI, a Next.js app. In production it is built as a +**static export**, bundled into the `xinference` Python package, and served by +the Xinference backend itself — a single process, no Node.js runtime needed. ## Local Development @@ -11,7 +12,7 @@ Start the backend first: xinference-local --host 127.0.0.1 --port 9997 ``` -Then start the frontend: +Then start the frontend dev server: ```bash cd frontend @@ -21,7 +22,7 @@ npm run dev Open http://127.0.0.1:3999. -By default, the frontend proxies API requests to `http://127.0.0.1:9997`. To +In dev mode, the frontend proxies API requests to `http://127.0.0.1:9997`. To use a different backend endpoint: ```bash @@ -36,3 +37,19 @@ For direct browser requests to a non-default backend, use ```bash npm run build ``` + +This produces the static export in `out/` and stages it at +`xinference/ui/web/dist` (postbuild hook), where the backend serves it. After +building, `http://:9997/` serves the UI directly. + +Notes on the static export: + +- Dynamic routes (`launch-model/[modelType]`, `running-model/[modelUid]`, ...) + are emitted as `__shell__` placeholder pages; the backend maps any real URL + to the matching shell and the client reads the actual params from the URL + (see `src/lib/route-params.ts` and `xinference/api/frontend_static.py`). +- `next.config.ts` only enables rewrites (the dev API proxy) outside export + mode; in production the UI is same-origin with the API, so no proxy is + needed. +- To build a self-contained Node server instead (no static export), use + `NEXT_OUTPUT=standalone npm run build`. diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e300510892..cc680fcd7f 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,26 @@ +// Output mode (build only): +// 'export' -> static assets in out/, bundled into the Python wheel and +// served by the Xinference backend (single-process pip path). +// This is the default for builds. +// 'standalone' -> self-contained Node server (set NEXT_OUTPUT=standalone) for +// deployments that run the frontend as a separate service. +// +// Never set an output mode in `next dev`: 'export' forces dynamicParams=false, +// so navigating to a real dynamic route (/launch-model/llm, ...) whose params +// are not in generateStaticParams throws. In production the backend SPA +// fallback maps those paths to the __shell__ page; the dev server has no such +// layer. +const isDev = process.env.NODE_ENV === 'development'; +const outputMode = isDev + ? undefined + : process.env.NEXT_OUTPUT === 'standalone' + ? 'standalone' + : 'export'; + /** @type {import('next').NextConfig} */ const nextConfig = { - // Enable standalone output for Docker deployment - output: 'standalone', + ...(outputMode ? { output: outputMode } : {}), + images: { unoptimized: true }, experimental: { optimizeCss: false, }, @@ -21,30 +40,30 @@ const nextConfig = { eslint: { ignoreDuringBuilds: false, }, - async rewrites() { - const apiUrl = ( - process.env.XINFERENCE_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:9997' - ).replace(/\/+$/, ''); - return [ - { - source: '/v1/:path*', - destination: `${apiUrl}/v1/:path*`, - }, - { - source: '/token', - destination: `${apiUrl}/token`, - }, - ]; - }, - async redirects() { - return [ - { - source: '/', - destination: '/launch-model', - permanent: true, // false = 307 (temporary redirect), true = 301 (permanent redirect) - }, - ]; - }, + // rewrites/redirects are unsupported in static export; in the single-process + // deployment the backend serves the UI same-origin so no proxy is needed. + // Keep the API proxy for `next dev` and standalone builds. + ...(outputMode !== 'export' + ? { + async rewrites() { + const apiUrl = ( + process.env.XINFERENCE_API_URL || + process.env.NEXT_PUBLIC_API_URL || + 'http://127.0.0.1:9997' + ).replace(/\/+$/, ''); + return [ + { + source: '/v1/:path*', + destination: `${apiUrl}/v1/:path*`, + }, + { + source: '/token', + destination: `${apiUrl}/token`, + }, + ]; + }, + } + : {}), webpack(config: any) { const fileLoaderRule = config.module.rules.find((rule: any) => rule.test?.test?.('.svg')); diff --git a/frontend/package.json b/frontend/package.json index 5baa804d82..40ac058a8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev -H 127.0.0.1 -p 3999", "build": "next build", + "postbuild": "node scripts/stage-export.mjs", "start": "next start", "lint": "eslint .", "format": "prettier . --write" diff --git a/frontend/scripts/stage-export.mjs b/frontend/scripts/stage-export.mjs new file mode 100644 index 0000000000..3ab2aa4306 --- /dev/null +++ b/frontend/scripts/stage-export.mjs @@ -0,0 +1,22 @@ +// Stage the Next.js static export inside the Python package (postbuild hook). +// +// `next build` with output 'export' emits static assets into out/; the +// Xinference backend serves them from xinference/ui/web/dist so the wheel is +// self-contained. Standalone/dev builds produce no out/ directory, in which +// case there is nothing to stage. +import { cpSync, existsSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const frontendRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const exportDir = join(frontendRoot, 'out'); +const destDir = join(frontendRoot, '..', 'xinference', 'ui', 'web', 'dist'); + +if (!existsSync(join(exportDir, 'index.html'))) { + console.log(`[stage-export] no static export at ${exportDir}; skipping`); + process.exit(0); +} + +rmSync(destDir, { recursive: true, force: true }); +cpSync(exportDir, destDir, { recursive: true }); +console.log(`[stage-export] staged ${exportDir} -> ${destDir}`); diff --git a/frontend/src/app/launch-model/[modelType]/page-client.tsx b/frontend/src/app/launch-model/[modelType]/page-client.tsx new file mode 100644 index 0000000000..185b466bde --- /dev/null +++ b/frontend/src/app/launch-model/[modelType]/page-client.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { Suspense } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import LaunchModel from '@/components/pages/launch-model'; +import { + getLaunchModelRouteType, + getInitialCustomType, +} from '@/components/pages/launch-model/utils'; +import { decodeRouteParam } from '@/lib/route-params'; + +function LaunchModelPageInner() { + const params = useParams<{ modelType: string }>(); + const searchParams = useSearchParams(); + const modelType = decodeRouteParam(params.modelType); + const activeType = searchParams.get('activeType') ?? undefined; + + return ( + + ); +} + +export default function LaunchModelPageClient() { + // useSearchParams requires a Suspense boundary during prerender. + return ( + + + + ); +} diff --git a/frontend/src/app/launch-model/[modelType]/page.tsx b/frontend/src/app/launch-model/[modelType]/page.tsx index c0c0d0f797..8c0f6aa99b 100644 --- a/frontend/src/app/launch-model/[modelType]/page.tsx +++ b/frontend/src/app/launch-model/[modelType]/page.tsx @@ -1,24 +1,12 @@ -import LaunchModel from '@/components/pages/launch-model'; -import { getLaunchModelRouteType, getInitialCustomType } from '@/components/pages/launch-model/utils'; +import LaunchModelPageClient from './page-client'; -interface LaunchModelPageProps { - params: Promise<{ - modelType: string; - }>; - - searchParams: Promise<{ - activeType?: string; - }>; +// Server wrapper for static export: provides a placeholder param so a shell +// HTML is emitted for this dynamic route. The backend serves the shell for any +// real model type; the client component reads the actual values from the URL. +export function generateStaticParams() { + return [{ modelType: '__shell__' }]; } -export default async function LaunchModelPage({ params, searchParams }: LaunchModelPageProps) { - const { modelType } = await params; - const { activeType } = await searchParams; - - return ( - - ); +export default function LaunchModelPage() { + return ; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index dddd74ff2b..91804fd3ce 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import { cookies, headers } from 'next/headers'; import { getBrandingFromEnv } from '@/lib/branding'; import { I18nProvider } from '@/contexts/i18n-context'; import RequestProvider from '@/contexts/request-context'; @@ -8,8 +7,6 @@ import ThemeProvider from '@/contexts/theme-context'; import AppInit from '@/contexts/app-init'; import { LayoutContent } from '@/components/layout/layout-content'; import { Toaster } from '@/components/ui/sonner'; -import type { Locale } from '@/types/common'; -import { getApiUrl } from '@/lib/utils'; import './globals.css'; const branding = getBrandingFromEnv(); @@ -22,64 +19,24 @@ export const metadata: Metadata = { apple: branding.logoPath, }, }; -const resolveInitialLocale = async (): Promise => { - try { - const cookieStore = await cookies(); - const cookieLocale = cookieStore.get('app_locale')?.value; - if (cookieLocale === 'en' || cookieLocale === 'zh') { - return cookieLocale; - } - const headerStore = await headers(); - const acceptLanguage = headerStore.get('accept-language')?.toLowerCase() || ''; - return acceptLanguage.includes('zh') ? 'zh' : 'en'; - } catch { - return 'en'; - } -}; - -export default async function RootLayout({ +// No request-time cookies()/headers() here: the app is shipped as a static +// export served by the Xinference backend. The client restores the locale +// (localStorage/navigator) in I18nProvider and fetches the cluster auth mode +// in AppInit. +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const initialLocale = await resolveInitialLocale(); - const apiUrl = getApiUrl(); - let clusterAuth = null; - let clusterAuthError: string | null = null; - try { - const res = await fetch(apiUrl + '/v1/cluster/auth', { - cache: 'no-store', - }); - - let data: any = null; - try { - data = await res.json(); - } catch { - data = null; - } - if (!res.ok) { - throw new Error( - `Server error: ${res.status} - ${ - data?.detail || 'Unknown error' - }` - ); - } - clusterAuth = data; - } catch (error) { - clusterAuthError = error instanceof Error ? error.message : 'Cluster auth failed'; - } - return ( - + - - + + - + {children} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 8ffc12240b..e87b0296e5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,5 +1,16 @@ -import { redirect } from 'next/navigation'; +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +// Client-side redirect: server redirect()/next.config redirects are not +// supported in static export. export default function Home() { - redirect('/launch-model'); + const router = useRouter(); + + useEffect(() => { + router.replace('/launch-model'); + }, [router]); + + return null; } diff --git a/frontend/src/app/register-model/[modelType]/[modelName]/page-client.tsx b/frontend/src/app/register-model/[modelType]/[modelName]/page-client.tsx new file mode 100644 index 0000000000..8fce98533f --- /dev/null +++ b/frontend/src/app/register-model/[modelType]/[modelName]/page-client.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import RegisterModel from '@/components/pages/register-model'; +import { getRigisterModelTyps } from '@/components/pages/register-model/utils'; +import { decodeRouteParam } from '@/lib/route-params'; + +export default function RegisterModelEditPageClient() { + const params = useParams<{ modelType: string; modelName: string }>(); + const modelType = decodeRouteParam(params.modelType); + const modelName = decodeRouteParam(params.modelName); + + return ; +} diff --git a/frontend/src/app/register-model/[modelType]/[modelName]/page.tsx b/frontend/src/app/register-model/[modelType]/[modelName]/page.tsx index 4783bdba4b..677bb150ca 100644 --- a/frontend/src/app/register-model/[modelType]/[modelName]/page.tsx +++ b/frontend/src/app/register-model/[modelType]/[modelName]/page.tsx @@ -1,14 +1,13 @@ -import RegisterModel from '@/components/pages/register-model'; -import { getRigisterModelTyps } from '@/components/pages/register-model/utils'; +import RegisterModelEditPageClient from './page-client'; -interface RegisterModelPageProps { - params: Promise<{ - modelType: string; - modelName: string; - }>; +// Server wrapper for static export: provides placeholder params so a shell +// HTML is emitted for this dynamic route. The backend serves the shell for any +// real model type/name; the client component reads the actual values from the +// URL. +export function generateStaticParams() { + return [{ modelType: '__shell__', modelName: '__shell__' }]; } -export default async function RegisterModelPage({ params }: RegisterModelPageProps) { - const { modelType, modelName } = await params; - return ; +export default function RegisterModelEditPage() { + return ; } diff --git a/frontend/src/app/register-model/[modelType]/page-client.tsx b/frontend/src/app/register-model/[modelType]/page-client.tsx new file mode 100644 index 0000000000..41649e0490 --- /dev/null +++ b/frontend/src/app/register-model/[modelType]/page-client.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import RegisterModel from '@/components/pages/register-model'; +import { getRigisterModelTyps } from '@/components/pages/register-model/utils'; +import { decodeRouteParam } from '@/lib/route-params'; + +export default function RegisterModelPageClient() { + const params = useParams<{ modelType: string }>(); + const modelType = decodeRouteParam(params.modelType); + + return ; +} diff --git a/frontend/src/app/register-model/[modelType]/page.tsx b/frontend/src/app/register-model/[modelType]/page.tsx index 4242e3eade..2c7d714f51 100644 --- a/frontend/src/app/register-model/[modelType]/page.tsx +++ b/frontend/src/app/register-model/[modelType]/page.tsx @@ -1,13 +1,12 @@ -import RegisterModel from '@/components/pages/register-model'; -import { getRigisterModelTyps } from '@/components/pages/register-model/utils'; +import RegisterModelPageClient from './page-client'; -interface RegisterModelPageProps { - params: Promise<{ - modelType: string; - }>; +// Server wrapper for static export: provides a placeholder param so a shell +// HTML is emitted for this dynamic route. The backend serves the shell for any +// real model type; the client component reads the actual values from the URL. +export function generateStaticParams() { + return [{ modelType: '__shell__' }]; } -export default async function RegisterModelPage({ params }: RegisterModelPageProps) { - const { modelType } = await params; - return ; +export default function RegisterModelPage() { + return ; } diff --git a/frontend/src/app/running-model/[modelUid]/page-client.tsx b/frontend/src/app/running-model/[modelUid]/page-client.tsx new file mode 100644 index 0000000000..7e27bfcab8 --- /dev/null +++ b/frontend/src/app/running-model/[modelUid]/page-client.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import RunningModelDetail from '@/components/pages/running-model-detail'; +import { decodeRouteParam } from '@/lib/route-params'; + +export default function RunningModelDetailPageClient() { + const params = useParams<{ modelUid: string }>(); + const modelUid = decodeRouteParam(params.modelUid); + + return ; +} diff --git a/frontend/src/app/running-model/[modelUid]/page.tsx b/frontend/src/app/running-model/[modelUid]/page.tsx index 3426d02f97..65e863e6cf 100644 --- a/frontend/src/app/running-model/[modelUid]/page.tsx +++ b/frontend/src/app/running-model/[modelUid]/page.tsx @@ -1,11 +1,12 @@ -import RunningModelDetail from '@/components/pages/running-model-detail'; +import RunningModelDetailPageClient from './page-client'; -interface RunningModelDetailPageProps { - params: Promise<{ - modelUid: string; - }>; +// Server wrapper for static export: provides a placeholder param so a shell +// HTML is emitted for this dynamic route. The backend serves the shell for any +// real model uid; the client component reads the actual value from the URL. +export function generateStaticParams() { + return [{ modelUid: '__shell__' }]; } -export default async function RunningModelDetailPage({ params }: RunningModelDetailPageProps) { - const { modelUid } = await params; - return ; + +export default function RunningModelDetailPage() { + return ; } diff --git a/frontend/src/contexts/app-init.tsx b/frontend/src/contexts/app-init.tsx index d67db8daeb..befaefbde7 100644 --- a/frontend/src/contexts/app-init.tsx +++ b/frontend/src/contexts/app-init.tsx @@ -6,20 +6,13 @@ import { toast } from 'sonner'; import Cookies from 'js-cookie'; import { NO_AUTH, LOGIN_PATH } from '@/constants'; import { useGlobal } from '@/contexts/global-context'; +import { getApiUrl } from '@/lib/utils'; +import type { ClusterAuth } from '@/types/services'; -interface ClusterAuthResponse { - auth: boolean; -} - -interface AppInitProps { - clusterAuth: ClusterAuthResponse | null; - clusterAuthError: string | null; -} - -export default function AppInit({ clusterAuth, clusterAuthError }: AppInitProps) { +export default function AppInit() { const router = useRouter(); const pathname = usePathname(); - const { fetchGlobalAfterAuth } = useGlobal(); + const { setClusterAuth, fetchGlobalAfterAuth } = useGlobal(); const initialized = useRef(false); @@ -28,14 +21,32 @@ export default function AppInit({ clusterAuth, clusterAuthError }: AppInitProps) initialized.current = true; const init = async () => { - // service error - if (clusterAuthError) { + // Fetched client-side: the app ships as a static export, so there is no + // request-time server rendering to resolve the cluster auth mode. + let clusterAuth: ClusterAuth | null = null; + try { + const res = await fetch(getApiUrl() + '/v1/cluster/auth', { + cache: 'no-store', + }); + let data = null; + try { + data = await res.json(); + } catch { + data = null; + } + if (!res.ok) { + throw new Error(`Server error: ${res.status} - ${data?.detail || 'Unknown error'}`); + } + clusterAuth = data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Cluster auth failed'; // Fix Sonner not being mounted during initialization queueMicrotask(() => { - toast.error(clusterAuthError); + toast.error(message); }); return; } + setClusterAuth(clusterAuth); // no_auth (login not required) if (clusterAuth?.auth === false) { Cookies.set('token', NO_AUTH, { @@ -59,7 +70,7 @@ export default function AppInit({ clusterAuth, clusterAuthError }: AppInitProps) } }; init(); - }, [clusterAuth, clusterAuthError, pathname, router, fetchGlobalAfterAuth]); + }, [pathname, router, setClusterAuth, fetchGlobalAfterAuth]); return null; } diff --git a/frontend/src/contexts/global-context.tsx b/frontend/src/contexts/global-context.tsx index 7f41504ec0..c9258660a4 100644 --- a/frontend/src/contexts/global-context.tsx +++ b/frontend/src/contexts/global-context.tsx @@ -21,10 +21,10 @@ export interface GlobalState { const GlobalContext = createContext(null); interface Props extends PropsWithChildren { - initClusterAuth: ClusterAuth | null; + initClusterAuth?: ClusterAuth | null; } -export function GlobalProvider({ children, initClusterAuth }: Props) { +export function GlobalProvider({ children, initClusterAuth = null }: Props) { const [clusterAuth, setClusterAuth] = useState(initClusterAuth); const [clusterVersion, setClusterVersion] = useState({} as ClusterVersion); const [clusterUIConfig, setClusterUIConfig] = useState({} as ClusterUIConfig); diff --git a/frontend/src/contexts/i18n-context.tsx b/frontend/src/contexts/i18n-context.tsx index bdee439144..8dc8fc5dcc 100644 --- a/frontend/src/contexts/i18n-context.tsx +++ b/frontend/src/contexts/i18n-context.tsx @@ -46,6 +46,15 @@ export function I18nProvider({ setLocaleState( (LANGUAGES_KEYS.includes(stored as Locale) ? stored : DEFAULT_LANGUAGE) as Locale ); + } else if (!stored) { + // First visit: the app is statically exported, so there is no + // request-time Accept-Language detection; use the browser language. + const browserLocale: Locale = navigator.language?.toLowerCase().includes('zh') + ? 'zh' + : DEFAULT_LANGUAGE; + if (browserLocale !== locale) { + setLocaleState(browserLocale); + } } } catch { // ignore diff --git a/frontend/src/lib/route-params.ts b/frontend/src/lib/route-params.ts new file mode 100644 index 0000000000..2dc2734f0f --- /dev/null +++ b/frontend/src/lib/route-params.ts @@ -0,0 +1,15 @@ +/** + * Decode a dynamic route segment read via useParams(). + * + * Server components receive params already decoded, but useParams() returns + * the raw URL segment. Decode it so client pages see the same value the old + * server pages did; fall back to the raw value on malformed input. + */ +export function decodeRouteParam(value: string | string[] | undefined): string { + const raw = Array.isArray(value) ? value[0] : (value ?? ''); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} diff --git a/setup.py b/setup.py index 2993b9e492..2f295d0ab8 100644 --- a/setup.py +++ b/setup.py @@ -74,11 +74,16 @@ class CustomSDist(ExtraCommandMixin, sdist): pass class BuildWeb(Command): - """build_web command""" + """build_web command + + Builds the Next.js static export under ``frontend/`` and stages it at + ``xinference/ui/web/dist`` so it ships inside the wheel/sdist and the + backend serves it without a Node runtime. + """ user_options = [] _web_src_path = "frontend" - _web_dest_path = "frontend/.next/standalone/server.js" + _web_dest_path = "xinference/ui/web/dist/index.html" _commands = [ ["npm", "ci"], ["npm", "run", "build"], @@ -120,9 +125,13 @@ def run(cls): cmd_errored = True break if not cmd_errored: + # `npm run build` stages the static export at + # xinference/ui/web/dist via its postbuild hook. assert os.path.exists(web_dest_path) +CustomInstall.register_pre_command("build_web") +CustomDevelop.register_pre_command("build_web") CustomSDist.register_pre_command("build_web") sys.path.append(repo_root) diff --git a/xinference/api/frontend_static.py b/xinference/api/frontend_static.py new file mode 100644 index 0000000000..0eb330da89 --- /dev/null +++ b/xinference/api/frontend_static.py @@ -0,0 +1,194 @@ +# Copyright 2022-2026 XProbe Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Serve the built frontend static export from the FastAPI backend. + +In the single-process (pip) deployment the web UI is shipped as a Next.js +static export bundled into the ``xinference`` package and served by this +backend, so no Node runtime is needed. When the export directory is absent +(e.g. a source checkout without a frontend build), the backend stays API-only. + +Next static export emits one HTML file per route. Dynamic routes +(``[modelType]``/``[modelUid]``/...) are emitted as a ``__shell__`` placeholder +file (see the server wrappers under ``frontend/src/app``); the matching client +page reads the real value from the URL. This module reconstructs the route +table from those files so an arbitrary URL resolves to the correct HTML shell. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles + +logger = logging.getLogger(__name__) + +_SHELL_TOKEN = "__shell__" +_SPA_ROUTE_NAME = "xinference_spa_fallback" + + +def _collect_backend_prefixes(app: FastAPI) -> frozenset[str]: + """Collect the literal first path segments owned by the backend. + + Derived from the registered route table rather than hand-maintained, so it + can't silently drift from the real routes. Any request whose first segment + is in this set but reached the SPA catch-all (i.e. matched no route) gets a + JSON 404 instead of the SPA shell. Must be called after all routers/mounts + are registered and before the catch-all is added (the catch-all's own + ``{full_path:path}`` segment is dynamic and therefore skipped here). + """ + prefixes: set[str] = set() + for route in app.routes: + path = getattr(route, "path", None) + if not path or not path.startswith("/"): + continue + first = path[1:].split("/", 1)[0] + # Skip empty (root) and dynamic segments like "{full_path:path}". + if not first or first.startswith("{"): + continue + prefixes.add(first) + return frozenset(prefixes) + + +def _build_shell_patterns(dist_dir: Path) -> list[tuple[re.Pattern[str], Path]]: + """Turn every ``__shell__`` export file into a (URL regex -> file) rule. + + ``running-model/__shell__.html`` -> ``^running-model/[^/]+$`` + ``register-model/__shell__/__shell__.html``-> ``^register-model/[^/]+/[^/]+$`` + """ + patterns: list[tuple[re.Pattern[str], Path]] = [] + for html in sorted(dist_dir.rglob("*.html")): + rel = html.relative_to(dist_dir).with_suffix("") # drop .html + segments = rel.as_posix().split("/") + if _SHELL_TOKEN not in segments: # placeholder may be a dir or the file + continue + regex = ["[^/]+" if seg == _SHELL_TOKEN else re.escape(seg) for seg in segments] + patterns.append((re.compile("^" + "/".join(regex) + "$"), html)) + return patterns + + +def ensure_spa_fallback_last(app: FastAPI) -> None: + """Move the SPA catch-all back to the end of the route table. + + Starlette matches routes in registration order, so anything mounted after + startup (e.g. per-model Gradio apps at ``/{model_uid}``) would be shadowed + by the catch-all. Call this after every runtime mount. No-op if the + frontend is not mounted. + """ + routes = app.router.routes + for i, route in enumerate(routes): + if getattr(route, "name", None) == _SPA_ROUTE_NAME: + routes.append(routes.pop(i)) + return + + +def mount_frontend(app: FastAPI, dist_dir: Path) -> bool: + """Serve the frontend static export from ``dist_dir`` on ``app``. + + Returns True if the frontend was mounted, False if the directory is absent + (in which case the backend stays API-only). Must be called after all API + routers are registered so the catch-all only receives unmatched paths. + """ + if not dist_dir.is_dir() or not (dist_dir / "index.html").is_file(): + logger.info( + "Frontend static export not found at %s; serving API only", dist_dir + ) + return False + + # Hashed build assets: let StaticFiles handle them directly. + next_assets = dist_dir / "_next" + if next_assets.is_dir(): + app.mount("/_next", StaticFiles(directory=str(next_assets)), name="next-assets") + + shell_patterns = _build_shell_patterns(dist_dir) + not_found = dist_dir / "404.html" + resolved_root = dist_dir.resolve() + backend_prefixes = _collect_backend_prefixes(app) + + def _resolve(rel_path: str) -> Path | None: + # Reject path traversal: the request path must stay within dist_dir. + try: + (resolved_root / rel_path).resolve().relative_to(resolved_root) + except (ValueError, RuntimeError): + return None + + # Exact asset (favicon, images, and static routes' own .html/.txt files). + candidate = dist_dir / rel_path + if rel_path and candidate.is_file(): + return candidate + + # Next prefetches a route's RSC flight payload at ".txt". Match it + # against the route table on the base path and serve the .txt variant, so + # dynamic-route prefetches get the shell's flight payload (not the HTML, + # which would break client-side soft navigation). + is_rsc = rel_path.endswith(".txt") + lookup = rel_path[:-4] if is_rsc else rel_path + ext = ".txt" if is_rsc else ".html" + + # Static route emitted as ".html" / ".txt". + static_file = dist_dir / f"{lookup}{ext}" + if lookup and static_file.is_file(): + return static_file + + # Directory index (HTML navigations only). + if not is_rsc: + index = candidate / "index.html" + if candidate.is_dir() and index.is_file(): + return index + + # Dynamic route -> shell, in the format the request asked for. + for pattern, target in shell_patterns: + if pattern.match(lookup): + if is_rsc: + txt = target.with_suffix(".txt") + return txt if txt.is_file() else target + return target + return None + + @app.get("/ui", include_in_schema=False) + @app.get("/ui/{legacy_path:path}", include_in_schema=False) + def redirect_legacy_ui(legacy_path: str = "") -> Response: + # The UI used to be served under /ui/; keep old bookmarks working. + return RedirectResponse(url="/") + + @app.get("/{full_path:path}", include_in_schema=False, name=_SPA_ROUTE_NAME) + async def serve_spa(full_path: str) -> Response: + if full_path == "": + return FileResponse(dist_dir / "index.html") + + first_segment = full_path.split("/", 1)[0] + if first_segment in backend_prefixes: + # Owned by the backend but unmatched above -> genuine 404. Mirror + # Starlette's default 404 body so API clients see a consistent shape + # whether or not the frontend is mounted. + return JSONResponse({"detail": "Not Found"}, status_code=404) + + resolved = _resolve(full_path) + if resolved is not None: + return FileResponse(resolved) + + if not_found.is_file(): + return FileResponse(not_found, status_code=404) + return JSONResponse({"detail": "Not Found"}, status_code=404) + + logger.info( + "Serving frontend static export from %s (%d dynamic-route shells)", + dist_dir, + len(shell_patterns), + ) + return True diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index b114b4c66a..4933c3c459 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -23,6 +23,7 @@ import time import uuid import warnings +from pathlib import Path from typing import Any, List, Optional, Union, get_type_hints import gradio as gr @@ -41,10 +42,9 @@ UploadFile, ) from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from PIL import Image from sse_starlette.sse import EventSourceResponse -from starlette.responses import PlainTextResponse, RedirectResponse +from starlette.responses import PlainTextResponse from uvicorn import Config, Server from xoscar.utils import get_next_port @@ -72,6 +72,7 @@ PeftModelConfig, max_tokens_field, ) +from .frontend_static import ensure_spa_fallback_last, mount_frontend from .oauth2.auth_service import AuthService from .responses import JSONResponse from .schemas import ( @@ -554,55 +555,26 @@ async def _start_cluster_metrics_updater(): f"{pprint.pformat(invalid_routes)}" ) - class SPAStaticFiles(StaticFiles): - async def get_response(self, path: str, scope): - response = await super().get_response(path, scope) - if response.status_code == 404: - response = await super().get_response(".", scope) - return response - try: package_file_path = __import__("xinference").__file__ assert package_file_path is not None lib_location = os.path.abspath(os.path.dirname(package_file_path)) - ui_location = os.path.join(lib_location, "ui/web/ui/build/") except ImportError as e: raise ImportError(f"Xinference is imported incorrectly: {e}") - frontend_endpoint = os.environ.get( - "XINFERENCE_FRONTEND_ENDPOINT", "http://127.0.0.1:3999" + ui_dist_location = os.environ.get( + "XINFERENCE_FRONTEND_DIST_DIR", + os.path.join(lib_location, "ui", "web", "dist"), ) - if frontend_endpoint: - frontend_endpoint = frontend_endpoint.rstrip("/") - - @self._app.get("/") - def read_main(): - response = RedirectResponse(url=frontend_endpoint) - return response - - @self._app.get("/ui/") - def read_ui(): - response = RedirectResponse(url=frontend_endpoint) - return response - - elif os.path.exists(ui_location): - - @self._app.get("/") - def read_main(): - response = RedirectResponse(url="/ui/") - return response - - self._app.mount( - "/ui/", - SPAStaticFiles(directory=ui_location, html=True), - ) - else: + if not mount_frontend(self._app, Path(ui_dist_location)): warnings.warn( f""" - The legacy Xinference ui is not built at expected directory: {ui_location} - The default frontend now runs as a separate Next.js application. - Start the backend with "xinference-local" and start the frontend from - the repository "frontend/" directory with "npm run dev". + The Xinference web UI is not built at expected directory: {ui_dist_location} + The API keeps serving without the web UI. To enable it, build the + frontend static export from the repository "frontend/" directory with + "npm ci && npm run build" (this stages the export at the directory + above), or set XINFERENCE_FRONTEND_DIST_DIR to an export directory, + and restart. For frontend development, run "npm run dev" instead. """ ) @@ -1091,6 +1063,7 @@ async def build_gradio_interface( access_token=access_token, ).build() gr.mount_gradio_app(self._app, interface, f"/{model_uid}") + ensure_spa_fallback_last(self._app) except ValueError as ve: logger.error(str(ve), exc_info=True) raise HTTPException(status_code=400, detail=str(ve)) @@ -1131,6 +1104,7 @@ async def build_gradio_media_interface( ).build() gr.mount_gradio_app(self._app, interface, f"/{model_uid}") + ensure_spa_fallback_last(self._app) except ValueError as ve: logger.error(str(ve), exc_info=True) raise HTTPException(status_code=400, detail=str(ve)) @@ -1172,6 +1146,7 @@ async def build_gradio_embedding_interface( ).build() gr.mount_gradio_app(self._app, interface, f"/{model_uid}") + ensure_spa_fallback_last(self._app) except ValueError as ve: logger.error(str(ve), exc_info=True) raise HTTPException(status_code=400, detail=str(ve)) diff --git a/xinference/api/tests/test_frontend_static.py b/xinference/api/tests/test_frontend_static.py new file mode 100644 index 0000000000..76de208b6f --- /dev/null +++ b/xinference/api/tests/test_frontend_static.py @@ -0,0 +1,248 @@ +# Copyright 2022-2026 XProbe Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for serving the frontend static export (``xinference.api.frontend_static``). + +Uses a synthetic export directory rather than a real Next.js build, so the +route-resolution logic can be exercised without a Node toolchain. +""" + +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from xinference.api.frontend_static import ( + _build_shell_patterns, + _collect_backend_prefixes, + ensure_spa_fallback_last, + mount_frontend, +) + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +@pytest.fixture +def dist_dir(tmp_path: Path) -> Path: + """A synthetic Next.js static export mirroring the real output shape.""" + d = tmp_path / "dist" + _write(d / "index.html", "index") + _write(d / "404.html", "not found") + # Static routes emit both an HTML file and an RSC .txt payload. + _write(d / "launch-model.html", "launch-model") + _write(d / "launch-model.txt", "RSC:launch-model") + _write(d / "login.html", "login") + _write(d / "login.txt", "RSC:login") + # Single-segment dynamic route shell. + _write(d / "running-model" / "__shell__.html", "running-shell") + _write(d / "running-model" / "__shell__.txt", "RSC:running-shell") + # Nested double-dynamic route shell (register-model/[modelType]/[modelName]). + _write( + d / "register-model" / "__shell__" / "__shell__.html", + "register-edit-shell", + ) + _write( + d / "register-model" / "__shell__" / "__shell__.txt", + "RSC:register-edit-shell", + ) + # Hashed build asset. + _write(d / "_next" / "static" / "chunks" / "main.js", "console.log(1)") + _write(d / "favicon.ico", "icon-bytes") + return d + + +def _app_with_backend_routes(dist: Path) -> FastAPI: + """An app whose backend routes are registered before the frontend mount.""" + app = FastAPI() + + @app.get("/v1/models/{model_uid}") + async def _models(model_uid: str): # pragma: no cover - trivial + return JSONResponse({"uid": model_uid}) + + @app.post("/token") + async def _token(): # pragma: no cover - trivial + return JSONResponse({"token": "t"}) + + @app.get("/status") + async def _status(): # pragma: no cover - trivial + return JSONResponse({"ok": True}) + + mounted = mount_frontend(app, dist) + assert mounted is True + return app + + +class TestBuildShellPatterns: + def test_maps_single_and_double_dynamic_shells(self, dist_dir: Path): + patterns = _build_shell_patterns(dist_dir) + assert len(patterns) == 2 + assert any(p.match("running-model/uid-1") for p, _ in patterns) + assert any(p.match("register-model/llm/my-model") for p, _ in patterns) + # Static routes must not produce shell patterns. + assert not any(p.match("launch-model") for p, _ in patterns) + + def test_matches_ids_but_not_extra_segments(self, dist_dir: Path): + patterns = _build_shell_patterns(dist_dir) + running = next(p for p, _ in patterns if p.match("running-model/some-uid-1234")) + assert not running.match("running-model/uid/extra") + assert not running.match("running-model") + + +class TestCollectBackendPrefixes: + def test_derives_prefixes_from_route_table(self, dist_dir: Path): + app = _app_with_backend_routes(dist_dir) + prefixes = _collect_backend_prefixes(app) + assert {"v1", "token", "status"} <= prefixes + + def test_skips_dynamic_first_segments(self, dist_dir: Path): + app = FastAPI() + + @app.get("/{full_path:path}") + async def _catchall(full_path: str): # pragma: no cover - trivial + return JSONResponse({}) + + prefixes = _collect_backend_prefixes(app) + # The catch-all's dynamic first segment must be skipped; only FastAPI's + # built-in literal routes (/docs, /redoc, /openapi.json) remain. + assert not any(p.startswith("{") for p in prefixes) + assert prefixes == frozenset({"docs", "redoc", "openapi.json"}) + + +class TestMountFrontend: + def test_not_mounted_when_dist_missing(self, tmp_path: Path): + app = FastAPI() + assert mount_frontend(app, tmp_path / "nope") is False + + def test_not_mounted_without_index_html(self, tmp_path: Path): + d = tmp_path / "dist" + d.mkdir() + app = FastAPI() + assert mount_frontend(app, d) is False + + +class TestSpaRouting: + def test_root_serves_index(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/") + assert resp.status_code == 200 + assert "index" in resp.text + + def test_static_route_html(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/launch-model") + assert resp.status_code == 200 + assert "launch-model" in resp.text + + def test_static_route_rsc_payload(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/launch-model.txt") + assert resp.status_code == 200 + assert resp.text == "RSC:launch-model" + + def test_dynamic_route_serves_shell(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/running-model/my-model-uid") + assert resp.status_code == 200 + assert "running-shell" in resp.text + + def test_double_dynamic_route_serves_shell(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/register-model/llm/my-custom-model") + assert resp.status_code == 200 + assert "register-edit-shell" in resp.text + + def test_dynamic_route_rsc_payload(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/running-model/my-model-uid.txt") + assert resp.status_code == 200 + assert resp.text == "RSC:running-shell" + + def test_exact_asset(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/favicon.ico") + assert resp.status_code == 200 + assert resp.text == "icon-bytes" + + def test_next_assets_mounted(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/_next/static/chunks/main.js") + assert resp.status_code == 200 + + def test_api_route_still_wins(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/v1/models/abc") + assert resp.status_code == 200 + assert resp.json() == {"uid": "abc"} + + def test_unmatched_backend_prefix_is_json_404(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/v1/does-not-exist") + assert resp.status_code == 404 + assert resp.json() == {"detail": "Not Found"} + + def test_unknown_page_serves_404_html(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/definitely/not/a/route") + assert resp.status_code == 404 + assert "not found" in resp.text + + def test_legacy_ui_path_redirects_to_root(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/ui/", follow_redirects=False) + assert resp.status_code in (302, 307) + assert resp.headers["location"] == "/" + resp = client.get("/ui", follow_redirects=False) + assert resp.status_code in (302, 307) + + def test_path_traversal_rejected(self, dist_dir: Path): + # Write a file outside the dist dir that must never be reachable. + secret = dist_dir.parent / "secret.txt" + secret.write_text("top-secret", encoding="utf-8") + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/%2e%2e/secret.txt") + assert resp.status_code == 404 + assert "top-secret" not in resp.text + + +class TestEnsureSpaFallbackLast: + def test_runtime_mount_not_shadowed_after_reorder(self, dist_dir: Path): + app = _app_with_backend_routes(dist_dir) + + # Simulate a runtime mount (e.g. a per-model Gradio app). + sub = FastAPI() + + @sub.get("/") + async def _sub_root(): # pragma: no cover - trivial + return JSONResponse({"gradio": True}) + + app.mount("/my-model-uid", sub) + + client = TestClient(app) + # Mounted after the catch-all: shadowed until reordered. + resp = client.get("/my-model-uid/") + assert resp.status_code == 404 + + ensure_spa_fallback_last(app) + resp = client.get("/my-model-uid/") + assert resp.status_code == 200 + assert resp.json() == {"gradio": True} + + def test_noop_when_frontend_not_mounted(self): + app = FastAPI() + ensure_spa_fallback_last(app) # must not raise diff --git a/xinference/api/tests/test_frontend_static_real_export.py b/xinference/api/tests/test_frontend_static_real_export.py new file mode 100644 index 0000000000..ff854c998d --- /dev/null +++ b/xinference/api/tests/test_frontend_static_real_export.py @@ -0,0 +1,132 @@ +# Copyright 2022-2026 XProbe Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests: SPA routing against a real Next.js static export. + +Skipped unless the export has been built. CI builds it in the lint job +(``cd frontend && npm run build``) and runs this module against +``frontend/out``; locally build the export the same way first, or point +``XINFERENCE_TEST_FRONTEND_DIST`` at an export directory. +""" + +import os +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from xinference.api.frontend_static import mount_frontend + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_DIST = Path( + os.environ.get( + "XINFERENCE_TEST_FRONTEND_DIST", str(_REPO_ROOT / "frontend" / "out") + ) +) + +pytestmark = pytest.mark.skipif( + not (_DIST / "index.html").is_file(), + reason="frontend static export not built (run `npm run build` under frontend/)", +) + + +@pytest.fixture(scope="module") +def client(): + app = FastAPI() + + @app.get("/v1/cluster/auth") + async def _cluster_auth(): + return JSONResponse({"auth": False}) + + assert mount_frontend(app, _DIST) is True + with TestClient(app) as c: + yield c + + +def _assert_html(resp): + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + +def test_root_serves_index(client): + _assert_html(client.get("/")) + + +@pytest.mark.parametrize( + "path", + [ + "/launch-model", + "/running-model", + "/register-model", + "/cluster-information", + "/login", + ], +) +def test_static_routes(client, path): + _assert_html(client.get(path)) + + +@pytest.mark.parametrize( + "path", + [ + # launch-model/[modelType] + "/launch-model/llm", + # register-model/[modelType] + "/register-model/llm", + # register-model/[modelType]/[modelName] + "/register-model/llm/my-custom-model", + # running-model/[modelUid] + "/running-model/qwen3-0", + ], +) +def test_dynamic_routes_serve_shell(client, path): + _assert_html(client.get(path)) + + +def test_rsc_prefetch_payload(client): + resp = client.get("/launch-model.txt") + assert resp.status_code == 200 + + +def test_next_assets_served(client): + chunks = list((_DIST / "_next").rglob("*.js")) + assert chunks, "export has no _next JS chunks" + rel = chunks[0].relative_to(_DIST).as_posix() + resp = client.get(f"/{rel}") + assert resp.status_code == 200 + + +def test_api_route_precedence(client): + resp = client.get("/v1/cluster/auth") + assert resp.status_code == 200 + assert resp.json() == {"auth": False} + + +def test_unmatched_api_prefix_is_json_404(client): + resp = client.get("/v1/definitely-not-a-route") + assert resp.status_code == 404 + assert resp.json() == {"detail": "Not Found"} + + +def test_unknown_page_is_404(client): + resp = client.get("/definitely/not/a/page") + assert resp.status_code == 404 + + +def test_legacy_ui_redirects(client): + resp = client.get("/ui/", follow_redirects=False) + assert resp.status_code in (302, 307) + assert resp.headers["location"] == "/" From dfc18ebb85002e7b3c2e82c5c7764e04686d55b3 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Wed, 8 Jul 2026 16:47:13 +0800 Subject: [PATCH 7/7] Address gemini-code-assist review feedback - frontend_static: strip trailing slash before route lookup so /running-model// and /launch-model/ resolve like their no-slash form instead of 404ing. - i18n-context: guard the browser-language fallback with a typeof navigator check instead of relying on the try/catch. The other gemini comments (Switch onChange, frontend_endpoint default, CustomInstall/CustomDevelop build_web hooks, MANIFEST.in artifacts, trailing-slash stripping in next.config/getApiUrl) target the pre-rework code and no longer apply. --- frontend/src/contexts/i18n-context.tsx | 7 ++++--- xinference/api/frontend_static.py | 7 +++++++ xinference/api/tests/test_frontend_static.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/src/contexts/i18n-context.tsx b/frontend/src/contexts/i18n-context.tsx index 8dc8fc5dcc..eb4023e883 100644 --- a/frontend/src/contexts/i18n-context.tsx +++ b/frontend/src/contexts/i18n-context.tsx @@ -49,9 +49,10 @@ export function I18nProvider({ } else if (!stored) { // First visit: the app is statically exported, so there is no // request-time Accept-Language detection; use the browser language. - const browserLocale: Locale = navigator.language?.toLowerCase().includes('zh') - ? 'zh' - : DEFAULT_LANGUAGE; + const browserLocale: Locale = + typeof navigator !== 'undefined' && navigator.language?.toLowerCase().includes('zh') + ? 'zh' + : DEFAULT_LANGUAGE; if (browserLocale !== locale) { setLocaleState(browserLocale); } diff --git a/xinference/api/frontend_static.py b/xinference/api/frontend_static.py index 0eb330da89..ab46dc15fc 100644 --- a/xinference/api/frontend_static.py +++ b/xinference/api/frontend_static.py @@ -132,6 +132,13 @@ def _resolve(rel_path: str) -> Path | None: if rel_path and candidate.is_file(): return candidate + # A trailing slash (e.g. "running-model/uid1/") would otherwise miss + # every lookup below, since none of the emitted file/shell names end + # in "/". Strip it before continuing; the root path is already + # handled by the caller. + rel_path = rel_path.rstrip("/") + candidate = dist_dir / rel_path + # Next prefetches a route's RSC flight payload at ".txt". Match it # against the route table on the base path and serve the .txt variant, so # dynamic-route prefetches get the shell's flight payload (not the HTML, diff --git a/xinference/api/tests/test_frontend_static.py b/xinference/api/tests/test_frontend_static.py index 76de208b6f..ab3acc3f9c 100644 --- a/xinference/api/tests/test_frontend_static.py +++ b/xinference/api/tests/test_frontend_static.py @@ -155,12 +155,24 @@ def test_static_route_rsc_payload(self, dist_dir: Path): assert resp.status_code == 200 assert resp.text == "RSC:launch-model" + def test_static_route_with_trailing_slash(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/launch-model/") + assert resp.status_code == 200 + assert "launch-model" in resp.text + def test_dynamic_route_serves_shell(self, dist_dir: Path): client = TestClient(_app_with_backend_routes(dist_dir)) resp = client.get("/running-model/my-model-uid") assert resp.status_code == 200 assert "running-shell" in resp.text + def test_dynamic_route_with_trailing_slash(self, dist_dir: Path): + client = TestClient(_app_with_backend_routes(dist_dir)) + resp = client.get("/running-model/my-model-uid/") + assert resp.status_code == 200 + assert "running-shell" in resp.text + def test_double_dynamic_route_serves_shell(self, dist_dir: Path): client = TestClient(_app_with_backend_routes(dist_dir)) resp = client.get("/register-model/llm/my-custom-model")