diff --git a/.dockerignore b/.dockerignore index a236b55..8fd302d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,18 +11,18 @@ __pycache__/ # Generated code — regenerated inside the image by `rbt # generate`. backend/api/ -web/api/ +frontend/api/ -# Node sources / deps / caches — we only copy `web/dist/` +# Node sources / deps / caches — we only copy `frontend/dist/` # into the image. -web/node_modules/ -web/ui/ -web/src/ -web/package.json -web/package-lock.json -web/tsconfig*.json -web/vite.config.ts -web/index.css +frontend/node_modules/ +frontend/mcp/ +frontend/src/ +frontend/package.json +frontend/package-lock.json +frontend/tsconfig*.json +frontend/vite.config.ts +frontend/index.css # VCS. .git/ diff --git a/.gitignore b/.gitignore index ffb85d3..f730f86 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ __pycache__/ # Generated API code (from `rbt generate`). backend/api/ -web/api/ +frontend/api/ # Reboot runtime state. .rbt/ @@ -21,8 +21,8 @@ web/api/ .pytest_cache/ # Node / web build artifacts. -web/node_modules/ -web/dist/ +frontend/node_modules/ +frontend/dist/ # IDE / editor. .idea/ diff --git a/.rbtrc b/.rbtrc index 205eb8f..890ac61 100644 --- a/.rbtrc +++ b/.rbtrc @@ -4,12 +4,12 @@ generate api/ # Tell `rbt generate` where to put generated files. generate --python=backend/api/ -# Generate React bindings for web apps (into "web/api/"). -generate --react=web/api -generate --react-extensions +# Generate React bindings for web apps (into "frontend/api/"). +generate --react=frontend/api # Watch if any source files are modified. dev run --watch=backend/**/*.py +dev run --watch=frontend/dist/**/*.html # Tell `rbt` that this is a Python application. dev run --python @@ -24,23 +24,28 @@ dev run --application=backend/src/main.py dev run --default-config=hmr # Hot Module Replacement (HMR): Vite dev server proxied through Envoy. -# Run Vite in a separate terminal: cd web && npm run dev -# Envoy routes "/__/web/**" to Vite for HMR support. -dev run:hmr --mcp-frontend-host=http://localhost:4444 +# Run Vite in a separate terminal: cd frontend && npm run dev +# Envoy routes "/__/frontend/**" to Vite for HMR support. +dev run --frontend-root-path=frontend -# Dist mode: serve pre-built artifacts from "web/dist/" (no Vite HMR). +dev run:hmr --frontend-host=http://localhost:4444 + +# Dist mode: serve pre-built artifacts from "frontend/dist/" (no Vite HMR). # Usage: uv run rbt dev run --config=dist -# Requires: cd web && npm run build -dev run:dist --mcp-frontend-host="" +# Requires: cd frontend && npm run build +dev run:dist --frontend-dist-path=frontend/dist # When expunging, expunge that state we've saved. dev expunge --application-name=agent-wiki # Production (`rbt serve run`) settings, used by the Dockerfile -# when deploying to the Reboot Cloud. In production `web/dist/` -# is served from disk, so no `--mcp-frontend-host` is needed -# (that flag is dev-only anyway). +# when deploying to the Reboot Cloud. In production the built +# `frontend/dist/` is served on disk by the static-file server +# (`--frontend-dist-path`); the `--frontend-host` Vite dev server +# is dev-only. serve run --python +serve run --frontend-dist-path=frontend/dist +serve run --frontend-root-path=frontend serve run --application-name=agent-wiki serve run --application=backend/src/main.py diff --git a/BUILD.bazel b/BUILD.bazel index 3afcc40..624740a 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -15,9 +15,9 @@ filegroup( ".pytest_cache/**/*", ".mypy_cache/**/*", "backend/api/**/*", - "web/api/**/*", - "web/node_modules/**/*", - "web/dist/**/*", + "frontend/api/**/*", + "frontend/node_modules/**/*", + "frontend/dist/**/*", ], ), visibility = ["//visibility:public"], diff --git a/Dockerfile b/Dockerfile index 442307c..7af8876 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ # Dockerfile for deploying agent-wiki to Reboot Cloud. # -# Prerequisite: run `cd web && npm install && npm run build` -# locally before `docker build` so that `web/dist/` contains the -# bundled UIs. This image copies that prebuilt bundle rather +# Prerequisite: run `cd frontend && npm install && npm run build` +# locally before `docker build` so that `frontend/dist/` contains +# the bundled UIs. This image copies that prebuilt bundle rather # than installing Node and rebuilding it here. -FROM ghcr.io/reboot-dev/reboot-base:1.2.1 +FROM ghcr.io/reboot-dev/reboot-base:1.3.0 WORKDIR /app @@ -32,7 +32,7 @@ RUN rbt generate COPY backend/src/ backend/src/ # Copy the prebuilt web bundle. -COPY web/dist/ web/dist/ +COPY frontend/dist/ frontend/dist/ # Make the Pydantic API definitions in `api/` and the generated # Reboot bindings in `backend/api/` both importable. They share diff --git a/README.md b/README.md index c68e8c4..3ff649d 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,13 @@ for manual fix-ups). uv sync # Install web dependencies. -cd web && npm install && cd .. +cd frontend && npm install && cd .. # Generate API code (Python + React bindings). uv run rbt generate # Build the React UIs. -cd web && npm run build && cd .. +cd frontend && npm run build && cd .. ``` The librarian runs on Anthropic's Claude via Pydantic AI, so @@ -114,7 +114,7 @@ project directory): uv run rbt dev run # Terminal 2: start the Vite dev server for Hot Module Replacement. -cd web && npm run dev +cd frontend && npm run dev ``` Then open and follow the setup wizard to @@ -184,9 +184,9 @@ agent-wiki/ │ ├── main.py # Application entrypoint. │ └── servicers/wiki.py # User/Wiki/Page/Transcript servicers │ # plus the Pydantic AI librarian agent. -└── web/ +└── frontend/ ├── api/ # Generated React bindings. - └── ui/ + └── mcp/ ├── wiki_view/ # Renders a Wiki's markdown body. ├── page_view/ # Renders a Page's markdown body. ├── transcript_view/ # Renders a raw Transcript. diff --git a/api/agent_wiki/v1/wiki.py b/api/agent_wiki/v1/wiki.py index 262114d..1ab7bd9 100644 --- a/api/agent_wiki/v1/wiki.py +++ b/api/agent_wiki/v1/wiki.py @@ -196,7 +196,7 @@ class TranscriptUpdateRequest(Model): methods=Methods( show_wiki=UI( request=None, - path="web/ui/wiki_view", + path="frontend/mcp/wiki_view", title="Wiki", description="Open the Wiki viewer UI to " "render the Wiki's markdown body.", @@ -285,7 +285,7 @@ class TranscriptUpdateRequest(Model): methods=Methods( show_page=UI( request=None, - path="web/ui/page_view", + path="frontend/mcp/page_view", title="Page", description="Open the Page viewer UI to " "render the Page's markdown body.", @@ -333,7 +333,7 @@ class TranscriptUpdateRequest(Model): methods=Methods( show_transcript=UI( request=None, - path="web/ui/transcript_view", + path="frontend/mcp/transcript_view", title="Transcript", description="Open the Transcript viewer " "UI to read the raw conversation " diff --git a/backend/tests/wiki_test.py b/backend/tests/wiki_test.py index 89655be..89f8edd 100644 --- a/backend/tests/wiki_test.py +++ b/backend/tests/wiki_test.py @@ -19,8 +19,7 @@ ) from pydantic_ai.models.function import AgentInfo, FunctionModel from reboot.aio.applications import Application -from reboot.aio.auth.oauth_providers import Anonymous -from reboot.aio.tests import OAuthProviderForTest, Reboot +from reboot.aio.tests import Reboot from servicers import wiki as wiki_module from servicers.wiki import ( PageServicer, @@ -89,15 +88,12 @@ async def asyncSetUp(self) -> None: await self.rbt.up( Application( servicers=APPLICATION_SERVICERS, - oauth=OAuthProviderForTest(Anonymous()), ), ) self.user_id = "alice" - self.context = self.rbt.create_external_context( + self.context = await self.rbt.create_external_context_as( name=f"test-{self.id()}", - bearer_token=self.rbt.make_valid_oauth_access_token( - user_id=self.user_id, - ), + user_id=self.user_id, ) # `User` is an auto-constructed state type: in # production the MCP session's "new session" hook diff --git a/frontend/.tests/type_check.sh b/frontend/.tests/type_check.sh new file mode 100755 index 0000000..bc07c03 --- /dev/null +++ b/frontend/.tests/type_check.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# Type-checks this example's frontend end-to-end: it generates the +# typed Reboot clients from the example's own `.rbtrc` using the +# `rbt` CLI, installs the frontend's JavaScript dependencies +# overlaid with the locally built Reboot npm packages, and runs +# `tsc` over the frontend project. This applies the checks a +# developer's editor and the Vite toolchain apply (`strict`, +# `verbatimModuleSyntax`, ...), which the esbuild-based bundling +# steps skip. + +set -e # Exit if a command exits with an error. +set -u # Treat expanding an unset variable as an error. +set -x # Echo executed commands to help debug failures. + +# The harness copies the whole example into a sandbox and runs us +# from its root, where the top-level `.rbtrc`, `api/`, and +# `pyproject.toml` live. We generate from here and only +# `cd frontend` later, for the type check. +ls -l .rbtrc pyproject.toml frontend/tsconfig.json 2> /dev/null > /dev/null || { + echo "ERROR: could not find the example's files. Invoke this from" + echo "the example's root. Current working directory is '$(pwd)'." + exit 1 +} + +# Set up the Python environment: the example is a Python project, so +# we generate with the Python `rbt`, which reads the example's own +# `.rbtrc` and emits the Python and React clients in one pass — the +# React one into `frontend/api/`. In a Bazel test we install the +# locally built wheel over the pinned version. +if [[ -n "${REBOOT_WHL_FILE:-}" ]]; then + uv add --no-sync "${SANDBOX_ROOT}${REBOOT_WHL_FILE}" +fi + +# Force a fresh virtualenv. A pre-existing `.venv/` has its original +# creation path baked into its `activate` script and console-script +# shebangs, which breaks them when the venv is used from a different +# location; `uv sync` alone can't repair a relocated venv. Nuking +# and re-syncing guarantees the venv lives at the current path. +rm -rf .venv +uv sync +source .venv/bin/activate + +# When running in a Bazel test, our `.rbtrc` file ends up in a very +# deep directory structure, which can result in "path too long" +# errors from RocksDB. Explicitly specify a shorter path. +RBT_FLAGS="--state-directory=$(mktemp -d)" + +# Generate the clients from the example's `.rbtrc`. The frontend +# imports the generated `frontend/api/` modules, so this must run +# before the type check. +rbt $RBT_FLAGS generate + +# Install the frontend's JavaScript dependencies. In a Bazel test we +# overlay the locally built Reboot npm packages with `--no-save` so +# the check exercises the in-repo client rather than a published +# release; `npm install` still resolves the rest (React, Vite, ...) +# from `package.json`. +cd frontend +if [[ -n "${REBOOT_NPM_PACKAGE:-}" ]]; then + npm install --no-save \ + "${SANDBOX_ROOT}${REBOOT_NPM_PACKAGE}" \ + "${SANDBOX_ROOT}${REBOOT_API_NPM_PACKAGE}" \ + "${SANDBOX_ROOT}${REBOOT_WEB_NPM_PACKAGE}" \ + "${SANDBOX_ROOT}${REBOOT_REACT_NPM_PACKAGE}" +else + npm install +fi + +# Run the frontend's real build command, so we exercise the exact +# command a developer runs. +npm run build diff --git a/frontend/build.mjs b/frontend/build.mjs new file mode 100644 index 0000000..9e5076d --- /dev/null +++ b/frontend/build.mjs @@ -0,0 +1,39 @@ +// Builds every Reboot UI in this frontend in one shot, so +// `npm run build` keeps working as UIs are added or removed. +// +// `vite.config.ts` knows how to build a *single* target, selected by +// the `RBT_BUILD_TARGET` env var (`mcp:` for one MCP UI, or +// `web` for the SPA). This script is the discover-and-loop around it: +// it finds every `mcp//index.html`, builds each into +// `dist/mcp//index.html`, then builds the `web/` SPA into +// `dist/web/` if a `web/index.html` exists. +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { build } from "vite"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Auto-discover MCP UIs: every `mcp//` with an `index.html`. +const mcpDir = path.resolve(__dirname, "mcp"); +const mcpNames = fs.existsSync(mcpDir) + ? fs + .readdirSync(mcpDir) + .filter((name) => fs.existsSync(path.resolve(mcpDir, name, "index.html"))) + : []; + +// Build each MCP UI. `vite.config.ts` reads the target from +// `RBT_BUILD_TARGET`; we leave Vite's `mode` at its build default +// (`production`) so a `web/.env.production` would be honored. +for (const name of mcpNames) { + console.log(`Building mcp/${name} ...`); + process.env.RBT_BUILD_TARGET = `mcp:${name}`; + await build(); +} + +// Build the standalone `web/` SPA if it exists. +if (fs.existsSync(path.resolve(__dirname, "web", "index.html"))) { + console.log("Building web ..."); + process.env.RBT_BUILD_TARGET = "web"; + await build(); +} diff --git a/web/index.css b/frontend/index.css similarity index 100% rename from web/index.css rename to frontend/index.css diff --git a/web/ui/_shared/Markdown.module.css b/frontend/mcp/_shared/Markdown.module.css similarity index 100% rename from web/ui/_shared/Markdown.module.css rename to frontend/mcp/_shared/Markdown.module.css diff --git a/web/ui/_shared/Markdown.tsx b/frontend/mcp/_shared/Markdown.tsx similarity index 100% rename from web/ui/_shared/Markdown.tsx rename to frontend/mcp/_shared/Markdown.tsx diff --git a/web/ui/page_view/App.module.css b/frontend/mcp/page_view/App.module.css similarity index 100% rename from web/ui/page_view/App.module.css rename to frontend/mcp/page_view/App.module.css diff --git a/web/ui/page_view/App.tsx b/frontend/mcp/page_view/App.tsx similarity index 55% rename from web/ui/page_view/App.tsx rename to frontend/mcp/page_view/App.tsx index 47a7293..8d9aba2 100644 --- a/web/ui/page_view/App.tsx +++ b/frontend/mcp/page_view/App.tsx @@ -1,10 +1,32 @@ import { type FC } from "react"; -import { usePage } from "@api/agent_wiki/v1/wiki_rbt_react"; +import { type UsePageApi, usePage } from "@api/agent_wiki/v1/wiki_rbt_react"; import { Markdown } from "../_shared/Markdown"; import css from "./App.module.css"; export const PageView: FC = () => { - const page = usePage(); + const { page, isLoading } = usePage(); + + if (isLoading) { + return ( +
+
loading...
+
+ ); + } + + if (page === undefined) { + console.error("No default Page id was available; cannot render."); + return ( +
+
An error occurred, sorry about that!
+
+ ); + } + + return ; +}; + +const Page: FC<{ page: UsePageApi }> = ({ page }) => { const { response, isLoading } = page.useGet(); if (isLoading && response === undefined) { diff --git a/web/ui/page_view/index.html b/frontend/mcp/page_view/index.html similarity index 100% rename from web/ui/page_view/index.html rename to frontend/mcp/page_view/index.html diff --git a/web/ui/page_view/main.tsx b/frontend/mcp/page_view/main.tsx similarity index 100% rename from web/ui/page_view/main.tsx rename to frontend/mcp/page_view/main.tsx diff --git a/web/ui/transcript_view/App.module.css b/frontend/mcp/transcript_view/App.module.css similarity index 100% rename from web/ui/transcript_view/App.module.css rename to frontend/mcp/transcript_view/App.module.css diff --git a/web/ui/transcript_view/App.tsx b/frontend/mcp/transcript_view/App.tsx similarity index 59% rename from web/ui/transcript_view/App.tsx rename to frontend/mcp/transcript_view/App.tsx index ee54c88..d9da069 100644 --- a/web/ui/transcript_view/App.tsx +++ b/frontend/mcp/transcript_view/App.tsx @@ -1,9 +1,34 @@ import { type FC } from "react"; -import { useTranscript } from "@api/agent_wiki/v1/wiki_rbt_react"; +import { + type UseTranscriptApi, + useTranscript, +} from "@api/agent_wiki/v1/wiki_rbt_react"; import css from "./App.module.css"; export const TranscriptView: FC = () => { - const transcript = useTranscript(); + const { transcript, isLoading } = useTranscript(); + + if (isLoading) { + return ( +
+
loading...
+
+ ); + } + + if (transcript === undefined) { + console.error("No default Transcript id was available; cannot render."); + return ( +
+
An error occurred, sorry about that!
+
+ ); + } + + return ; +}; + +const Transcript: FC<{ transcript: UseTranscriptApi }> = ({ transcript }) => { const { response, isLoading } = transcript.useGet(); if (isLoading && response === undefined) { diff --git a/web/ui/transcript_view/index.html b/frontend/mcp/transcript_view/index.html similarity index 100% rename from web/ui/transcript_view/index.html rename to frontend/mcp/transcript_view/index.html diff --git a/web/ui/transcript_view/main.tsx b/frontend/mcp/transcript_view/main.tsx similarity index 100% rename from web/ui/transcript_view/main.tsx rename to frontend/mcp/transcript_view/main.tsx diff --git a/web/ui/wiki_view/App.module.css b/frontend/mcp/wiki_view/App.module.css similarity index 100% rename from web/ui/wiki_view/App.module.css rename to frontend/mcp/wiki_view/App.module.css diff --git a/web/ui/wiki_view/App.tsx b/frontend/mcp/wiki_view/App.tsx similarity index 59% rename from web/ui/wiki_view/App.tsx rename to frontend/mcp/wiki_view/App.tsx index b4227c8..61295f8 100644 --- a/web/ui/wiki_view/App.tsx +++ b/frontend/mcp/wiki_view/App.tsx @@ -1,10 +1,32 @@ import { type FC } from "react"; -import { useWiki } from "@api/agent_wiki/v1/wiki_rbt_react"; +import { type UseWikiApi, useWiki } from "@api/agent_wiki/v1/wiki_rbt_react"; import { Markdown } from "../_shared/Markdown"; import css from "./App.module.css"; export const WikiView: FC = () => { - const wiki = useWiki(); + const { wiki, isLoading } = useWiki(); + + if (isLoading) { + return ( +
+
loading...
+
+ ); + } + + if (wiki === undefined) { + console.error("No default Wiki id was available; cannot render."); + return ( +
+
An error occurred, sorry about that!
+
+ ); + } + + return ; +}; + +const Wiki: FC<{ wiki: UseWikiApi }> = ({ wiki }) => { const { response, isLoading } = wiki.useGet(); if (isLoading && response === undefined) { diff --git a/web/ui/wiki_view/index.html b/frontend/mcp/wiki_view/index.html similarity index 100% rename from web/ui/wiki_view/index.html rename to frontend/mcp/wiki_view/index.html diff --git a/web/ui/wiki_view/main.tsx b/frontend/mcp/wiki_view/main.tsx similarity index 100% rename from web/ui/wiki_view/main.tsx rename to frontend/mcp/wiki_view/main.tsx diff --git a/web/package-lock.json b/frontend/package-lock.json similarity index 93% rename from web/package-lock.json rename to frontend/package-lock.json index aaa47c6..a29a2f3 100644 --- a/web/package-lock.json +++ b/frontend/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@modelcontextprotocol/ext-apps": "1.5.0", "@modelcontextprotocol/sdk": "1.29.0", - "@reboot-dev/reboot-api": "1.2.1", - "@reboot-dev/reboot-react": "1.2.1", + "@reboot-dev/reboot-api": "1.3.0", + "@reboot-dev/reboot-react": "1.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", @@ -19,10 +19,10 @@ "zod": "^4.0.0" }, "devDependencies": { + "@types/node": "^20.11.5", "@types/react": "^18.2.67", "@types/react-dom": "^18.2.22", "@vitejs/plugin-react": "^4.7.0", - "concurrently": "^9.1.2", "typescript": "^5.9.2", "vite": "^6.3.5", "vite-plugin-singlefile": "^2.0.3" @@ -888,9 +888,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-1.2.1.tgz", - "integrity": "sha512-CmrL7P/dtm+DBMWLWijs+NIlt0PKmdelwmP3Efmsz9x1dkICn6iq/MOh7FLVe2gcHubKL6RuiwO3vOOBVzObPg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-1.3.0.tgz", + "integrity": "sha512-LgkAGdIWT3sK80w27zSAU5OG2/Sf0t9lvcky5Bj8M0aRElqVhQGQZPma1Qk6vKVtvfZSCYRTqye7C4ULJeNExw==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -915,15 +915,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-1.2.1.tgz", - "integrity": "sha512-ImVCLLaOTZoDDtAnXdXufScZWOz2DVomVuhs6WwEgjW99yTtwTx1OMsC/wbU37pSmadmdfdlTYP9vy/V9EcFMg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-1.3.0.tgz", + "integrity": "sha512-6pgOoQQ5BSZ4d0GLkBZrTVBl/3UoDJASTobBMXs3x7WJNcbN9mMyLd2MEK590WA3X4hNDLKfLreJHJHM4lnvxg==", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/ext-apps": "1.5.0", "@modelcontextprotocol/sdk": "1.29.0", - "@reboot-dev/reboot-api": "1.2.1", - "@reboot-dev/reboot-web": "1.2.1", + "@reboot-dev/reboot-api": "1.3.0", + "@reboot-dev/reboot-web": "1.3.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -951,12 +951,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-1.2.1.tgz", - "integrity": "sha512-/jk3OdG6pF+McvIP6yEuJKSWmDpHSR4+nVdbvUIHDdJh8cQkFj2QnzQJ+pRbql207LvwUNJ3T9YwZDSYz8rB9A==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-1.3.0.tgz", + "integrity": "sha512-qxizCaX2fQ/idPPxrc1eWe8TJhjoyCLk69LKJUiNtFkfg0G+J3tUrOFse2Ysor/GxNg7UyK+SXUisAO6rxt6iQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "1.2.1", + "@reboot-dev/reboot-api": "1.3.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -1438,6 +1438,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1549,32 +1559,6 @@ } } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -1738,36 +1722,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -1808,41 +1762,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -1853,31 +1772,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2050,13 +1944,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -2399,16 +2286,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2458,16 +2335,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2651,16 +2518,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -4111,16 +3968,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4191,16 +4038,6 @@ "node": ">= 18" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4298,19 +4135,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4412,21 +4236,6 @@ "node": ">= 0.8" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -4441,19 +4250,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -4472,22 +4268,6 @@ "inline-style-parser": "0.2.7" } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -4527,16 +4307,6 @@ "node": ">=0.6" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -4591,6 +4361,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4880,40 +4657,12 @@ "node": ">= 8" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4921,35 +4670,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1114b85 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "agent-wiki-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && node build.mjs" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "1.5.0", + "@modelcontextprotocol/sdk": "1.29.0", + "@reboot-dev/reboot-api": "1.3.0", + "@reboot-dev/reboot-react": "1.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.11.5", + "@types/react": "^18.2.67", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.7.0", + "typescript": "^5.9.2", + "vite": "^6.3.5", + "vite-plugin-singlefile": "^2.0.3" + } +} diff --git a/web/tsconfig.app.json b/frontend/tsconfig.app.json similarity index 94% rename from web/tsconfig.app.json rename to frontend/tsconfig.app.json index 910e900..9faf713 100644 --- a/web/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -21,5 +21,5 @@ "@api/*": ["./api/*"] } }, - "include": ["ui"] + "include": ["mcp", "vite-env.d.ts"] } diff --git a/web/tsconfig.json b/frontend/tsconfig.json similarity index 100% rename from web/tsconfig.json rename to frontend/tsconfig.json diff --git a/web/tsconfig.node.json b/frontend/tsconfig.node.json similarity index 100% rename from web/tsconfig.node.json rename to frontend/tsconfig.node.json diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..824adf6 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,231 @@ +// Vite configuration for Reboot UIs. +// +// One config drives three jobs. The dev server is selected by +// `command === "serve"`; the two build shapes are selected by the +// `RBT_BUILD_TARGET` env var, set per UI by `build.mjs`. `mode` keeps +// its conventional Vite values — `development` (serve) / `production` +// (build) — so the matching `.env.` files load as usual: +// +// * `vite` (serve): a single dev server that delivers HMR for every +// `mcp/` UI AND the standalone `web/` SPA, all under +// `base: "/__/frontend/"`. Envoy proxies that prefix to this dev +// server (`run --config=hmr`). +// * `RBT_BUILD_TARGET=mcp: vite build`: builds one MCP UI into +// a single, self-contained `dist/mcp//index.html` (assets +// inlined via `vite-plugin-singlefile`). The framework serves it +// at `/__/frontend/mcp//index.html` in dist mode. +// * `RBT_BUILD_TARGET=web vite build`: builds the `web/` SPA into +// `dist/web/` with normal (non-inlined) assets. Its `base` is +// `/__/frontend/web/` so asset URLs resolve when served at that +// prefix. In Vite's `production` mode, it reads +// `web/.env.production`. +import fs from "fs"; +import path from "path"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type Plugin } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +// A served directory under `/__/frontend/` requested without its +// trailing slash (e.g. `/__/frontend/web`) doesn't match Vite's static +// index.html serving, which expects `/__/frontend/web/`, so it 404s. +// Redirect the slash-less form to the canonical trailing-slash form so +// the `web/` SPA and each `mcp/` UI load with or without the +// trailing slash — matching how the framework's dist-mode server +// behaves. Only a path that resolves to a real directory with an +// `index.html` is redirected, so Vite's own internal module URLs +// (`@vite/client`, `@react-refresh`) and source or asset files fall +// through untouched. +function redirectFrontendDirTrailingSlash(root: string): Plugin { + const prefix = "/__/frontend/"; + return { + name: "reboot-frontend-dir-trailing-slash", + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = req.url ?? ""; + const queryAt = url.indexOf("?"); + const pathname = queryAt === -1 ? url : url.slice(0, queryAt); + if (pathname.startsWith(prefix) && !pathname.endsWith("/")) { + const subpath = pathname.slice(prefix.length); + if (fs.existsSync(path.join(root, subpath, "index.html"))) { + const query = queryAt === -1 ? "" : url.slice(queryAt); + // 302 (not 301): a permanent redirect would be cached by the + // browser, which is wrong for a dev server whose routes can + // change between runs. + res.statusCode = 302; + res.setHeader("Location", `${pathname}/${query}`); + res.end(); + return; + } + } + next(); + }); + }, + }; +} + +// When a `web/` SPA exists, treat it as the dev server's home: the bare +// `/`, `/__/frontend`, and `/__/frontend/` otherwise 404 (there's no +// index there), so silently redirect them to `/__/frontend/web/`. Also +// print the friendlier `http:///` at startup instead of the +// `/__/frontend/` base URL Vite would otherwise show (which is a dead +// link). +function serveWebAppAtRoot(root: string): Plugin { + const webIndex = path.resolve(root, "web", "index.html"); + const target = "/__/frontend/web/"; + const homes = new Set(["/", "/__/frontend", "/__/frontend/"]); + return { + name: "reboot-serve-web-app-at-root", + configureServer(server) { + if (!fs.existsSync(webIndex)) return; + server.middlewares.use((req, res, next) => { + const url = req.url ?? ""; + const queryAt = url.indexOf("?"); + const pathname = queryAt === -1 ? url : url.slice(0, queryAt); + if (homes.has(pathname)) { + const query = queryAt === -1 ? "" : url.slice(queryAt); + res.statusCode = 302; + res.setHeader("Location", target + query); + res.end(); + return; + } + next(); + }); + // Print `http:///` instead of the `/__/frontend/` base. + const printUrls = server.printUrls.bind(server); + server.printUrls = () => { + const urls = server.resolvedUrls; + if (urls) { + const toRoot = (u: string) => u.replace(/\/__\/frontend\/$/, "/"); + urls.local = urls.local.map(toRoot); + urls.network = urls.network.map(toRoot); + } + printUrls(); + }; + }, + }; +} + +// Auto-discover MCP UIs: every `mcp//` with an `index.html`. +// There may be no `mcp/` directory at all (a web-only frontend, or +// one whose last MCP UI was removed), so guard the read. +const mcpDir = path.resolve(__dirname, "mcp"); +const mcpNames: string[] = fs.existsSync(mcpDir) + ? fs + .readdirSync(mcpDir) + .filter((name) => fs.existsSync(path.resolve(mcpDir, name, "index.html"))) + : []; + +// Path alias for API imports (`@api/...` -> `./api/...`). +const resolve = { + alias: { + "@api": path.resolve(__dirname, "./api"), + }, + dedupe: ["react", "react-dom", "zod"], +}; + +export default defineConfig(({ command }) => { + // Dev server: serves both the MCP UIs and the `web/` SPA. + // + // UIs use a double iframe architecture: + // MCP Host -> srcdoc (origin=null) -> iframe (origin=localhost:9991) + // + // The inner iframe loads from Envoy ("/__/frontend/**"), which + // proxies to Vite. Because the inner iframe has a real origin, + // Vite's URLs work normally. `base: "/__/frontend/"` ensures all + // paths route through Envoy. + // + // The standalone `web/` SPA is served at `/__/frontend/web/` by this + // same server. It reaches the backend via `VITE_REBOOT_URL` (see + // `web/.env.development`) rather than its own origin, so opening it + // straight from this dev server exercises the real cross-origin + // frontend/backend path — the same shape as a production deploy. + // + // Hot Module Replacement works automatically: Vite's client connects + // to the page's origin, and Envoy proxies WebSocket upgrades to + // Vite. This also works with tunnels (ngrok) since the tunnel + // points to Envoy. + if (command === "serve") { + const port = parseInt(process.env.RBT_VITE_PORT || "4444", 10); + + return { + plugins: [ + react(), + redirectFrontendDirTrailingSlash(__dirname), + serveWebAppAtRoot(__dirname), + ], + root: ".", + // Read `.env*` from `web/` (alongside the SPA), so the `web/` + // SPA's `VITE_REBOOT_URL` is picked up by both this serve and the + // `web/` production build, which is also rooted there. + envDir: path.resolve(__dirname, "web"), + resolve, + base: "/__/frontend/", + server: { + port, + strictPort: true, + // Listen on all interfaces since requests come through + // Envoy (and tunnels). + host: true, + allowedHosts: true, + }, + }; + } + + // Which UI a build targets is read from `RBT_BUILD_TARGET` (set by + // `build.mjs`), not Vite's `mode` — so `mode` stays `production` + // and the `web/` build below reads `web/.env.production`. + const target = process.env.RBT_BUILD_TARGET ?? ""; + + // Build the standalone `web/` SPA into `dist/web/`. We root the + // build at `web/` so `index.html` and its `assets/` land directly + // under `dist/web/` (rather than `dist/web/web/`). Keep assets as + // separate files (a normal multi-file build) and set `base` so + // their URLs resolve when the SPA is served at `/__/frontend/web/`. + if (target === "web") { + return { + plugins: [react()], + root: path.resolve(__dirname, "web"), + base: "/__/frontend/web/", + build: { + outDir: path.resolve(__dirname, "dist/web"), + emptyOutDir: true, + }, + resolve, + }; + } + + // Build one MCP UI (`RBT_BUILD_TARGET=mcp:`). We root the + // build at `mcp//` so the output lands directly at + // `dist/mcp//index.html`, a single self-contained file + // (assets inlined by `vite-plugin-singlefile`). The framework + // serves it at `/__/frontend/mcp//index.html` in dist mode. + const name = target.startsWith("mcp:") ? target.slice("mcp:".length) : ""; + if (!mcpNames.includes(name)) { + const valid = mcpNames.map((n) => `mcp:${n}`).join(", "); + throw new Error( + `Unknown build target: ${target || "(unset)"}. Set ` + + `RBT_BUILD_TARGET=web or one of: ${valid}.` + ); + } + + return { + plugins: [react(), viteSingleFile()], + root: path.resolve(__dirname, "mcp", name), + base: "/__/frontend/", + // Read `.env*` from `web/` (the shared frontend env), so an MCP UI + // can pick up e.g. `VITE_WEB_APP_URL` for a pop-out link. + envDir: path.resolve(__dirname, "web"), + build: { + outDir: path.resolve(__dirname, "dist/mcp", name), + emptyOutDir: true, + assetsInlineLimit: 100000000, + cssCodeSplit: false, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, + resolve, + }; +}); diff --git a/pyproject.toml b/pyproject.toml index 489d97e..92f9b31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "uuid7>=0.1.0", "anyio>=4.0.0", "pydantic-ai-slim[anthropic]>=1.0.0", - "reboot==1.2.1", + "reboot==1.3.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index b40b8f6..cfb63dc 100644 --- a/uv.lock +++ b/uv.lock @@ -28,7 +28,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, { name = "httpx", specifier = ">=0.27,<1.0" }, { name = "pydantic-ai-slim", extras = ["anthropic"], specifier = ">=1.0.0" }, - { name = "reboot", specifier = "==1.2.1" }, + { name = "reboot", specifier = "==1.3.0" }, { name = "uuid7", specifier = ">=0.1.0" }, ] @@ -2020,7 +2020,7 @@ wheels = [ [[package]] name = "reboot" -version = "1.2.1" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2070,9 +2070,9 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/85/082fea1aaf65904b2f732c893469cb1d2534dff96ba527ca5575c5afbf55/reboot-1.2.1-py3-none-macosx_14_0_arm64.whl", hash = "sha256:b54cbf0233f297926ce516d39578efaea47953d113eca7095d4d0f07a1e4b192", size = 20452221, upload-time = "2026-06-23T19:41:33.886Z" }, - { url = "https://files.pythonhosted.org/packages/64/3a/e004b65f1bbef40d8da6051fcff73925fae358a4c3ccf7b00d00bd2c7421/reboot-1.2.1-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:bb91ee132940d66c6779c2e5038f2fbf70fd567351c912bf0acf33ba60111860", size = 24135744, upload-time = "2026-06-23T19:28:20.962Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a3/7c8b5caf625a3f1b87b075b5bf23040a6f7761c797c245822144900999ce/reboot-1.2.1-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:988cb8d6e84981d35c1bf4c9040eda4181d4d221d0ff1e9a48922b7f50c123de", size = 24265726, upload-time = "2026-06-23T19:27:45.447Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/ba5c67b5eabadaa220f9998e05a63d57eef0e76d302082607547b66716bd/reboot-1.3.0-py3-none-macosx_14_0_arm64.whl", hash = "sha256:d9dfa184eb3ca1b8afff6a2b1a7b98b1371e2a06b0ce279fa4bb32064b8c4446", size = 20486712, upload-time = "2026-07-20T18:37:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/85/a1/880489a5eed6f153e96580f264aaac0a02928fd47e7d89f31ab52de0e565/reboot-1.3.0-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:7bfca0237482d5cdf2078430e745000821a8f38d5e341e75e61b956d80d6c4b5", size = 24172775, upload-time = "2026-07-20T18:29:25.383Z" }, + { url = "https://files.pythonhosted.org/packages/10/1a/fe683d0eab72f4e64d95ce6715add5b2d376d879e71275c7e260755222b5/reboot-1.3.0-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:e03658188c0e85362b1fb8bfdf4b1fe9f6fd047fae198d3e2467b4969fcca8e3", size = 24301054, upload-time = "2026-07-20T18:56:54.321Z" }, ] [[package]] diff --git a/web/package.json b/web/package.json deleted file mode 100644 index 811023f..0000000 --- a/web/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "agent-wiki-web", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build:wiki_view": "vite build --mode wiki_view", - "build:page_view": "vite build --mode page_view", - "build:transcript_view": "vite build --mode transcript_view", - "build:watch:wiki_view": "vite build --mode wiki_view --watch", - "build:watch:page_view": "vite build --mode page_view --watch", - "build:watch:transcript_view": "vite build --mode transcript_view --watch", - "build": "tsc --noEmit && npm run build:wiki_view && npm run build:page_view && npm run build:transcript_view", - "build:watch": "concurrently \"npm:build:watch:*\"" - }, - "dependencies": { - "@modelcontextprotocol/ext-apps": "1.5.0", - "@modelcontextprotocol/sdk": "1.29.0", - "@reboot-dev/reboot-api": "1.2.1", - "@reboot-dev/reboot-react": "1.2.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", - "zod": "^4.0.0" - }, - "devDependencies": { - "@types/react": "^18.2.67", - "@types/react-dom": "^18.2.22", - "@vitejs/plugin-react": "^4.7.0", - "concurrently": "^9.1.2", - "typescript": "^5.9.2", - "vite": "^6.3.5", - "vite-plugin-singlefile": "^2.0.3" - } -} diff --git a/web/vite.config.ts b/web/vite.config.ts deleted file mode 100644 index ab3670d..0000000 --- a/web/vite.config.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Vite configuration for Reboot UIs. -import fs from "fs"; -import path from "path"; -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; -import { viteSingleFile } from "vite-plugin-singlefile"; - -// Auto-discover UIs from ui/ directory. -const uiDir = path.resolve(__dirname, "ui"); -const uis: Record = - Object.fromEntries( - fs - .readdirSync(uiDir) - .filter((d) => fs.existsSync(path.join(uiDir, d, "index.html"))) - .map((name) => [ - name, - { input: `ui/${name}/index.html`, output: `${name}.html` }, - ]) - ); - -export default defineConfig(({ command, mode }) => { - const resolve = { - alias: { - "@api": path.resolve(__dirname, "./api"), - }, - dedupe: ["react", "react-dom", "zod"], - }; - - if (command === "serve") { - const port = parseInt(process.env.RBT_VITE_PORT || "4444", 10); - - return { - plugins: [react()], - root: ".", - resolve, - base: "/__/web/", - server: { - port, - strictPort: true, - host: true, - allowedHosts: true, - }, - }; - } - - const ui = uis[mode]; - if (!ui) { - const valid = Object.keys(uis).join(", "); - throw new Error(`Unknown UI: ${mode}. Use --mode with: ${valid}`); - } - - return { - plugins: [react(), viteSingleFile()], - build: { - outDir: "dist", - emptyOutDir: false, - assetsInlineLimit: 100000000, - cssCodeSplit: false, - rollupOptions: { - input: ui.input, - output: { - inlineDynamicImports: true, - entryFileNames: ui.output.replace(".html", ".js"), - assetFileNames: ui.output.replace(".html", ".[ext]"), - }, - }, - }, - resolve, - }; -});