From f6ca814dd182e04987be6d61f51e1295eefd96f3 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 25 Nov 2025 10:54:42 +0700 Subject: [PATCH 01/11] fix: mobile layout --- frontend/components/MessageInput.tsx | 11 ----------- frontend/components/MessageList.tsx | 4 ++-- frontend/components/ui/ModelSelector.tsx | 2 +- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/frontend/components/MessageInput.tsx b/frontend/components/MessageInput.tsx index 3a0e8cd7..b41b54bf 100644 --- a/frontend/components/MessageInput.tsx +++ b/frontend/components/MessageInput.tsx @@ -789,17 +789,6 @@ export const MessageInput = forwardRef(funct })} - - {/* Dropdown footer */} -
- -
)} diff --git a/frontend/components/MessageList.tsx b/frontend/components/MessageList.tsx index a36cf2bb..3a25e3f3 100644 --- a/frontend/components/MessageList.tsx +++ b/frontend/components/MessageList.tsx @@ -256,7 +256,7 @@ const Message = React.memo( )}
{isEditing ? (
{messages.length === 0 && } diff --git a/frontend/components/ui/ModelSelector.tsx b/frontend/components/ui/ModelSelector.tsx index 688d661b..6be4fc08 100644 --- a/frontend/components/ui/ModelSelector.tsx +++ b/frontend/components/ui/ModelSelector.tsx @@ -423,7 +423,7 @@ export default function ModelSelector({ } }); }} - className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-neutral-900 border border-slate-200 dark:border-neutral-700 rounded-lg hover:bg-slate-50 dark:hover:bg-neutral-800 transition-colors min-w-48 w-56" + className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-neutral-900 border border-slate-200 dark:border-neutral-700 rounded-lg hover:bg-slate-50 dark:hover:bg-neutral-800 transition-colors min-w-0 w-full sm:min-w-48 sm:w-56" aria-label={ariaLabel} aria-expanded={isOpen} aria-haspopup="listbox" From 842e6b014c16d1bcc79b70290cd0bb36dfaf3afb Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 25 Nov 2025 12:09:11 +0700 Subject: [PATCH 02/11] feat: docker hub publishing --- .github/workflows/docker-publish.yml | 112 +++++++++++++++++++++++++++ README.md | 31 +++++++- backend/entrypoint.sh | 25 ++++++ docker-compose.hub.yml | 97 +++++++++++++++++++++++ proxy/Dockerfile | 11 +++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 docker-compose.hub.yml create mode 100644 proxy/Dockerfile diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..65e99c3f --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,112 @@ +name: Build and Publish Docker Images + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to build and publish (e.g., v1.0.0)' + required: true + type: string + +env: + REGISTRY: docker.io + BACKEND_IMAGE: qduc/chatforge-backend + FRONTEND_IMAGE: qduc/chatforge-frontend + PROXY_IMAGE: qduc/chatforge-proxy + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract version tag + id: version + run: | + if [ -n "${{ github.event.inputs.tag }}" ]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "version_short=${VERSION#v}" >> $GITHUB_OUTPUT + + - name: Build and push backend image + uses: docker/build-push-action@v5 + with: + context: ./backend + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.BACKEND_IMAGE }}:${{ steps.version.outputs.version }} + ${{ env.BACKEND_IMAGE }}:${{ steps.version.outputs.version_short }} + ${{ env.BACKEND_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push frontend image + uses: docker/build-push-action@v5 + with: + context: ./frontend + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + NEXT_PUBLIC_API_BASE=/api + BACKEND_ORIGIN=http://backend:3001 + tags: | + ${{ env.FRONTEND_IMAGE }}:${{ steps.version.outputs.version }} + ${{ env.FRONTEND_IMAGE }}:${{ steps.version.outputs.version_short }} + ${{ env.FRONTEND_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push proxy image + uses: docker/build-push-action@v5 + with: + context: ./proxy + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.PROXY_IMAGE }}:${{ steps.version.outputs.version }} + ${{ env.PROXY_IMAGE }}:${{ steps.version.outputs.version_short }} + ${{ env.PROXY_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Create release summary + run: | + echo "## Docker Images Published 🚀" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Version: \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Images" >> $GITHUB_STEP_SUMMARY + echo "- \`${{ env.BACKEND_IMAGE }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`${{ env.FRONTEND_IMAGE }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`${{ env.PROXY_IMAGE }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Quick Start" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "curl -O https://raw.githubusercontent.com/qduc/chat/main/docker-compose.hub.yml" >> $GITHUB_STEP_SUMMARY + echo "echo 'OPENAI_API_KEY=sk-your-key' > .env" >> $GITHUB_STEP_SUMMARY + echo "docker compose -f docker-compose.hub.yml up -d" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index c5c58e7c..ca27eff3 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,38 @@ ChatForge is a full-stack AI chat application featuring a Next.js 15 frontend an ## Quick Start -**Recommended: Docker Development** +### Option 1: One-Click Docker Hub Deployment (Recommended) + +Pull pre-built images from Docker Hub - no cloning required: + +```bash +# Download the compose file +curl -O https://raw.githubusercontent.com/qduc/chat/main/docker-compose.hub.yml + +# Create .env with your API key +echo "OPENAI_API_KEY=sk-your-key-here" > .env + +# Start the stack +docker compose -f docker-compose.hub.yml up -d +``` + +Visit http://localhost:3000 and register your first user. That's it! + +**Optional configuration** (add to `.env` file): +```bash +JWT_SECRET=your-secret-here # Auto-generated if not set +DEFAULT_MODEL=gpt-4.1-mini # Default model +ANTHROPIC_API_KEY=sk-ant-... # For Anthropic provider +GOOGLE_API_KEY=... # For Google/Gemini provider +PORT=3000 # External port (default: 3000) +``` + +### Option 2: Docker Development (with hot reload) ```bash +# Clone the repository +git clone https://github.com/qduc/chat.git && cd chat + # Copy environment files cp backend/.env.example backend/.env # Edit backend/.env and set OPENAI_API_KEY and JWT_SECRET diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index ae95fab9..3f28a6b2 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -4,6 +4,23 @@ set -e APP_USER="${APP_USER:-node}" APP_GROUP="${APP_GROUP:-node}" INSTALL_ON_START="${INSTALL_ON_START:-1}" +JWT_SECRET_FILE="/data/.jwt_secret" + +# Auto-generate JWT_SECRET if not provided +if [ -z "$JWT_SECRET" ]; then + if [ -f "$JWT_SECRET_FILE" ]; then + # Load existing secret from persistent storage + JWT_SECRET=$(cat "$JWT_SECRET_FILE") + export JWT_SECRET + echo "Loaded JWT_SECRET from persistent storage" + else + # Generate a new secure secret + JWT_SECRET=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 64) + export JWT_SECRET + echo "Generated new JWT_SECRET (will be persisted after directory setup)" + export JWT_SECRET_NEEDS_SAVE=1 + fi +fi if [ "$(id -u)" = "0" ]; then ADJUST_PATHS="/data" @@ -34,4 +51,12 @@ if [ "$INSTALL_ON_START" = "1" ] && [ -f package.json ]; then npm install fi +# Save auto-generated JWT_SECRET to persistent storage +if [ "$JWT_SECRET_NEEDS_SAVE" = "1" ] && [ -d "/data" ]; then + echo "$JWT_SECRET" > "$JWT_SECRET_FILE" + chmod 600 "$JWT_SECRET_FILE" + echo "Saved JWT_SECRET to persistent storage" + unset JWT_SECRET_NEEDS_SAVE +fi + exec "$@" diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml new file mode 100644 index 00000000..de5c0242 --- /dev/null +++ b/docker-compose.hub.yml @@ -0,0 +1,97 @@ +# ChatForge - One-Click Deployment +# +# Quick Start: +# 1. Create a .env file with your settings: +# echo "OPENAI_API_KEY=sk-your-key-here" > .env +# 2. Start the stack: +# docker compose -f docker-compose.hub.yml up -d +# 3. Visit http://localhost:3000 and register your first user +# +# Optional environment variables (add to .env file): +# JWT_SECRET=your-secret-here # Auto-generated if not set +# OPENAI_BASE_URL=https://api.openai.com/v1 # Custom OpenAI-compatible endpoint +# DEFAULT_MODEL=gpt-4.1-mini # Default model to use +# ANTHROPIC_API_KEY=sk-ant-... # For Anthropic provider +# GOOGLE_API_KEY=... # For Google/Gemini provider +# TAVILY_API_KEY=... # For Tavily web search tool +# EXA_API_KEY=... # For Exa web search tool +# +# For more options, see: https://github.com/qduc/chat/blob/main/docs/ENVIRONMENT_VARIABLES.md + +services: + backend: + image: qduc/chatforge-backend:latest + environment: + # Required: Your OpenAI API key (or compatible provider) + - OPENAI_API_KEY=${OPENAI_API_KEY:?Please set OPENAI_API_KEY in .env file} + + # Security: JWT secret (auto-generated if not provided) + - JWT_SECRET=${JWT_SECRET:-} + + # Optional: Custom OpenAI-compatible endpoint + - OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1} + + # Optional: Default model + - DEFAULT_MODEL=${DEFAULT_MODEL:-gpt-4.1-mini} + + # Optional: Additional provider API keys + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} + + # Optional: Tool API keys + - TAVILY_API_KEY=${TAVILY_API_KEY:-} + - EXA_API_KEY=${EXA_API_KEY:-} + - SEARXNG_BASE_URL=${SEARXNG_BASE_URL:-} + + # Internal configuration (do not change unless you know what you're doing) + - PERSIST_TRANSCRIPTS=1 + - IMAGE_STORAGE_PATH=/data/images + - FILE_STORAGE_PATH=/data/files + - DB_URL=file:/data/prod.db + - NODE_ENV=production + - PORT=3001 + - CORS_ORIGIN=* + volumes: + - chatforge_data:/data + - chatforge_logs:/app/logs + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(res => { if (res.ok) process.exit(0); process.exit(1); }).catch(() => process.exit(1));"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + + frontend: + image: qduc/chatforge-frontend:latest + environment: + - NEXT_PUBLIC_API_BASE=/api + - BACKEND_ORIGIN=http://backend:3001 + - NODE_ENV=production + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(res => { if (res.ok) process.exit(0); process.exit(1); }).catch(() => process.exit(1));"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + + proxy: + image: qduc/chatforge-proxy:latest + depends_on: + frontend: + condition: service_started + backend: + condition: service_healthy + ports: + - "${PORT:-3000}:80" + restart: unless-stopped + +volumes: + chatforge_data: + driver: local + chatforge_logs: + driver: local diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 00000000..24f01a5b --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,11 @@ +FROM nginx:1.27-alpine + +# Copy the nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Expose port 80 +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \ + CMD wget --spider -q http://127.0.0.1/health || exit 1 From 193901d4e63e24f360aa305258f72666edf0b3db Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 25 Nov 2025 12:30:00 +0700 Subject: [PATCH 03/11] refactor: Removed every non-infrastructure environment variable from runtime config, docs, Compose templates, onboarding scripts, and sample .env files. Back-end configuration now expects only infrastructure knobs (JWT, ports, etc.) while provider/API details are exclusively stored in user settings. --- .devcontainer/init.sh | 2 +- .github/workflows/docker-publish.yml | 3 +- README.md | 17 ++++---- backend/.env.example | 20 ++-------- backend/README.md | 6 +-- backend/__tests__/web_search_exa_tool.test.js | 17 ++------ .../__tests__/web_search_searxng_tool.test.js | 15 +------ backend/src/env.js | 39 +++++++++---------- backend/src/lib/providers/geminiProvider.js | 7 +--- backend/src/lib/tools/webSearch.js | 20 ++++++---- backend/src/lib/tools/webSearchExa.js | 16 +++++--- backend/src/lib/tools/webSearchSearxng.js | 12 +----- backend/src/logger.js | 8 +--- backend/src/routes/chat.js | 11 ++---- docker-compose.hub.yml | 29 ++------------ docs/ENVIRONMENT_VARIABLES.md | 30 +++----------- docs/INSTALLATION.md | 8 ++-- docs/TOOLS.md | 10 ++--- docs/tool_orchestration_deep_dive.md | 6 ++- requests/openai.http | 2 +- 20 files changed, 89 insertions(+), 189 deletions(-) diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index 4abfa48c..0fbf76ea 100644 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -9,7 +9,7 @@ cd "$REPO_ROOT" if [ ! -f backend/.env ]; then if [ -f backend/.env.example ]; then cp backend/.env.example backend/.env - echo "Created backend/.env from example. Remember to set OPENAI_API_KEY." + echo "Created backend/.env from example. Remember to set JWT_SECRET." echo "Note: ALLOWED_ORIGIN is set for devcontainer (port 3003)." else echo "Warning: backend/.env.example not found; create backend/.env manually." >&2 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 65e99c3f..fafa11b7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -107,6 +107,7 @@ jobs: echo "### Quick Start" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY echo "curl -O https://raw.githubusercontent.com/qduc/chat/main/docker-compose.hub.yml" >> $GITHUB_STEP_SUMMARY - echo "echo 'OPENAI_API_KEY=sk-your-key' > .env" >> $GITHUB_STEP_SUMMARY + echo "echo 'JWT_SECRET=change-me' > .env" >> $GITHUB_STEP_SUMMARY echo "docker compose -f docker-compose.hub.yml up -d" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "Then visit http://localhost:3000 and add your provider API key via Settings → Providers & Tools." >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index ca27eff3..bd812b57 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ChatForge is a full-stack AI chat application featuring a Next.js 15 frontend an - Node.js 18 or higher - Docker and Docker Compose (for containerized deployment) -- OpenAI API key or compatible provider API key +- An OpenAI (or compatible) API key that you'll enter through Settings → Providers & Tools ## Quick Start @@ -41,21 +41,18 @@ Pull pre-built images from Docker Hub - no cloning required: # Download the compose file curl -O https://raw.githubusercontent.com/qduc/chat/main/docker-compose.hub.yml -# Create .env with your API key -echo "OPENAI_API_KEY=sk-your-key-here" > .env +# (Optional) Provide infrastructure secrets +echo "JWT_SECRET=change-me" > .env # Start the stack docker compose -f docker-compose.hub.yml up -d ``` -Visit http://localhost:3000 and register your first user. That's it! +Visit http://localhost:3000, register your first user, then open **Settings → Providers & Tools** to enter your API key and base URL. -**Optional configuration** (add to `.env` file): +**Optional infrastructure config** (add to `.env` file): ```bash -JWT_SECRET=your-secret-here # Auto-generated if not set -DEFAULT_MODEL=gpt-4.1-mini # Default model -ANTHROPIC_API_KEY=sk-ant-... # For Anthropic provider -GOOGLE_API_KEY=... # For Google/Gemini provider +JWT_SECRET=your-secret-here # Overrides auto-generated secret PORT=3000 # External port (default: 3000) ``` @@ -67,7 +64,7 @@ git clone https://github.com/qduc/chat.git && cd chat # Copy environment files cp backend/.env.example backend/.env -# Edit backend/.env and set OPENAI_API_KEY and JWT_SECRET +# Edit backend/.env and set JWT_SECRET # Start with hot reload ./dev.sh up --build diff --git a/backend/.env.example b/backend/.env.example index cd81b764..7509eb8f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,22 +1,10 @@ # shellcheck disable=SC2034 -## Provider selection (default: openai) -PROVIDER=openai +## Authentication +JWT_SECRET=change-me -## Generic provider config (falls back to OpenAI values) -# PROVIDER_BASE_URL= -# PROVIDER_API_KEY= -# PROVIDER_HEADERS_JSON={"X-Custom":"Value"} - -## OpenAI-compatible defaults (kept for backward-compat) -OPENAI_BASE_URL=https://api.openai.com/v1 -OPENAI_API_KEY=sk-xxxxx -TAVILY_API_KEY=tvly-xxxxx # Optional: Tavily web search tool -EXA_API_KEY=exa-xxxxx # Optional: Exa web search tool -# SEARXNG_BASE_URL=http://localhost:8080 # Legacy fallback; configure "SearXNG Base URL" in user settings instead - -DEFAULT_MODEL=gpt-4.1-mini -TITLE_MODEL=gpt-4.1-mini +## Provider & tool configuration now lives in Settings → Providers & Tools +# Add API keys, base URLs, and default models in-app; they are no longer read from .env files. PORT=3001 RATE_LIMIT_WINDOW_SEC=60 RATE_LIMIT_MAX=50 diff --git a/backend/README.md b/backend/README.md index 7d0702d9..023dd94f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,14 +4,14 @@ Express-based proxy for OpenAI-compatible chat completions, with pluggable provi ## Endpoints -- `POST /v1/chat/completions` – proxies to `${PROVIDER_BASE_URL||OPENAI_BASE_URL}/v1/chat/completions` (supports streaming) +- `POST /v1/chat/completions` – proxies requests to the configured provider (supports streaming) - `POST /v1/conversations` – create a conversation (feature-flagged) - `GET /v1/conversations/:id` – fetch conversation metadata (feature-flagged) - `GET /healthz` – health/status info ## Env Vars (.env) -See `.env.example` for required variables. You can select a provider via `PROVIDER` (default: `openai`). Generic keys `PROVIDER_BASE_URL`, `PROVIDER_API_KEY`, and optional `PROVIDER_HEADERS_JSON` are supported; OpenAI-specific vars remain for backward compatibility. +See `.env.example` for required infrastructure variables (e.g., `JWT_SECRET`, `PORT`). Provider API keys, base URLs, and default models now live in the database via per-user settings. Additional (Sprint 1): @@ -50,7 +50,7 @@ This reduces database write load and avoids timer-based flushes while preserving 1. Create env file (not copied into image): ```bash cp .env.example .env - # edit PROVIDER/OPENAI variables as needed + # set infrastructure-only variables (e.g., JWT secret) ``` 2. Build & run (from repo root): ```bash diff --git a/backend/__tests__/web_search_exa_tool.test.js b/backend/__tests__/web_search_exa_tool.test.js index 4531290f..e24648df 100644 --- a/backend/__tests__/web_search_exa_tool.test.js +++ b/backend/__tests__/web_search_exa_tool.test.js @@ -22,19 +22,8 @@ describe('web_search_exa tool', () => { assert.throws(() => webSearchExaTool.validate({}), /requires a "query"/); }); - test('handler throws when EXA_API_KEY is missing', async () => { - const originalKey = process.env.EXA_API_KEY; - delete process.env.EXA_API_KEY; - - try { - const args = webSearchExaTool.validate({ query: 'test' }); - await assert.rejects(() => webSearchExaTool.handler(args), /EXA_API_KEY environment variable is not set/); - } finally { - if (originalKey !== undefined) { - process.env.EXA_API_KEY = originalKey; - } else { - delete process.env.EXA_API_KEY; - } - } + test('handler throws when Exa API key is missing', async () => { + const args = webSearchExaTool.validate({ query: 'test' }); + await assert.rejects(() => webSearchExaTool.handler(args), /Exa API key is not configured/); }); }); diff --git a/backend/__tests__/web_search_searxng_tool.test.js b/backend/__tests__/web_search_searxng_tool.test.js index a30068a7..8ac2cc92 100644 --- a/backend/__tests__/web_search_searxng_tool.test.js +++ b/backend/__tests__/web_search_searxng_tool.test.js @@ -76,19 +76,8 @@ describe('web_search_searxng tool', () => { }); test('handler throws when SearXNG base URL is not configured', async () => { - const originalUrl = process.env.SEARXNG_BASE_URL; - delete process.env.SEARXNG_BASE_URL; - - try { - const args = webSearchSearxngTool.validate({ query: 'test' }); - await assert.rejects(() => webSearchSearxngTool.handler(args), /SearXNG base URL is not configured/); - } finally { - if (originalUrl !== undefined) { - process.env.SEARXNG_BASE_URL = originalUrl; - } else { - delete process.env.SEARXNG_BASE_URL; - } - } + const args = webSearchSearxngTool.validate({ query: 'test' }); + await assert.rejects(() => webSearchSearxngTool.handler(args), /SearXNG base URL is not configured/); }); test('validate handles optional parameters correctly', () => { diff --git a/backend/src/env.js b/backend/src/env.js index c34be5b4..e7db5fb6 100644 --- a/backend/src/env.js +++ b/backend/src/env.js @@ -5,8 +5,6 @@ import { logger } from './logger.js'; const isTest = process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined'; const required = [ - // Provider config is flexible; default remains OpenAI-compatible - 'DEFAULT_MODEL', 'PORT', 'RATE_LIMIT_WINDOW_SEC', 'RATE_LIMIT_MAX', @@ -28,34 +26,33 @@ const bool = (v, def = false) => { return s === '1' || s === 'true' || s === 'yes' || s === 'on'; }; +const DEFAULT_MODEL = 'gpt-4.1-mini'; +const DEFAULT_TITLE_MODEL = 'gpt-4.1-mini'; +const OPENAI_BASE_URL = 'https://api.openai.com/v1'; +const ANTHROPIC_BASE_URL = 'https://api.anthropic.com'; + +const providerHeaders = undefined; + export const config = { // Provider selection (default to openai for backward-compat) - provider: process.env.PROVIDER || 'openai', - // Backward-compat: legacy OpenAI fields still present - openaiBaseUrl: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', - openaiApiKey: process.env.OPENAI_API_KEY, + provider: 'openai', + // Backward-compat: legacy OpenAI fields still present (now static defaults) + openaiBaseUrl: OPENAI_BASE_URL, + openaiApiKey: null, // Anthropic provider overrides - anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', - anthropicApiKey: process.env.ANTHROPIC_API_KEY, + anthropicBaseUrl: ANTHROPIC_BASE_URL, + anthropicApiKey: null, // Generic provider config; falls back to OpenAI values providerConfig: { - baseUrl: process.env.PROVIDER_BASE_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', - apiKey: process.env.PROVIDER_API_KEY || process.env.OPENAI_API_KEY, - headers: (() => { - try { - return process.env.PROVIDER_HEADERS_JSON ? JSON.parse(process.env.PROVIDER_HEADERS_JSON) : undefined; - } catch { - // Avoid noisy warnings during automated tests - if (!isTest) logger.warn('[env] Invalid PROVIDER_HEADERS_JSON; expected JSON'); - return undefined; - } - })(), + baseUrl: OPENAI_BASE_URL, + apiKey: null, + headers: providerHeaders, timeoutMs: Number(process.env.PROVIDER_TIMEOUT_MS) || 10000, // 10 second default for provider operations modelFetchTimeoutMs: Number(process.env.PROVIDER_MODEL_FETCH_TIMEOUT_MS) || 3000, // 3 second default for model fetching streamTimeoutMs: Number(process.env.PROVIDER_STREAM_TIMEOUT_MS) || 300000, // 300 second default for streaming operations }, - defaultModel: process.env.DEFAULT_MODEL || 'gpt-4.1-mini', - titleModel: process.env.TITLE_MODEL || 'gpt-4.1-mini', + defaultModel: DEFAULT_MODEL, + titleModel: DEFAULT_TITLE_MODEL, port: Number(process.env.PORT) || 3001, rate: { windowSec: Number(process.env.RATE_LIMIT_WINDOW_SEC) || 60, diff --git a/backend/src/lib/providers/geminiProvider.js b/backend/src/lib/providers/geminiProvider.js index a4ebfc75..bd2b03f4 100644 --- a/backend/src/lib/providers/geminiProvider.js +++ b/backend/src/lib/providers/geminiProvider.js @@ -46,12 +46,7 @@ export class GeminiProvider extends BaseProvider { } get apiKey() { - return ( - this.settings?.apiKey || - this.config?.providerConfig?.apiKey || - this.config?.geminiApiKey || - process.env.GEMINI_API_KEY - ); + return this.settings?.apiKey || this.config?.providerConfig?.apiKey || null; } get baseUrl() { diff --git a/backend/src/lib/tools/webSearch.js b/backend/src/lib/tools/webSearch.js index e36ad5b7..ea53da73 100644 --- a/backend/src/lib/tools/webSearch.js +++ b/backend/src/lib/tools/webSearch.js @@ -137,10 +137,7 @@ async function handler({ } } if (!apiKey) { - apiKey = process.env.TAVILY_API_KEY; - } - if (!apiKey) { - throw new Error('Tavily API key is not configured (no per-user key or TAVILY_API_KEY env var)'); + throw new Error('Tavily API key is not configured. Please add it in Settings → Search & Web Tools.'); } const url = 'https://api.tavily.com/search'; @@ -188,17 +185,23 @@ async function handler({ // 400 Bad Request - LLM can fix these by adjusting parameters if (response.status === 400) { - throw new Error(`Invalid request parameters: ${apiErrorMessage}. Please adjust the tool call parameters and try again.`); + throw new Error( + `Invalid request parameters: ${apiErrorMessage}. Please adjust the tool call parameters and try again.` + ); } // 401/403 - Authentication/authorization issues (infra) if (response.status === 401 || response.status === 403) { - throw new Error(`Tavily API authentication failed: ${apiErrorMessage} (Check TAVILY_API_KEY configuration)`); + throw new Error( + `Tavily API authentication failed: ${apiErrorMessage} (Verify your Tavily API key under Settings → Search & Web Tools)` + ); } // 429 - Rate limiting (infra) if (response.status === 429) { - throw new Error(`Tavily API rate limit exceeded: ${apiErrorMessage} (API quota exhausted - please try again later)`); + throw new Error( + `Tavily API rate limit exceeded: ${apiErrorMessage} (API quota exhausted - please try again later)` + ); } // 500+ - Server errors (infra) @@ -208,7 +211,8 @@ async function handler({ // Other errors - provide full context throw new Error(`Tavily API request failed with status ${response.status}: ${apiErrorMessage}`); - } const results = await response.json(); + } + const results = await response.json(); let output = ''; if (results.answer) { diff --git a/backend/src/lib/tools/webSearchExa.js b/backend/src/lib/tools/webSearchExa.js index 5da6e4f6..a67c3781 100644 --- a/backend/src/lib/tools/webSearchExa.js +++ b/backend/src/lib/tools/webSearchExa.js @@ -161,10 +161,8 @@ async function handler({ logger.warn('Failed to read user exa_api_key from DB', { userId, err: err?.message || err }); } } - if (!apiKey) apiKey = process.env.EXA_API_KEY; if (!apiKey) { - // Keep an exact message expected by unit tests - throw new Error('EXA_API_KEY environment variable is not set'); + throw new Error('Exa API key is not configured. Please add it in Settings → Search & Web Tools.'); } const url = 'https://api.exa.ai/search'; @@ -213,15 +211,21 @@ async function handler({ } if (response.status === 400) { - throw new Error(`Invalid Exa request parameters: ${apiErrorMessage}. Please adjust the tool call parameters and try again.`); + throw new Error( + `Invalid Exa request parameters: ${apiErrorMessage}. Please adjust the tool call parameters and try again.` + ); } if (response.status === 401 || response.status === 403) { - throw new Error(`Exa API authentication failed: ${apiErrorMessage} (Check EXA_API_KEY configuration)`); + throw new Error( + `Exa API authentication failed: ${apiErrorMessage} (Verify your Exa API key under Settings → Search & Web Tools)` + ); } if (response.status === 429) { - throw new Error(`Exa API rate limit exceeded: ${apiErrorMessage} (API quota exhausted - please try again later)`); + throw new Error( + `Exa API rate limit exceeded: ${apiErrorMessage} (API quota exhausted - please try again later)` + ); } if (response.status >= 500) { diff --git a/backend/src/lib/tools/webSearchSearxng.js b/backend/src/lib/tools/webSearchSearxng.js index 56e04daf..bb17a868 100644 --- a/backend/src/lib/tools/webSearchSearxng.js +++ b/backend/src/lib/tools/webSearchSearxng.js @@ -80,16 +80,6 @@ function resolveSearxngBaseUrl(userId) { logger.warn('Failed to read user searxng_base_url from DB', { userId, err: err?.message || err }); } } - - const envValue = - typeof process.env.SEARXNG_BASE_URL === 'string' - ? process.env.SEARXNG_BASE_URL.trim() - : process.env.SEARXNG_BASE_URL; - - if (envValue) { - return envValue; - } - return null; } @@ -101,7 +91,7 @@ async function handler( const searxngUrl = resolveSearxngBaseUrl(userId); if (!searxngUrl) { throw new Error( - 'SearXNG base URL is not configured. Please enter a SearXNG Base URL in your user settings or set SEARXNG_BASE_URL as a fallback.' + 'SearXNG base URL is not configured. Please enter a SearXNG Base URL under Settings → Search & Web Tools.' ); } // Basic URL sanity check to provide clearer errors for bad config diff --git a/backend/src/logger.js b/backend/src/logger.js index 3c87954f..d2b8336f 100644 --- a/backend/src/logger.js +++ b/backend/src/logger.js @@ -52,13 +52,7 @@ export const logger = pino({ }, redact: { // Best-effort redactions for common sensitive fields - paths: [ - 'req.headers.authorization', - 'headers.authorization', - 'config.openaiApiKey', - 'OPENAI_API_KEY', - 'body.apiKey', - ], + paths: ['req.headers.authorization', 'headers.authorization', 'config.openaiApiKey', 'body.apiKey'], remove: true, }, }); diff --git a/backend/src/routes/chat.js b/backend/src/routes/chat.js index 45428bad..6ac5d473 100644 --- a/backend/src/routes/chat.js +++ b/backend/src/routes/chat.js @@ -24,9 +24,9 @@ chatRouter.get('/v1/tools', (req, res) => { // Define which tools require which API keys const toolApiKeyMapping = { - 'web_search': { settingKey: 'tavily_api_key', envVar: 'TAVILY_API_KEY', label: 'Tavily API Key' }, - 'web_search_exa': { settingKey: 'exa_api_key', envVar: 'EXA_API_KEY', label: 'Exa API Key' }, - 'web_search_searxng': { settingKey: 'searxng_base_url', envVar: 'SEARXNG_BASE_URL', label: 'SearXNG Base URL' } + web_search: { settingKey: 'tavily_api_key', label: 'Tavily API Key' }, + web_search_exa: { settingKey: 'exa_api_key', label: 'Exa API Key' }, + web_search_searxng: { settingKey: 'searxng_base_url', label: 'SearXNG Base URL' }, }; // Check each tool's API key status @@ -53,11 +53,6 @@ chatRouter.get('/v1/tools', (req, res) => { } } - // Fall back to environment variable if no user setting - if (!hasKey && process.env[apiKeyInfo.envVar]) { - hasKey = true; - } - toolApiKeyStatus[toolName] = { hasApiKey: hasKey, requiresApiKey: true, diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index de5c0242..e65e5411 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -2,19 +2,14 @@ # # Quick Start: # 1. Create a .env file with your settings: -# echo "OPENAI_API_KEY=sk-your-key-here" > .env +# echo "JWT_SECRET=change-me" > .env # 2. Start the stack: # docker compose -f docker-compose.hub.yml up -d -# 3. Visit http://localhost:3000 and register your first user +# 3. Visit http://localhost:3000, register your first user, and add your provider API key via Settings → Providers & Tools # # Optional environment variables (add to .env file): # JWT_SECRET=your-secret-here # Auto-generated if not set -# OPENAI_BASE_URL=https://api.openai.com/v1 # Custom OpenAI-compatible endpoint -# DEFAULT_MODEL=gpt-4.1-mini # Default model to use -# ANTHROPIC_API_KEY=sk-ant-... # For Anthropic provider -# GOOGLE_API_KEY=... # For Google/Gemini provider -# TAVILY_API_KEY=... # For Tavily web search tool -# EXA_API_KEY=... # For Exa web search tool +# PORT=3000 # External port (defaults to 3000) # # For more options, see: https://github.com/qduc/chat/blob/main/docs/ENVIRONMENT_VARIABLES.md @@ -22,27 +17,9 @@ services: backend: image: qduc/chatforge-backend:latest environment: - # Required: Your OpenAI API key (or compatible provider) - - OPENAI_API_KEY=${OPENAI_API_KEY:?Please set OPENAI_API_KEY in .env file} - # Security: JWT secret (auto-generated if not provided) - JWT_SECRET=${JWT_SECRET:-} - # Optional: Custom OpenAI-compatible endpoint - - OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1} - - # Optional: Default model - - DEFAULT_MODEL=${DEFAULT_MODEL:-gpt-4.1-mini} - - # Optional: Additional provider API keys - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - - # Optional: Tool API keys - - TAVILY_API_KEY=${TAVILY_API_KEY:-} - - EXA_API_KEY=${EXA_API_KEY:-} - - SEARXNG_BASE_URL=${SEARXNG_BASE_URL:-} - # Internal configuration (do not change unless you know what you're doing) - PERSIST_TRANSCRIPTS=1 - IMAGE_STORAGE_PATH=/data/images diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index c62ee2a5..540fda15 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -5,47 +5,27 @@ ### Required ```env -OPENAI_API_KEY=your-api-key-here # OpenAI API key (or use PROVIDER_API_KEY) JWT_SECRET=your-secret-key-here # Secret for JWT authentication (required) ``` ### Optional - Core Settings ```env -DEFAULT_MODEL=gpt-4o-mini # Default AI model -DEFAULT_TITLE_MODEL=gpt-4o-mini # Model for conversation titles PORT=3001 # Server port RATE_LIMIT_WINDOW=60 # Rate limit window in seconds RATE_LIMIT_MAX=50 # Max requests per window CORS_ORIGIN=http://localhost:3000 # CORS allowed origin ``` -### Optional - Provider Configuration - -```env -PROVIDER=openai # Provider selection (openai, anthropic, gemini) -PROVIDER_BASE_URL= # Custom provider base URL -PROVIDER_API_KEY= # Generic provider API key -PROVIDER_CUSTOM_HEADERS={} # Custom headers as JSON -OPENAI_BASE_URL=https://api.openai.com/v1 # OpenAI base URL -ANTHROPIC_BASE_URL=https://api.anthropic.com # Anthropic base URL -ANTHROPIC_API_KEY= # Anthropic API key (if different from PROVIDER_API_KEY) -``` - -### Optional - Tool Configuration - -```env -TAVILY_API_KEY=your-tavily-key # Tavily web search (optional) -EXA_API_KEY=your-exa-key # Exa web search (optional) -SEARXNG_BASE_URL= # SearXNG instance URL (optional) -``` +> **Note:** Provider API keys, base URLs, and default model selections now live in user settings. +> Configure them through the app (Settings → Providers & Tools); they are no longer read from `.env` files. ### Optional - Timeouts ```env -PROVIDER_TIMEOUT=10000 # Provider operation timeout (ms) -MODEL_TIMEOUT=3000 # Model fetching timeout (ms) -STREAMING_TIMEOUT=300000 # Streaming timeout (ms) +PROVIDER_TIMEOUT_MS=10000 # Provider operation timeout (ms) +PROVIDER_MODEL_FETCH_TIMEOUT_MS=3000 # Model fetching timeout (ms) +PROVIDER_STREAM_TIMEOUT_MS=300000 # Streaming timeout (ms) ``` ### Optional - Logging diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index f52a0913..90f7496a 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -4,7 +4,7 @@ - Node.js 18 or higher - Docker and Docker Compose (for containerized deployment) -- OpenAI API key or compatible provider API key +- An OpenAI (or compatible) API key that you'll enter via in-app user settings ## Quick Start @@ -18,7 +18,6 @@ cd chat # Set up backend cp backend/.env.example backend/.env # Edit backend/.env and set: -# - OPENAI_API_KEY (your API key) # - JWT_SECRET (a secure random string for authentication) npm --prefix backend install @@ -41,7 +40,6 @@ Visit http://localhost:3000 # Copy environment files cp backend/.env.example backend/.env # Edit backend/.env and set: -# - OPENAI_API_KEY (your API key) # - JWT_SECRET (a secure random string for authentication) # Start with hot reload @@ -59,7 +57,6 @@ API requests from the browser can now target `http://localhost:3003/api` via the ```bash # Ensure required variables are set in backend/.env: -# - OPENAI_API_KEY # - JWT_SECRET ./prod.sh up --build @@ -79,9 +76,10 @@ Visit http://localhost:3000 See [ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md) for detailed environment variable documentation. **Minimal required configuration:** -- `OPENAI_API_KEY` - Your API key - `JWT_SECRET` - A secure random string for JWT authentication +Provider API keys, base URLs, and default models are now configured per user inside the product (Settings → Providers & Tools). + ## Next Steps - Check [DEVELOPMENT.md](DEVELOPMENT.md) for development workflow and available commands diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b46e04fa..ffa72e69 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -241,10 +241,10 @@ Response includes tool schemas formatted for your AI provider. ## Configuration -Tool behavior can be configured via environment variables: +Provider and tool credentials are now stored per user. Open **Settings → Search & Web Tools** in the app to add: -- `TAVILY_API_KEY` - Tavily web search API key -- `EXA_API_KEY` - Exa web search API key -- `SEARXNG_BASE_URL` - SearXNG instance URL +- **Tavily API Key** (for the `web_search` tool) +- **Exa API Key** (for the `web_search_exa` tool) +- **SearXNG Base URL** (for the `web_search_searxng` tool) -See [ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md) for complete configuration options. +No additional environment variables are required for these tools anymore. diff --git a/docs/tool_orchestration_deep_dive.md b/docs/tool_orchestration_deep_dive.md index 23894891..3e2478ef 100644 --- a/docs/tool_orchestration_deep_dive.md +++ b/docs/tool_orchestration_deep_dive.md @@ -1089,7 +1089,7 @@ async function handler(args, context = {}) { } if (!apiKey) { - apiKey = process.env.TAVILY_API_KEY; // Fall back to global key + throw new Error('Tavily API key is not configured. Please add it in Settings → Search & Web Tools.'); } // ... execute search with apiKey @@ -1214,7 +1214,9 @@ if (!response.ok) { // 401/403 - Authentication/authorization issues (infra) if (response.status === 401 || response.status === 403) { - throw new Error(`Tavily API authentication failed: ${apiErrorMessage} (Check TAVILY_API_KEY configuration)`); + throw new Error( + `Tavily API authentication failed: ${apiErrorMessage} (Verify your Tavily API key under Settings → Search & Web Tools)` + ); } // 429 - Rate limiting (infra) diff --git a/requests/openai.http b/requests/openai.http index f7dbd1ba..a5bebe5b 100644 --- a/requests/openai.http +++ b/requests/openai.http @@ -1,6 +1,6 @@ ### POST request to openAI completions API POST https://api.openai.com/v1/chat/completions -Authorization: Bearer {{OPENAI_API_KEY}} +Authorization: Bearer {{UPSTREAM_API_KEY}} Accept: text/event-stream Content-Type: application/json From 9f6513b5e6f1e926f89cbfcae8024b1d096b34a6 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 25 Nov 2025 21:52:16 +0700 Subject: [PATCH 04/11] refactor: merge into one service --- Dockerfile | 49 +++++++++++++++++++++ backend/src/index.js | 17 ++++++++ docker-compose.hub.yml | 74 ------------------------------- docker-compose.yml | 97 ++++++++++++++--------------------------- frontend/next.config.ts | 1 + prod.sh | 22 +++++----- 6 files changed, 110 insertions(+), 150 deletions(-) create mode 100644 Dockerfile delete mode 100644 docker-compose.hub.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e255963b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +ARG NODE_IMAGE=node:20.18.0-alpine3.20 + +# --- Frontend Build Stage --- +FROM ${NODE_IMAGE} AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ . +ENV NEXT_PUBLIC_API_BASE=/api +RUN npm run build + +# --- Backend Build Stage --- +FROM ${NODE_IMAGE} AS backend-builder +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm ci --omit=dev + +# --- Final Stage --- +FROM ${NODE_IMAGE} AS runner +WORKDIR /app + +# Install runtime dependencies +RUN apk add --no-cache su-exec sqlite-libs + +# Copy backend dependencies +COPY --from=backend-builder --chown=node:node /app/backend/node_modules ./node_modules + +# Copy backend source +COPY --chown=node:node backend/ . + +# Copy frontend build to backend/public +COPY --from=frontend-builder --chown=node:node /app/frontend/out ./public + +# Setup permissions and directories +RUN mkdir -p logs && chown -R node:node /app +RUN chmod +x entrypoint.sh + +ENV NODE_ENV=production +ENV PORT=3000 +ENV INSTALL_ON_START=0 + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT || 3000}/health`).then(res => { if (res.ok) process.exit(0); process.exit(1); }).catch(() => process.exit(1));"] + +ENTRYPOINT ["./entrypoint.sh"] +CMD ["node","src/index.js"] diff --git a/backend/src/index.js b/backend/src/index.js index 87147268..fd77721a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,4 +1,6 @@ import express from 'express'; +import path from 'path'; +import { fileURLToPath } from 'url'; import cors from 'cors'; import { config } from './env.js'; import { rateLimit } from './middleware/rateLimit.js'; @@ -57,6 +59,21 @@ import { exceptionHandler } from './middleware/exceptionHandler.js'; app.use(exceptionHandler); +// Serve static files from the React app +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const buildPath = path.join(__dirname, '../public'); + +if (process.env.NODE_ENV === 'production') { + app.use(express.static(buildPath)); + + // The "catchall" handler: for any request that doesn't + // match one above, send back React's index.html file. + app.get('*', (req, res) => { + res.sendFile(path.join(buildPath, 'index.html')); + }); +} + // Database initialization and retention worker (Sprint 3) import { getDb } from './db/client.js'; import { retentionSweep } from './db/retention.js'; diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml deleted file mode 100644 index e65e5411..00000000 --- a/docker-compose.hub.yml +++ /dev/null @@ -1,74 +0,0 @@ -# ChatForge - One-Click Deployment -# -# Quick Start: -# 1. Create a .env file with your settings: -# echo "JWT_SECRET=change-me" > .env -# 2. Start the stack: -# docker compose -f docker-compose.hub.yml up -d -# 3. Visit http://localhost:3000, register your first user, and add your provider API key via Settings → Providers & Tools -# -# Optional environment variables (add to .env file): -# JWT_SECRET=your-secret-here # Auto-generated if not set -# PORT=3000 # External port (defaults to 3000) -# -# For more options, see: https://github.com/qduc/chat/blob/main/docs/ENVIRONMENT_VARIABLES.md - -services: - backend: - image: qduc/chatforge-backend:latest - environment: - # Security: JWT secret (auto-generated if not provided) - - JWT_SECRET=${JWT_SECRET:-} - - # Internal configuration (do not change unless you know what you're doing) - - PERSIST_TRANSCRIPTS=1 - - IMAGE_STORAGE_PATH=/data/images - - FILE_STORAGE_PATH=/data/files - - DB_URL=file:/data/prod.db - - NODE_ENV=production - - PORT=3001 - - CORS_ORIGIN=* - volumes: - - chatforge_data:/data - - chatforge_logs:/app/logs - restart: unless-stopped - healthcheck: - test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(res => { if (res.ok) process.exit(0); process.exit(1); }).catch(() => process.exit(1));"] - interval: 30s - timeout: 10s - start_period: 30s - retries: 3 - - frontend: - image: qduc/chatforge-frontend:latest - environment: - - NEXT_PUBLIC_API_BASE=/api - - BACKEND_ORIGIN=http://backend:3001 - - NODE_ENV=production - depends_on: - backend: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(res => { if (res.ok) process.exit(0); process.exit(1); }).catch(() => process.exit(1));"] - interval: 30s - timeout: 10s - start_period: 30s - retries: 3 - - proxy: - image: qduc/chatforge-proxy:latest - depends_on: - frontend: - condition: service_started - backend: - condition: service_healthy - ports: - - "${PORT:-3000}:80" - restart: unless-stopped - -volumes: - chatforge_data: - driver: local - chatforge_logs: - driver: local diff --git a/docker-compose.yml b/docker-compose.yml index ee8b1ae4..e2e31993 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,83 +1,50 @@ +# ChatForge - Production Deployment +# +# Quick Start: +# 1. Create a .env file with your settings: +# echo "JWT_SECRET=change-me" > .env +# 2. Start the stack: +# ./prod.sh up -d +# +# Optional environment variables (add to .env file): +# JWT_SECRET=your-secret-here # Auto-generated if not set +# PORT=3000 # External port (defaults to 3000) +# +# For more options, see: https://github.com/qduc/chat/blob/main/docs/ENVIRONMENT_VARIABLES.md + services: - backend: + app: build: - context: ./backend - env_file: - - ./backend/.env + context: . + target: runner + image: qduc/chatforge:latest environment: + # Security: JWT secret (auto-generated if not provided) + - JWT_SECRET=${JWT_SECRET:-} + + # Internal configuration - PERSIST_TRANSCRIPTS=1 - # Store uploaded images on a Docker volume - IMAGE_STORAGE_PATH=/data/images + - FILE_STORAGE_PATH=/data/files - DB_URL=file:/data/prod.db - NODE_ENV=production + - PORT=3000 + - CORS_ORIGIN=* volumes: - - db_data:/data - # store uploaded images in a separate named volume mounted at /data/images - - images_data:/data/images - # persist application logs - - logs_data:/app/logs - restart: unless-stopped - healthcheck: - test: - ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(res => { if (res.ok) process.exit(0); process.exit(1); }).catch(() => process.exit(1));"] - interval: 30s - timeout: 10s - start_period: 30s - retries: 3 - frontend: - build: - context: ./frontend - args: - NEXT_PUBLIC_API_BASE: /api - BACKEND_ORIGIN: http://backend:3001 - environment: - - NEXT_PUBLIC_API_BASE=/api - - BACKEND_ORIGIN=http://backend:3001 - - NODE_ENV=production - depends_on: - backend: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: - ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(res => { if (res.ok) process.exit(0); process.exit(1); }).catch(() => process.exit(1));"] - interval: 30s - timeout: 10s - start_period: 30s - retries: 3 - proxy: - image: nginx:1.27-alpine - init: true - hostname: chatforge-proxy - depends_on: - frontend: - condition: service_started - backend: - condition: service_healthy - volumes: - - ./proxy/nginx.conf:/etc/nginx/nginx.conf:ro + - chatforge_data:/data + - chatforge_logs:/app/logs ports: - - "3000:80" + - "${PORT:-3000}:3000" restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1/health || exit 1"] + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(res => { if (res.ok) process.exit(0); process.exit(1); }).catch(() => process.exit(1));"] interval: 30s timeout: 10s + start_period: 30s retries: 3 - start_period: 10s - networks: - - frontend - - default - -networks: - frontend: volumes: - db_data: - driver: local - # Optional separate volume if you want to separate DB and images - images_data: + chatforge_data: driver: local - # Persist application logs - logs_data: + chatforge_logs: driver: local diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 5ac7655e..a1fab2aa 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,6 +5,7 @@ const backendOrigin = (process.env.BACKEND_ORIGIN || 'http://localhost:3001').re const nextConfig: NextConfig = { // Disable gzip compression to ensure SSE streams flush properly compress: false, + output: 'export', async rewrites() { return [ { diff --git a/prod.sh b/prod.sh index afc7b1f1..af35add0 100755 --- a/prod.sh +++ b/prod.sh @@ -29,8 +29,8 @@ Production Management Commands: Examples: $(basename "$0") up # Start services in detached mode $(basename "$0") up --build # Rebuild and start services - $(basename "$0") logs -f backend # Follow backend logs - $(basename "$0") exec backend sh # Open shell in backend container + $(basename "$0") logs -f app # Follow app logs + $(basename "$0") exec app sh # Open shell in app container $(basename "$0") health # Check health of all services $(basename "$0") migrate status # Check migration status $(basename "$0") migrate up # Apply pending migrations @@ -62,7 +62,7 @@ check_health() { echo "Checking service health..." local all_healthy=true - for service in backend frontend; do + for service in app; do if "${DC[@]}" ps --status running "$service" &>/dev/null; then if docker inspect "$("${DC[@]}" ps -q "$service" 2>/dev/null || echo '')" --format='{{.State.Health.Status}}' 2>/dev/null | grep -q "healthy"; then echo -e "${GREEN}✓${NC} $service is healthy" @@ -96,15 +96,15 @@ backup_database() { echo "Creating database backup..." - # Check if backend is running - backend_cid="$("${DC[@]}" ps -q backend 2>/dev/null || echo '')" + # Check if app is running + backend_cid="$("${DC[@]}" ps -q app 2>/dev/null || echo '')" if [ -n "$backend_cid" ]; then - "${DC[@]}" exec -T backend sh -c "cp /data/prod.db /data/backup_$timestamp.db" + "${DC[@]}" exec -T app sh -c "cp /data/prod.db /data/backup_$timestamp.db" docker cp "$backend_cid:/data/backup_$timestamp.db" "$backup_file" - "${DC[@]}" exec -T backend sh -c "rm /data/backup_$timestamp.db" + "${DC[@]}" exec -T app sh -c "rm /data/backup_$timestamp.db" else - echo -e "${YELLOW}Backend is not running. Attempting to backup from volume...${NC}" + echo -e "${YELLOW}App is not running. Attempting to backup from volume...${NC}" # Create a temporary container to access the volume docker run --rm -v chat_db_data:/data -v "$backup_dir:/backup" alpine cp /data/prod.db "/backup/prod_db_backup_$timestamp.db" fi @@ -131,7 +131,7 @@ migrate_command() { case "$subcommand" in status) echo "Checking migration status..." - "${DC[@]}" exec -T backend npm run migrate status "$@" + "${DC[@]}" exec -T app npm run migrate status "$@" ;; up) echo -e "${YELLOW}Applying pending migrations...${NC}" @@ -139,7 +139,7 @@ migrate_command() { # Create backup before migration backup_database echo "Running migrations..." - "${DC[@]}" exec -T backend npm run migrate up "$@" + "${DC[@]}" exec -T app npm run migrate up "$@" echo -e "${GREEN}✓${NC} Migrations applied successfully" fi ;; @@ -151,7 +151,7 @@ migrate_command() { # Create backup before destructive operation backup_database echo "Resetting database..." - "${DC[@]}" exec -T backend npm run migrate fresh "$@" + "${DC[@]}" exec -T app npm run migrate fresh "$@" echo -e "${GREEN}✓${NC} Database reset complete" else echo -e "${RED}Operation cancelled.${NC}" From 33eb50d88de12c11972b39b32e99d0f32c19ef32 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 25 Nov 2025 22:08:01 +0700 Subject: [PATCH 05/11] refactor: streamline CI process and consolidate Docker image publishing --- .github/workflows/ci.yml | 26 ++++------ .github/workflows/docker-publish.yml | 66 ++++++++----------------- backend/src/index.js | 20 ++++---- ci.sh | 72 ++++++++++++++++++++++++++++ docker-compose.yml | 2 +- 5 files changed, 113 insertions(+), 73 deletions(-) create mode 100755 ci.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fce16a9e..5209cf0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,15 +19,10 @@ jobs: cache: 'npm' cache-dependency-path: backend/package-lock.json - - name: Install Backend Dependencies - run: npm --prefix backend install - - - name: Lint Backend - run: npm --prefix backend run lint - - - name: Test Backend - run: npm --prefix backend test - timeout-minutes: 1 + - name: Run Backend CI + run: | + chmod +x ci.sh + ./ci.sh backend frontend: runs-on: ubuntu-latest @@ -43,15 +38,10 @@ jobs: cache: 'npm' cache-dependency-path: frontend/package-lock.json - - name: Install Frontend Dependencies - run: npm --prefix frontend install - - - name: Lint Frontend - run: npm --prefix frontend run lint - - - name: Test Frontend - run: npm --prefix frontend test - timeout-minutes: 1 + - name: Run Frontend CI + run: | + chmod +x ci.sh + ./ci.sh frontend # This job will only run if both backend and frontend jobs succeed # The workflow will fail if either backend or frontend fails diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fafa11b7..17e193e4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Build and Publish Docker Images +name: Build and Publish Docker Image on: push: @@ -13,9 +13,7 @@ on: env: REGISTRY: docker.io - BACKEND_IMAGE: qduc/chatforge-backend - FRONTEND_IMAGE: qduc/chatforge-frontend - PROXY_IMAGE: qduc/chatforge-proxy + IMAGE_NAME: qduc/chatforge jobs: build-and-push: @@ -51,63 +49,39 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "version_short=${VERSION#v}" >> $GITHUB_OUTPUT - - name: Build and push backend image + - name: Build and push Docker image uses: docker/build-push-action@v5 with: - context: ./backend + context: . + target: runner platforms: linux/amd64,linux/arm64 push: true tags: | - ${{ env.BACKEND_IMAGE }}:${{ steps.version.outputs.version }} - ${{ env.BACKEND_IMAGE }}:${{ steps.version.outputs.version_short }} - ${{ env.BACKEND_IMAGE }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Build and push frontend image - uses: docker/build-push-action@v5 - with: - context: ./frontend - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - NEXT_PUBLIC_API_BASE=/api - BACKEND_ORIGIN=http://backend:3001 - tags: | - ${{ env.FRONTEND_IMAGE }}:${{ steps.version.outputs.version }} - ${{ env.FRONTEND_IMAGE }}:${{ steps.version.outputs.version_short }} - ${{ env.FRONTEND_IMAGE }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Build and push proxy image - uses: docker/build-push-action@v5 - with: - context: ./proxy - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ${{ env.PROXY_IMAGE }}:${{ steps.version.outputs.version }} - ${{ env.PROXY_IMAGE }}:${{ steps.version.outputs.version_short }} - ${{ env.PROXY_IMAGE }}:latest + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version_short }} + ${{ env.IMAGE_NAME }}:latest cache-from: type=gha cache-to: type=gha,mode=max - name: Create release summary run: | - echo "## Docker Images Published 🚀" >> $GITHUB_STEP_SUMMARY + echo "## Docker Image Published 🚀" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Version: \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "### Images" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ env.BACKEND_IMAGE }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ env.FRONTEND_IMAGE }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ env.PROXY_IMAGE }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "### Image" >> $GITHUB_STEP_SUMMARY + echo "- \`${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`${{ env.IMAGE_NAME }}:latest\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Quick Start" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "curl -O https://raw.githubusercontent.com/qduc/chat/main/docker-compose.hub.yml" >> $GITHUB_STEP_SUMMARY - echo "echo 'JWT_SECRET=change-me' > .env" >> $GITHUB_STEP_SUMMARY - echo "docker compose -f docker-compose.hub.yml up -d" >> $GITHUB_STEP_SUMMARY + echo "docker run -d -p 3000:3000 -v chatforge_data:/data ${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Or with docker-compose:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "curl -O https://raw.githubusercontent.com/qduc/chat/main/docker-compose.yml" >> $GITHUB_STEP_SUMMARY + echo "docker compose up -d" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "Then visit http://localhost:3000 and add your provider API key via Settings → Providers & Tools." >> $GITHUB_STEP_SUMMARY diff --git a/backend/src/index.js b/backend/src/index.js index fd77721a..45fb4888 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -46,14 +46,18 @@ if (process.env.NODE_ENV === 'production') { } app.use(healthRouter); -app.use('/v1/auth', authRouter); -app.use(imagesRouter); // Must be before auth-protected routers -app.use(filesRouter); // File upload routes -app.use(conversationsRouter); -app.use(providersRouter); -app.use(userSettingsRouter); -app.use(systemPromptsRouter); -app.use(chatRouter); + +const apiRouter = express.Router(); +apiRouter.use('/v1/auth', authRouter); +apiRouter.use(imagesRouter); // Must be before auth-protected routers +apiRouter.use(filesRouter); // File upload routes +apiRouter.use(conversationsRouter); +apiRouter.use(providersRouter); +apiRouter.use(userSettingsRouter); +apiRouter.use(systemPromptsRouter); +apiRouter.use(chatRouter); + +app.use('/api', apiRouter); import { exceptionHandler } from './middleware/exceptionHandler.js'; diff --git a/ci.sh b/ci.sh new file mode 100755 index 00000000..dde82e67 --- /dev/null +++ b/ci.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +run_component() { + local component=$1 + local dir="$ROOT/$component" + + log_info "Starting CI for $component..." + + if [ ! -d "$dir" ]; then + log_error "Directory $dir does not exist." + exit 1 + fi + + pushd "$dir" > /dev/null + + log_info "Installing dependencies for $component..." + npm install + + log_info "Linting $component..." + npm run lint + + log_info "Testing $component..." + npm test + + popd > /dev/null + log_success "CI for $component completed successfully." +} + +cmd=${1:-all} + +case "$cmd" in + backend) + run_component "backend" + ;; + frontend) + run_component "frontend" + ;; + all) + run_component "backend" + run_component "frontend" + ;; + -h|--help) + echo "Usage: $0 [all|backend|frontend]" + exit 0 + ;; + *) + log_error "Unknown command: $cmd" + echo "Usage: $0 [all|backend|frontend]" + exit 1 + ;; +esac diff --git a/docker-compose.yml b/docker-compose.yml index e2e31993..6817c810 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: - DB_URL=file:/data/prod.db - NODE_ENV=production - PORT=3000 - - CORS_ORIGIN=* + - ALLOWED_ORIGIN=* volumes: - chatforge_data:/data - chatforge_logs:/app/logs From 24b48eee5df371e36d4a50c92398b68d13f13fb4 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 25 Nov 2025 22:36:05 +0700 Subject: [PATCH 06/11] update docs --- AGENTS.md | 7 +++++-- README.md | 11 +++++++++-- docs/ARCHITECTURE.md | 13 ++++++------- docs/DEVELOPMENT.md | 2 ++ docs/INSTALLATION.md | 2 ++ frontend/app/api/health/route.ts | 11 ----------- 6 files changed, 24 insertions(+), 22 deletions(-) delete mode 100644 frontend/app/api/health/route.ts diff --git a/AGENTS.md b/AGENTS.md index f9421474..e2634f61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ chat/ frontend/ # Next.js 15 + React 19 + TypeScript backend/ # Node.js + Express + SQLite docs/ # Architecture docs and ADRs - proxy/ # Nginx reverse proxy config + proxy/ # Dev-only Nginx reverse proxy config (compose.dev) integration/ # Integration tests requests/ # HTTP request examples dev.sh # Development orchestration script @@ -36,6 +36,8 @@ chat/ - Server-side tool orchestration - User-based authentication and authorization +> **Production bundling:** The root multi-stage `Dockerfile` exports the Next.js app and copies it into the Express backend, so the `app` container (managed by `prod.sh` / `docker-compose.yml`) serves both `/api` and static assets. The standalone `frontend`, `backend`, and `proxy` containers only exist in the development compose stack for hot reload. + ### Core Design Principles 1. **User-Based Data Isolation**: All data operations are scoped to authenticated users (enforced at database level with NOT NULL user_id constraints) @@ -116,6 +118,7 @@ chat/ ./prod.sh backup # Create database backup ./prod.sh exec # Execute command in container ``` +> In production there is a single `app` service. Most `prod.sh exec` commands therefore look like `./prod.sh exec app `. ### Release Management ```bash @@ -206,7 +209,7 @@ chat/ - **JWT Authentication**: Token-based authentication with bcrypt password hashing and refresh token support - **Streaming Protocol**: SSE for real-time updates with usage metadata tracking - **API Compatibility**: Maintains OpenAI API contract while extending functionality -- **Reverse Proxy**: Nginx proxy routes /api requests to backend in Docker deployments +- **Dev Reverse Proxy**: Nginx proxy routes /api requests to backend in the Docker *development* stack (production bundles everything into one container) - **Image Storage**: Secure image metadata storage with path-based access control and validation (max 10MB, 5 images/message) - **File Storage**: Text file uploads with content extraction (max 5MB, 3 files/message, 30+ file types supported) - **Conversation Snapshots**: Each conversation maintains complete settings snapshot for reproducibility diff --git a/README.md b/README.md index bd812b57..06a81984 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ docker compose -f docker-compose.hub.yml up -d Visit http://localhost:3000, register your first user, then open **Settings → Providers & Tools** to enter your API key and base URL. +The production compose file now runs a single `app` service built from the root multi-stage `Dockerfile`. That container bundles the Express API, the exported Next.js UI, and the static asset server, so there is no longer a separate frontend or nginx proxy to operate in production. + **Optional infrastructure config** (add to `.env` file): ```bash JWT_SECRET=your-secret-here # Overrides auto-generated secret @@ -73,7 +75,7 @@ cp backend/.env.example backend/.env ./dev.sh logs -f ``` -Visit http://localhost:3003 +Visit http://localhost:3003. The development compose file still runs dedicated `frontend`, `backend`, and `proxy` containers to keep hot reload fast, but production images collapse into a single runtime service. For alternative setup options, see [docs/INSTALLATION.md](docs/INSTALLATION.md). @@ -99,7 +101,7 @@ chat/ ├── frontend/ # Next.js 15 + React 19 + TypeScript ├── backend/ # Node.js + Express + SQLite ├── docs/ # Technical documentation -├── proxy/ # Nginx reverse proxy config +├── proxy/ # Dev-only Nginx reverse proxy config ├── integration/ # Integration tests ├── requests/ # HTTP request examples ├── dev.sh # Development orchestration @@ -115,6 +117,11 @@ chat/ ./dev.sh test:frontend # Frontend tests only ``` +## Deployment Architecture + +- **Production (`docker-compose.yml`, `prod.sh`)** – Single `app` container generated by the top-level `Dockerfile`. The multi-stage build compiles the Next.js frontend to a static export and copies it into the Express backend, which serves both `/api` and the UI while persisting data/logs under `/data`. +- **Development (`docker-compose.dev.yml`, `dev.sh`)** – Dedicated `frontend`, `backend`, `proxy`, and `adminer` services for fast iteration with hot reload. The nginx proxy that provides the http://localhost:3003 origin only exists in this dev stack. + ## Contributing Contributions are welcome! Please follow these guidelines: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3a9e0e1..0588deb5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,7 +51,7 @@ chat/ │ ├── scripts/ # Database migrations │ └── __tests__/ # Backend test suite ├── docs/ # Architecture documentation -├── proxy/ # Nginx reverse proxy configuration +├── proxy/ # Dev-only Nginx reverse proxy configuration ├── integration/ # Integration tests ├── requests/ # HTTP request examples └── docker-compose files # Container orchestration @@ -167,17 +167,16 @@ See [TOOLS.md](TOOLS.md) for adding new tools. ### Development -- **Docker Compose** - Orchestrates frontend, backend, proxy, and adminer +- **Docker Compose** - Orchestrates frontend, backend, proxy, and adminer for hot reload - **Hot Reload** - Changes automatically reload during development - **Unified Network** - Containers communicate via docker network ### Production -- **Nginx Proxy** - Reverse proxy at port 3000 -- **Frontend** - Next.js on port 3001 -- **Backend** - Express on port 3002 -- **Database** - SQLite persistent volume -- **Backups** - Automatic database backup support +- **Single App Container** - Multi-stage `Dockerfile` builds the frontend export and copies it into the Express backend, which serves both `/api` and the UI from one process +- **Volumes** - `/data` for SQLite + uploads, `/app/logs` for rolling logs +- **Runtime** - Same Express server handles health checks and static assets on port 3000 (configurable) +- **Backups** - `prod.sh backup` copies the SQLite database from the shared volume ## Documentation diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 45629d31..cad3e320 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -25,6 +25,8 @@ All development commands run in Docker via `./dev.sh` or directly via npm. ./prod.sh ps # Show service status ``` +> Production currently runs a single `app` container (built from the root `Dockerfile`) that bundles the Express backend and exported Next.js frontend. When you run `./prod.sh exec`, target the `app` service. + ### Logs #### Development diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 90f7496a..1cfdd3d4 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -34,6 +34,8 @@ npm --prefix frontend run dev Visit http://localhost:3000 +The production compose stack now exposes a single `app` container built from the root `Dockerfile`. That image bundles the Express API and the exported Next.js UI, so `/api` and the frontend are both served from port 3000 (or whatever you set via `PORT`). + ### Option 2: Docker Development (Recommended) ```bash diff --git a/frontend/app/api/health/route.ts b/frontend/app/api/health/route.ts deleted file mode 100644 index 7a224efa..00000000 --- a/frontend/app/api/health/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from 'next/server'; - -export function GET() { - return NextResponse.json( - { - status: 'ok', - timestamp: new Date().toISOString(), - }, - { headers: { 'Cache-Control': 'no-store' } } - ); -} From ccb72adddc0fc9375748a7336533fda1add078f6 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 25 Nov 2025 22:42:24 +0700 Subject: [PATCH 07/11] optimize docker build --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e255963b..29ad6d8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,8 @@ COPY --chown=node:node backend/ . COPY --from=frontend-builder --chown=node:node /app/frontend/out ./public # Setup permissions and directories -RUN mkdir -p logs && chown -R node:node /app -RUN chmod +x entrypoint.sh +RUN mkdir -p logs && chown node:node logs +RUN chmod +x entrypoint.sh && chown node:node entrypoint.sh ENV NODE_ENV=production ENV PORT=3000 From 98367f881640738e398db92f4f8abb74b1a60c9a Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 25 Nov 2025 22:44:46 +0700 Subject: [PATCH 08/11] fix permission --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 29ad6d8d..c2a482e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,7 @@ COPY --from=frontend-builder --chown=node:node /app/frontend/out ./public # Setup permissions and directories RUN mkdir -p logs && chown node:node logs +RUN mkdir -p /data && chown node:node /data RUN chmod +x entrypoint.sh && chown node:node entrypoint.sh ENV NODE_ENV=production From 1398abac3aa6c6739b8c05636a44b9d6089de8c2 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Wed, 26 Nov 2025 21:22:25 +0700 Subject: [PATCH 09/11] fix build error --- .dockerignore | 51 ++++++++++++++++++++++++++++++ Dockerfile | 17 +++++++--- backend/entrypoint.sh | 7 +++- backend/src/index.js | 2 +- backend/src/routes/userSettings.js | 6 ++-- docker-compose.yml | 4 +++ 6 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..18045ca1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# Ignore node_modules to prevent host-compiled binaries from contaminating the build +node_modules +*/node_modules + +# Development files +.git +.gitignore +*.md +!AGENTS.md +!CLAUDE.md + +# Environment files +.env +.env.local +.env.*.local + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Test coverage +coverage +.nyc_output + +# Build artifacts +dist +build +out +.next + +# Docker +docker-compose.override.yml + +# Data +data +*.db +*.db-* diff --git a/Dockerfile b/Dockerfile index c2a482e5..0c208b0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG NODE_IMAGE=node:20.18.0-alpine3.20 +ARG NODE_IMAGE=node:22.18.0-bookworm-slim # --- Frontend Build Stage --- FROM ${NODE_IMAGE} AS frontend-builder @@ -12,15 +12,24 @@ RUN npm run build # --- Backend Build Stage --- FROM ${NODE_IMAGE} AS backend-builder WORKDIR /app/backend + +# Install build dependencies for native modules +RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* + COPY backend/package*.json ./ -RUN npm ci --omit=dev + +# Clean install to ensure proper compilation in container +RUN npm ci --omit=dev --build-from-source + +# Verify better-sqlite3 was built correctly +RUN node -e "require('better-sqlite3'); console.log('better-sqlite3 loaded successfully')" # --- Final Stage --- FROM ${NODE_IMAGE} AS runner WORKDIR /app -# Install runtime dependencies -RUN apk add --no-cache su-exec sqlite-libs +# Install runtime dependencies (gosu is Debian equivalent of su-exec) +RUN apt-get update && apt-get install -y gosu libsqlite3-0 && rm -rf /var/lib/apt/lists/* # Copy backend dependencies COPY --from=backend-builder --chown=node:node /app/backend/node_modules ./node_modules diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 3f28a6b2..f0b9d7de 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -39,7 +39,12 @@ if [ "$(id -u)" = "0" ]; then if [ -z "$SU_EXEC_DONE" ]; then export SU_EXEC_DONE=1 - exec su-exec "$APP_USER:$APP_GROUP" "$0" "$@" + # Use gosu on Debian or su-exec on Alpine + if command -v gosu >/dev/null 2>&1; then + exec gosu "$APP_USER:$APP_GROUP" "$0" "$@" + else + exec su-exec "$APP_USER:$APP_GROUP" "$0" "$@" + fi else echo "Already switched user, not re-executing." exit 1 diff --git a/backend/src/index.js b/backend/src/index.js index 45fb4888..24627d6d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -73,7 +73,7 @@ if (process.env.NODE_ENV === 'production') { // The "catchall" handler: for any request that doesn't // match one above, send back React's index.html file. - app.get('*', (req, res) => { + app.get('/{*path}', (req, res) => { res.sendFile(path.join(buildPath, 'index.html')); }); } diff --git a/backend/src/routes/userSettings.js b/backend/src/routes/userSettings.js index 18924d83..1691a473 100644 --- a/backend/src/routes/userSettings.js +++ b/backend/src/routes/userSettings.js @@ -4,10 +4,10 @@ import { authenticateToken } from '../middleware/auth.js'; import { upsertUserSetting } from '../db/userSettings.js'; import { getAllUserSettings } from '../db/getAllUserSettings.js'; -const router = Router(); -router.use(authenticateToken); - export function createUserSettingsRouter() { + const router = Router(); + router.use(authenticateToken); + // Unified update route for all keys router.put('/v1/user-settings', (req, res) => { try { diff --git a/docker-compose.yml b/docker-compose.yml index 6817c810..e2c3bd52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,10 @@ services: - NODE_ENV=production - PORT=3000 - ALLOWED_ORIGIN=* + + # Rate limiting (optional) + - RATE_LIMIT_WINDOW_SEC=${RATE_LIMIT_WINDOW_SEC:-60} + - RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-100} volumes: - chatforge_data:/data - chatforge_logs:/app/logs From 27f4c7e011dbe979b3387c2afb08c8fc043a0d97 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Wed, 26 Nov 2025 22:14:29 +0700 Subject: [PATCH 10/11] fix: correct Docker image name and update username variable reference --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 17e193e4..17f9d3d2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,7 +13,7 @@ on: env: REGISTRY: docker.io - IMAGE_NAME: qduc/chatforge + IMAGE_NAME: qduc/chat jobs: build-and-push: @@ -35,7 +35,7 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} + username: ${{ variables.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract version tag From 740806f6981bcacad17ffbbd8c0a84646d8a89c2 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Wed, 26 Nov 2025 22:25:25 +0700 Subject: [PATCH 11/11] fix test --- backend/__tests__/providers.interface.test.js | 2 +- backend/src/lib/toolsStreaming.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/providers.interface.test.js b/backend/__tests__/providers.interface.test.js index 23bb3661..7d54d852 100644 --- a/backend/__tests__/providers.interface.test.js +++ b/backend/__tests__/providers.interface.test.js @@ -5,7 +5,7 @@ import { BaseProvider } from '../src/lib/providers/baseProvider.js'; describe('Provider Interface Compliance', () => { const providers = [ - { name: 'OpenAIProvider', Provider: OpenAIProvider, expectedTranslation: false }, + { name: 'OpenAIProvider', Provider: OpenAIProvider, expectedTranslation: true }, { name: 'AnthropicProvider', Provider: AnthropicProvider, expectedTranslation: true }, { name: 'GeminiProvider', Provider: GeminiProvider, expectedTranslation: true }, ]; diff --git a/backend/src/lib/toolsStreaming.js b/backend/src/lib/toolsStreaming.js index 518f2b63..b32c2523 100644 --- a/backend/src/lib/toolsStreaming.js +++ b/backend/src/lib/toolsStreaming.js @@ -317,7 +317,7 @@ export async function handleToolsStreaming({ leftoverIter = parseSSEStream( chunk, leftoverIter, - ((obj) => { + (obj) => { // Capture response_id from any chunk if (obj?.id && !responseId) { responseId = obj.id; @@ -398,7 +398,7 @@ export async function handleToolsStreaming({ }, () => { /* ignore JSON parse errors for this stream */ - }) + } ); } catch (e) { cleanup();