diff --git a/Dockerfile b/Dockerfile index 7c172ac47..d8cfeed27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -82,7 +82,27 @@ RUN --mount=type=cache,target=/root/.cache/uv,id=uv-${TARGETPLATFORM} \ ;; \ esac -# Stage 3: runtime +# Stage 3: build web-studio static bundle (Vite SPA). +# Produces /web-studio/dist which the runtime stage mounts at /studio in the +# OpenViking server. Lockfile-first dependency install keeps the layer cached +# until package.json / lockfile actually change. +# +# WEB_STUDIO_BASE_PATH is passed to `vite build --base=...` and is also +# consumed at runtime by getRouterBasePath() through import.meta.env.BASE_URL, +# so the SPA's asset URLs and TanStack Router basepath stay in sync. +FROM node:20-bookworm-slim AS web-studio-builder +WORKDIR /web-studio +ARG TARGETPLATFORM +ARG WEB_STUDIO_BASE_PATH=/studio/ + +COPY web-studio/package.json web-studio/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm,id=npm-${TARGETPLATFORM} \ + npm ci + +COPY web-studio/ ./ +RUN npm run build -- --base="${WEB_STUDIO_BASE_PATH}" + +# Stage 4: runtime FROM python:3.13-slim-trixie RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -94,6 +114,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app COPY --from=py-builder /app/.venv /app/.venv +COPY --from=web-studio-builder /web-studio/dist /app/web-studio/dist COPY docker/openviking-console-entrypoint.sh /usr/local/bin/openviking-console-entrypoint COPY docker/pending_health_server.py /usr/local/bin/openviking-pending-health RUN mkdir -p /app/.openviking \ @@ -101,7 +122,8 @@ RUN mkdir -p /app/.openviking \ ENV HOME="/app" \ PATH="/app/.venv/bin:$PATH" \ OPENVIKING_CONFIG_FILE="/app/.openviking/ov.conf" \ - OPENVIKING_CLI_CONFIG_FILE="/app/.openviking/ovcli.conf" + OPENVIKING_CLI_CONFIG_FILE="/app/.openviking/ovcli.conf" \ + OPENVIKING_WEB_STUDIO_DIR="/app/web-studio/dist" EXPOSE 1933 8020 diff --git a/openviking/server/app.py b/openviking/server/app.py index d23425800..115722aa8 100644 --- a/openviking/server/app.py +++ b/openviking/server/app.py @@ -596,11 +596,19 @@ def _current_role(account_id: str, user_id: str) -> Role: except Exception as e: # noqa: BLE001 logger.warning("Skipping OAuth router registration: %s", e) - # Favicon: shared with the console static assets so 1933/console use the same logo. - # Some MCP clients (claude.ai connector cards) resolve the icon relative to the - # connector URL (e.g. {mcp_url}/favicon.ico) rather than the origin, so the same - # files are also exposed under /mcp/. - _static_dir = Path(__file__).resolve().parent.parent / "console" / "static" + # Web Studio SPA: serve the static bundle when present so the same OV + # server origin can host the new frontend at /studio. The directory is + # populated by the docker `web-studio-builder` stage; outside docker, set + # OPENVIKING_WEB_STUDIO_DIR to a local `web-studio/dist` to enable it. + # Favicon assets are bundled with web-studio (see web-studio/public/), + # so the top-level /favicon.* and /mcp/favicon.* routes are served from + # the same dist directory — no separate server-side static folder. + _studio_env = os.environ.get("OPENVIKING_WEB_STUDIO_DIR", "").strip() + if _studio_env: + _studio_dir = Path(_studio_env) + else: + _studio_dir = Path(__file__).resolve().parents[2] / "web-studio" / "dist" + _favicon_headers = {"Cache-Control": "public, max-age=86400"} _favicon_files = { "/favicon.ico": ("favicon.ico", "image/x-icon"), @@ -610,17 +618,53 @@ def _current_role(account_id: str, user_id: str) -> Role: "/mcp/favicon.png": ("favicon-32.png", "image/png"), "/mcp/apple-touch-icon.png": ("apple-touch-icon.png", "image/png"), } + _favicon_source = _studio_dir if _studio_dir.is_dir() else None + if _favicon_source is not None and all( + (_favicon_source / fname).is_file() for fname, _ in _favicon_files.values() + ): + + def _make_favicon_handler(filename: str, media_type: str): + path = _favicon_source / filename + + async def _handler(): + return FileResponse(path, media_type=media_type, headers=_favicon_headers) + + return _handler + + for _route, (_fname, _mime) in _favicon_files.items(): + app.add_api_route(_route, _make_favicon_handler(_fname, _mime), include_in_schema=False) - def _make_favicon_handler(filename: str, media_type: str): - path = _static_dir / filename + if _studio_dir.is_dir() and (_studio_dir / "index.html").is_file(): + _studio_root = _studio_dir.resolve() + _studio_index = _studio_root / "index.html" + _studio_no_store = {"Cache-Control": "no-store"} - async def _handler(): - return FileResponse(path, media_type=media_type, headers=_favicon_headers) + def _studio_response(path: Path, *, no_store: bool = False) -> FileResponse: + return FileResponse(path, headers=_studio_no_store if no_store else None) - return _handler + @app.get("/studio", include_in_schema=False) + async def _studio_root_handler(): + return _studio_response(_studio_index, no_store=True) - for _route, (_fname, _mime) in _favicon_files.items(): - app.add_api_route(_route, _make_favicon_handler(_fname, _mime), include_in_schema=False) + @app.get("/studio/{path:path}", include_in_schema=False) + async def _studio_assets(path: str): + # SPA fallback: serve real files when present, otherwise return + # index.html so TanStack Router can resolve the deep link. + try: + requested = (_studio_root / path).resolve() + except OSError: + return _studio_response(_studio_index, no_store=True) + + if not requested.is_relative_to(_studio_root): + return _studio_response(_studio_index, no_store=True) + + if requested.is_file(): + return _studio_response(requested) + return _studio_response(_studio_index, no_store=True) + + logger.info("Web Studio mounted at /studio from %s", _studio_root) + else: + logger.info("Web Studio bundle not found at %s; skipping /studio mount", _studio_dir) # MCP endpoint — serves 5 tools (search, read, store, forget, health) # via streamable HTTP for Claude Code and other MCP clients. diff --git a/web-studio/public/apple-touch-icon.png b/web-studio/public/apple-touch-icon.png new file mode 100644 index 000000000..60289db06 Binary files /dev/null and b/web-studio/public/apple-touch-icon.png differ diff --git a/web-studio/public/favicon-32.png b/web-studio/public/favicon-32.png new file mode 100644 index 000000000..2a1662ed7 Binary files /dev/null and b/web-studio/public/favicon-32.png differ diff --git a/web-studio/public/favicon.ico b/web-studio/public/favicon.ico new file mode 100644 index 000000000..13eb46e09 Binary files /dev/null and b/web-studio/public/favicon.ico differ diff --git a/web-studio/src/lib/ov-client/client.ts b/web-studio/src/lib/ov-client/client.ts index 52bc8af4e..7e0952f74 100644 --- a/web-studio/src/lib/ov-client/client.ts +++ b/web-studio/src/lib/ov-client/client.ts @@ -299,9 +299,7 @@ export function createOvClient(options: OvClientOptions = {}): OvClientAdapter { } } -const DEFAULT_LOCAL_BASE_URL = 'http://127.0.0.1:1933' - export const ovClient = createOvClient({ - baseUrl: ENV_BASE_URL || DEFAULT_LOCAL_BASE_URL, + baseUrl: ENV_BASE_URL, bindSdkClient: true, })