diff --git a/.dockerignore b/.dockerignore index 47da7fe3..352de0c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,39 @@ .git .gitignore +node_modules +**/node_modules +**/.next +**/.next-build +**/.venv +**/.pytest_cache +**/__pycache__ +**/*.pyc +**/debug +**/fastembed_cache +**/chroma +.cache +app_data +presentation-export +servers/fastapi/tests +servers/fastapi/presenton_backend.egg-info +servers/fastapi/.python-version +servers/fastapi/placeholder +servers/nextjs/cypress +servers/nextjs/cypress.config.ts +servers/nextjs/README.md +servers/nextjs/tsconfig.tsbuildinfo +**/*.cy.ts +**/*.cy.tsx + +# Keep only the LiteParse runner from the Electron tree. +electron/* +!electron/resources +electron/resources/* +!electron/resources/document-extraction +electron/resources/document-extraction/* +!electron/resources/document-extraction/liteparse_runner.mjs + servers/fastapi/tmp servers/fastapi/debug servers/fastapi/.venv @@ -8,4 +41,4 @@ servers/fastapi/.venv servers/nextjs/node_modules servers/nextjs/.next -container.db \ No newline at end of file +container.db diff --git a/Dockerfile b/Dockerfile index 476ad1a0..01dd7a5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,60 +1,108 @@ -# syntax=docker/dockerfile:1.4 -FROM python:3.11-slim-trixie +# syntax=docker/dockerfile:1.7 + +FROM python:3.11-slim-trixie AS fastapi-builder + +WORKDIR /app/servers/fastapi + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +RUN python -m venv --without-pip /opt/venv \ + && pip install --no-cache-dir uv + +COPY servers/fastapi/pyproject.toml servers/fastapi/uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \ + && uv pip install --python /opt/venv/bin/python -r /tmp/requirements.txt + +COPY servers/fastapi /app/servers/fastapi +RUN --mount=type=cache,target=/root/.cache/uv \ + uv pip install --python /opt/venv/bin/python --no-deps . +RUN --mount=type=cache,target=/root/.cache \ + /opt/venv/bin/python scripts/warm_fastembed_cache.py + + +FROM node:20-bookworm-slim AS nextjs-builder + +WORKDIR /app/servers/nextjs + +ENV NEXT_TELEMETRY_DISABLED=1 \ + PUPPETEER_SKIP_DOWNLOAD=true + +COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +COPY servers/nextjs /app/servers/nextjs +RUN npm run build \ + && rm -rf .next-build/cache + + +FROM node:20-bookworm-slim AS assets-builder WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates unzip \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json /app/ + +RUN mkdir -p /app/document-extraction-liteparse \ + && npm --prefix /app/document-extraction-liteparse init -y \ + && npm --prefix /app/document-extraction-liteparse install @llamaindex/liteparse@1.4.0 --omit=dev + +COPY electron/resources/document-extraction/liteparse_runner.mjs /app/document-extraction-liteparse/liteparse_runner.mjs +COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs +RUN node /app/scripts/sync-presentation-export.cjs --force \ + && chmod +x /app/presentation-export/py/convert-linux-x64 + + +FROM python:3.11-slim-trixie AS runtime + +WORKDIR /app + +ARG INSTALL_CHROMIUM=true +ARG INSTALL_TESSERACT=true +ARG INSTALL_LIBREOFFICE=true + # LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract. ENV APP_DATA_DIRECTORY=/app_data \ TEMP_DIRECTORY=/tmp/presenton \ PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ - UV_SYSTEM_PYTHON=1 \ - UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy \ - PATH="/root/.local/bin:${PATH}" \ EXPORT_PACKAGE_ROOT=/app/presentation-export \ EXPORT_RUNTIME_DIR=/app/presentation-export \ BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \ - PRESENTON_APP_ROOT=/app + PRESENTON_APP_ROOT=/app \ + PATH="/opt/venv/bin:${PATH}" \ + NODE_ENV=production \ + START_OLLAMA=false -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl unzip \ - nginx libreoffice fontconfig chromium imagemagick zstd \ - tesseract-ocr tesseract-ocr-eng \ - && curl -LsSf https://astral.sh/uv/install.sh | sh \ - && rm -rf /var/lib/apt/lists/* +RUN set -eux; \ + packages="ca-certificates curl nginx fontconfig imagemagick zstd"; \ + if [ "$INSTALL_LIBREOFFICE" = "true" ]; then packages="$packages libreoffice"; fi; \ + if [ "$INSTALL_CHROMIUM" = "true" ]; then packages="$packages chromium"; fi; \ + if [ "$INSTALL_TESSERACT" = "true" ]; then packages="$packages tesseract-ocr tesseract-ocr-eng"; fi; \ + apt-get update; \ + apt-get install -y --no-install-recommends $packages; \ + curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \ + apt-get install -y --no-install-recommends nodejs; \ + rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ - && apt-get install -y nodejs \ - && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /app/scripts /app/servers/fastapi /app/servers/nextjs -COPY package.json package-lock.json /app/ -RUN npm --prefix /app install --omit=dev +COPY --from=fastapi-builder /opt/venv /opt/venv +COPY --from=fastapi-builder /app/servers/fastapi /app/servers/fastapi -RUN mkdir -p /app/document-extraction-liteparse \ - && npm --prefix /app/document-extraction-liteparse init -y \ - && npm --prefix /app/document-extraction-liteparse install @llamaindex/liteparse@1.4.0 --omit=dev -COPY electron/resources/document-extraction/liteparse_runner.mjs /app/document-extraction-liteparse/liteparse_runner.mjs +COPY --from=assets-builder /app/package.json /app/package.json +COPY --from=assets-builder /app/document-extraction-liteparse /app/document-extraction-liteparse +COPY --from=assets-builder /app/presentation-export /app/presentation-export +COPY --from=assets-builder /app/scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs -COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs -RUN node /app/scripts/sync-presentation-export.cjs --force \ - && chmod +x /app/presentation-export/py/convert-linux-x64 +COPY --from=nextjs-builder /app/servers/nextjs/.next-build/standalone/ /app/servers/nextjs/ +COPY --from=nextjs-builder /app/servers/nextjs/public /app/servers/nextjs/public +COPY --from=nextjs-builder /app/servers/nextjs/.next-build/static /app/servers/nextjs/.next-build/static -RUN curl -fsSL https://ollama.com/install.sh | sh - -COPY servers/fastapi /app/servers/fastapi -WORKDIR /app/servers/fastapi -RUN --mount=type=cache,target=/root/.cache/uv \ - uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \ - && uv pip install --system -r /tmp/requirements.txt \ - && uv pip install --system --no-deps . - -WORKDIR /app/servers/nextjs -COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./ -RUN npm install -COPY servers/nextjs/ /app/servers/nextjs/ -RUN npm run build - -WORKDIR /app COPY start.js LICENSE NOTICE ./ COPY nginx.conf /etc/nginx/nginx.conf diff --git a/docker-compose.yml b/docker-compose.yml index 70b64627..589b31be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - ANTHROPIC_MODEL=${ANTHROPIC_MODEL} - OLLAMA_URL=${OLLAMA_URL} - OLLAMA_MODEL=${OLLAMA_MODEL} + - START_OLLAMA=${START_OLLAMA:-false} - CUSTOM_LLM_URL=${CUSTOM_LLM_URL} - CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY} - CUSTOM_MODEL=${CUSTOM_MODEL} @@ -77,6 +78,7 @@ services: - ANTHROPIC_MODEL=${ANTHROPIC_MODEL} - OLLAMA_URL=${OLLAMA_URL} - OLLAMA_MODEL=${OLLAMA_MODEL} + - START_OLLAMA=${START_OLLAMA:-false} - CUSTOM_LLM_URL=${CUSTOM_LLM_URL} - CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY} - CUSTOM_MODEL=${CUSTOM_MODEL} @@ -115,8 +117,8 @@ services: - presenton_document_extraction_liteparse:/app/document-extraction-liteparse - ./app_data:/app_data environment: - # Dockerfile.dev does not install ollama; use a host daemon via OLLAMA_URL or omit. - - START_EMBEDDED_OLLAMA=false + # Ollama is not baked into the image; set START_OLLAMA=true for runtime install, or use OLLAMA_URL. + - START_OLLAMA=${START_OLLAMA:-false} - MIGRATE_DATABASE_ON_STARTUP=true - CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS} - LLM=${LLM} @@ -172,7 +174,7 @@ services: - presenton_document_extraction_liteparse:/app/document-extraction-liteparse - ./app_data:/app_data environment: - - START_EMBEDDED_OLLAMA=false + - START_OLLAMA=${START_OLLAMA:-false} - MIGRATE_DATABASE_ON_STARTUP=true - CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS} - LLM=${LLM} diff --git a/servers/fastapi/pyproject.toml b/servers/fastapi/pyproject.toml index 41fd81a6..26552d41 100644 --- a/servers/fastapi/pyproject.toml +++ b/servers/fastapi/pyproject.toml @@ -14,20 +14,17 @@ dependencies = [ "aiosqlite>=0.21.0", "anthropic>=0.60.0", "asyncpg>=0.30.0", - "chromadb>=1.0.15", "dirtyjson>=1.0.8", "fastapi[standard]>=0.116.1", "fastembed-vectorstore>=0.5.2", "fastmcp>=2.11.0", "google-genai>=1.28.0", - "mem0ai[nlp]>=0.1.115", + "mem0ai>=0.1.115", "nltk>=3.9.1", "openai>=1.98.0", "pathvalidate>=3.3.1", "pdfplumber>=0.11.7", - "pytest>=8.4.1", "python-pptx>=1.0.2", - "redis>=6.2.0", "sqlmodel>=0.0.24", ] diff --git a/servers/fastapi/scripts/warm_fastembed_cache.py b/servers/fastapi/scripts/warm_fastembed_cache.py new file mode 100644 index 00000000..719a22fc --- /dev/null +++ b/servers/fastapi/scripts/warm_fastembed_cache.py @@ -0,0 +1,23 @@ +from pathlib import Path +import sys + + +FASTAPI_ROOT = Path(__file__).resolve().parents[1] +if str(FASTAPI_ROOT) not in sys.path: + sys.path.insert(0, str(FASTAPI_ROOT)) + + +from services.icon_finder_service import ICON_FINDER_SERVICE + + +def main() -> None: + if not ICON_FINDER_SERVICE.ensure_initialized(): + raise RuntimeError("Failed to prepare fastembed cache for icon search") + + print( + f"Fastembed cache prepared at {ICON_FINDER_SERVICE.cache_directory}" + ) + + +if __name__ == "__main__": + main() diff --git a/servers/fastapi/services/icon_finder_service.py b/servers/fastapi/services/icon_finder_service.py index 87ead309..6b0a30dc 100644 --- a/servers/fastapi/services/icon_finder_service.py +++ b/servers/fastapi/services/icon_finder_service.py @@ -112,11 +112,13 @@ class IconFinderService: self._initialization_failed = True # Keep vectorstore as None so search_icons returns empty results - async def search_icons(self, query: str, k: int = 1): + def ensure_initialized(self) -> bool: if not self._initialized and not self._initialization_failed: self._initialize_icons_collection() - - if not self.vectorstore or self._initialization_failed: + return self.vectorstore is not None and not self._initialization_failed + + async def search_icons(self, query: str, k: int = 1): + if not self.ensure_initialized(): # Return empty list if vectorstore failed to initialize return [] diff --git a/servers/fastapi/services/mem0_presentation_memory_service.py b/servers/fastapi/services/mem0_presentation_memory_service.py index ebff298d..6fafc224 100644 --- a/servers/fastapi/services/mem0_presentation_memory_service.py +++ b/servers/fastapi/services/mem0_presentation_memory_service.py @@ -89,6 +89,10 @@ class Mem0PresentationMemoryService: "history_db_path": self._history_db_path, } + @staticmethod + def _is_nonfatal_mem0_error(exc: BaseException) -> bool: + return isinstance(exc, (Exception, SystemExit)) + async def _get_client(self): if not self._enabled: return None @@ -121,7 +125,9 @@ class Mem0PresentationMemoryService: self._qdrant_path, self._history_db_path, ) - except Exception: + except BaseException as exc: + if not self._is_nonfatal_mem0_error(exc): + raise LOGGER.exception("Failed to initialize Mem0 OSS Memory") self._client = None @@ -147,7 +153,9 @@ class Mem0PresentationMemoryService: try: await asyncio.to_thread(_add) - except Exception: + except BaseException as exc: + if not self._is_nonfatal_mem0_error(exc): + raise LOGGER.exception( "Failed to add mem0 memory for presentation_id=%s", presentation_id ) @@ -257,7 +265,9 @@ class Mem0PresentationMemoryService: try: response = await asyncio.to_thread(_search) - except Exception: + except BaseException as exc: + if not self._is_nonfatal_mem0_error(exc): + raise LOGGER.exception( "Failed to search mem0 context for presentation_id=%s", presentation_id ) diff --git a/servers/fastapi/uv.lock b/servers/fastapi/uv.lock index 8135a057..0c753046 100644 --- a/servers/fastapi/uv.lock +++ b/servers/fastapi/uv.lock @@ -341,48 +341,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] -[[package]] -name = "chromadb" -version = "1.0.15" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bcrypt" }, - { name = "build" }, - { name = "grpcio" }, - { name = "httpx" }, - { name = "importlib-resources" }, - { name = "jsonschema" }, - { name = "kubernetes" }, - { name = "mmh3" }, - { name = "numpy" }, - { name = "onnxruntime" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-sdk" }, - { name = "orjson" }, - { name = "overrides" }, - { name = "posthog" }, - { name = "pybase64" }, - { name = "pydantic" }, - { name = "pypika" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "tenacity" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, - { name = "uvicorn", extra = ["standard"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/e2/0653b2e539db5512d2200c759f1bc7f9ef5609fe47f3c7d24b82f62dc00f/chromadb-1.0.15.tar.gz", hash = "sha256:3e910da3f5414e2204f89c7beca1650847f2bf3bd71f11a2e40aad1eb31050aa", size = 1218840, upload-time = "2025-07-02T17:07:09.875Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/5a/866c6f0c2160cbc8dca0cf77b2fb391dcf435b32a58743da1bc1a08dc442/chromadb-1.0.15-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:51791553014297798b53df4e043e9c30f4e8bd157647971a6bb02b04bfa65f82", size = 18838820, upload-time = "2025-07-02T17:07:07.632Z" }, - { url = "https://files.pythonhosted.org/packages/e1/18/ff9b58ab5d334f5ecff7fdbacd6761bac467176708fa4d2500ae7c048af0/chromadb-1.0.15-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:48015803c0631c3a817befc276436dc084bb628c37fd4214047212afb2056291", size = 18057131, upload-time = "2025-07-02T17:07:05.15Z" }, - { url = "https://files.pythonhosted.org/packages/31/49/74e34cc5aeeb25aff2c0ede6790b3671e14c1b91574dd8f98d266a4c5aad/chromadb-1.0.15-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b73cd6fb32fcdd91c577cca16ea6112b691d72b441bb3f2140426d1e79e453a", size = 18595284, upload-time = "2025-07-02T17:06:59.102Z" }, - { url = "https://files.pythonhosted.org/packages/cb/33/190df917a057067e37f8b48d082d769bed8b3c0c507edefc7b6c6bb577d0/chromadb-1.0.15-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:479f1b401af9e7c20f50642ffb3376abbfd78e2b5b170429f7c79eff52e367db", size = 19526626, upload-time = "2025-07-02T17:07:02.163Z" }, - { url = "https://files.pythonhosted.org/packages/a1/30/6890da607358993f87a01e80bcce916b4d91515ce865f07dc06845cb472f/chromadb-1.0.15-cp39-abi3-win_amd64.whl", hash = "sha256:e0cb3b93fdc42b1786f151d413ef36299f30f783a30ce08bf0bfb12e552b4190", size = 19520490, upload-time = "2025-07-02T17:07:11.559Z" }, -] - [[package]] name = "click" version = "8.2.1" @@ -1761,20 +1719,17 @@ dependencies = [ { name = "alembic" }, { name = "anthropic" }, { name = "asyncpg" }, - { name = "chromadb" }, { name = "dirtyjson" }, { name = "fastapi", extra = ["standard"] }, { name = "fastembed-vectorstore" }, { name = "fastmcp" }, { name = "google-genai" }, - { name = "mem0ai", extra = ["nlp"] }, + { name = "mem0ai" }, { name = "nltk" }, { name = "openai" }, { name = "pathvalidate" }, { name = "pdfplumber" }, - { name = "pytest" }, { name = "python-pptx" }, - { name = "redis" }, { name = "sqlmodel" }, ] @@ -1786,20 +1741,17 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, { name = "anthropic", specifier = ">=0.60.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, - { name = "chromadb", specifier = ">=1.0.15" }, { name = "dirtyjson", specifier = ">=1.0.8" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.1" }, { name = "fastembed-vectorstore", specifier = ">=0.5.2" }, { name = "fastmcp", specifier = ">=2.11.0" }, { name = "google-genai", specifier = ">=1.28.0" }, - { name = "mem0ai", extras = ["nlp"], specifier = ">=0.1.115" }, + { name = "mem0ai", specifier = ">=0.1.115" }, { name = "nltk", specifier = ">=3.9.1" }, { name = "openai", specifier = ">=1.98.0" }, { name = "pathvalidate", specifier = ">=3.3.1" }, { name = "pdfplumber", specifier = ">=0.11.7" }, - { name = "pytest", specifier = ">=8.4.1" }, { name = "python-pptx", specifier = ">=1.0.2" }, - { name = "redis", specifier = ">=6.2.0" }, { name = "sqlmodel", specifier = ">=0.0.24" }, ] @@ -2084,22 +2036,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] -[[package]] -name = "pytest" -version = "8.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2199,18 +2135,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" }, ] -[[package]] -name = "redis" -version = "6.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/0551e01ba52b944f97480721656578c8a7c46b51b99d66814f85fe3a4f3e/redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977", size = 4639129, upload-time = "2025-05-28T05:01:18.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/67/e60968d3b0e077495a8fee89cf3f2373db98e528288a48f1ee44967f6e8c/redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e", size = 278659, upload-time = "2025-05-28T05:01:16.955Z" }, -] - [[package]] name = "referencing" version = "0.36.2" diff --git a/servers/nextjs/next.config.mjs b/servers/nextjs/next.config.mjs index 3e25faf7..9076b098 100644 --- a/servers/nextjs/next.config.mjs +++ b/servers/nextjs/next.config.mjs @@ -2,6 +2,7 @@ const nextConfig = { reactStrictMode: false, distDir: ".next-build", + output: "standalone", // Rewrites for development - proxy font requests to FastAPI backend diff --git a/servers/nextjs/tsconfig.json b/servers/nextjs/tsconfig.json index 0fed898e..c53d8ad6 100644 --- a/servers/nextjs/tsconfig.json +++ b/servers/nextjs/tsconfig.json @@ -37,6 +37,8 @@ ".next-build/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "**/*.cy.ts", + "**/*.cy.tsx" ] } diff --git a/start.js b/start.js index 77e6a778..9f19bcc3 100644 --- a/start.js +++ b/start.js @@ -10,6 +10,7 @@ const __dirname = dirname(__filename); const fastapiDir = join(__dirname, "servers/fastapi"); const nextjsDir = join(__dirname, "servers/nextjs"); +const nextjsStandaloneServer = join(nextjsDir, "server.js"); const exportSyncScript = join(__dirname, "scripts/sync-presentation-export.cjs"); const args = process.argv.slice(2); @@ -56,28 +57,60 @@ const setupNodeModules = () => { }); }; -const runNodeScript = (scriptPath, scriptArgs) => { +const runCommand = (command, commandArgs, options = {}) => { return new Promise((resolve, reject) => { - const scriptProcess = spawn(process.execPath, [scriptPath, ...scriptArgs], { - cwd: __dirname, - stdio: "inherit", - env: process.env, + const child = spawn(command, commandArgs, { + cwd: options.cwd || __dirname, + stdio: options.stdio || "inherit", + env: options.env || process.env, }); - scriptProcess.on("error", (err) => { + child.on("error", (err) => { reject(err); }); - scriptProcess.on("exit", (code) => { + child.on("exit", (code) => { if (code === 0) { resolve(); } else { - reject(new Error(`Script failed with exit code: ${code}`)); + reject(new Error(`${command} exited with code: ${code}`)); } }); }); }; +const runNodeScript = (scriptPath, scriptArgs) => { + return runCommand(process.execPath, [scriptPath, ...scriptArgs], { + cwd: __dirname, + }); +}; + +const isTruthyEnv = (value) => { + if (value == null) { + return false; + } + + return !["", "0", "false", "no", "off"].includes( + String(value).trim().toLowerCase() + ); +}; + +const isOllamaInstalled = () => + existsSync("/usr/bin/ollama") || existsSync("/usr/local/bin/ollama"); + +const shouldStartOllama = () => isTruthyEnv(process.env.START_OLLAMA); + +const ensureOllamaRuntime = async () => { + if (!shouldStartOllama() || isOllamaInstalled()) { + return; + } + + console.log("START_OLLAMA=true; installing Ollama runtime..."); + await runCommand("sh", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], { + cwd: "/", + }); +}; + const ensurePresentationExportRuntime = async () => { if (process.env.ENSURE_PRESENTATION_EXPORT_RUNTIME === "false") { return; @@ -195,21 +228,32 @@ const startServers = async () => { console.error("App MCP process failed to start:", err); }); + const useStandaloneNextjs = !isDev && existsSync(nextjsStandaloneServer); + const nextjsProcess = spawn( - "npm", - [ - "run", - isDev ? "dev" : "start", - "--", - "-H", - "127.0.0.1", - "-p", - nextjsPort.toString(), - ], + useStandaloneNextjs ? process.execPath : "npm", + useStandaloneNextjs + ? [nextjsStandaloneServer] + : [ + "run", + isDev ? "dev" : "start", + "--", + "-H", + "127.0.0.1", + "-p", + nextjsPort.toString(), + ], { cwd: nextjsDir, stdio: "inherit", - env: process.env, + env: + useStandaloneNextjs + ? { + ...process.env, + HOSTNAME: "127.0.0.1", + PORT: nextjsPort.toString(), + } + : process.env, } ); @@ -217,16 +261,15 @@ const startServers = async () => { console.error("Next.js process failed to start:", err); }); - const startEmbeddedOllama = - process.env.START_EMBEDDED_OLLAMA !== "false" && - process.env.START_EMBEDDED_OLLAMA !== "0"; + const shouldStartOllamaRuntime = shouldStartOllama(); + const ollamaInstalled = isOllamaInstalled(); const exitPromises = [ new Promise((resolve) => fastApiProcess.on("exit", resolve)), new Promise((resolve) => nextjsProcess.on("exit", resolve)), ]; - if (startEmbeddedOllama) { + if (shouldStartOllamaRuntime && ollamaInstalled) { const ollamaProcess = spawn("ollama", ["serve"], { cwd: "/", stdio: "inherit", @@ -236,9 +279,13 @@ const startServers = async () => { console.error("Ollama process failed to start:", err); }); exitPromises.push(new Promise((resolve) => ollamaProcess.on("exit", resolve))); + } else if (shouldStartOllamaRuntime) { + console.log( + "Ollama requested, but the binary is not installed. Set START_OLLAMA=true to install it at startup, or set OLLAMA_URL to a remote daemon." + ); } else { console.log( - "Embedded Ollama disabled (START_EMBEDDED_OLLAMA=false); use OLLAMA_URL for a remote daemon if needed." + "Ollama disabled (START_OLLAMA=false); use OLLAMA_URL for a remote daemon if needed." ); } @@ -271,6 +318,7 @@ const startNginx = () => { const main = async () => { await ensurePresentationExportRuntime(); + await ensureOllamaRuntime(); if (isDev) { await setupNodeModules();