From 5be5ca337632ed96aa1a62c6adbeb079ce167506 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 19 Oct 2026 21:30:00 +0800 Subject: [PATCH] =?UTF-8?q?chore(deploy):=20=E6=9C=AC=E5=9C=B0=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E8=84=9A=E6=9C=AC=E3=80=81=E5=AE=B9=E5=99=A8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E8=BF=90=E8=A1=8C=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostgreSQL/pgvector 与 Redis 独立容器,后端、worker、前端可一键启停;不覆盖既有密钥。 --- Dockerfile | 57 ++++++ Dockerfile.backend.prod | 62 +++++++ Dockerfile.frontend | 46 +++++ Dockerfile.frontend.prod | 64 +++++++ Dockerfile.postgres | 31 ++++ LOCAL_DEPLOY.md | 92 ++++++++++ backend/scripts/encrypt_chat_at_rest.py | 206 ++++++++++++++++++++++ backend/scripts/start.sh | 48 +++++ deploy/local/compose.yaml | 40 +++++ deploy/local/demo.template.html | 45 +++++ deploy/local/init_env.py | 62 +++++++ deploy/local/local.sh | 159 +++++++++++++++++ deploy/local/requirements.lock.txt | 222 ++++++++++++++++++++++++ deploy/local/seed_test.py | 107 ++++++++++++ docker-compose.prod.yml | 199 +++++++++++++++++++++ docker-compose.yml | 218 +++++++++++++++++++++++ frontend/docker-entrypoint.sh | 40 +++++ frontend/nginx.conf | 98 +++++++++++ 18 files changed, 1796 insertions(+) create mode 100644 Dockerfile create mode 100644 Dockerfile.backend.prod create mode 100644 Dockerfile.frontend create mode 100644 Dockerfile.frontend.prod create mode 100644 Dockerfile.postgres create mode 100644 LOCAL_DEPLOY.md create mode 100644 backend/scripts/encrypt_chat_at_rest.py create mode 100644 backend/scripts/start.sh create mode 100644 deploy/local/compose.yaml create mode 100644 deploy/local/demo.template.html create mode 100644 deploy/local/init_env.py create mode 100755 deploy/local/local.sh create mode 100644 deploy/local/requirements.lock.txt create mode 100644 deploy/local/seed_test.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 frontend/docker-entrypoint.sh create mode 100644 frontend/nginx.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..13676f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + netcat-traditional \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies. Bump pip's per-request timeout and retry count so +# the large opencv/onnxruntime wheel downloads survive slow links and the +# heavier amd64/x86_64 wheels don't abort with ReadTimeoutError. +ENV PIP_DEFAULT_TIMEOUT=120 +COPY backend/requirements.txt . +RUN pip install --no-cache-dir --retries 10 -r requirements.txt + +# Node.js + npx and uv/uvx for STDIO MCP servers (npx @elastic/mcp-server-…, +# uvx mcp-server-…). Copied from the official images instead of apt, which +# only ships an EOL Node 18 on bookworm. Kept below the pip layer so an +# upstream node/uv tag bump can't invalidate the cached pip layer. +COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22-slim /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /uvx /usr/local/bin/ + +# Copy application code +COPY backend/app ./app +COPY backend/alembic.ini . +COPY backend/alembic ./alembic +COPY backend/scripts ./scripts +COPY backend/assets ./assets + +# Create required directories including cache directories +RUN mkdir -p uploads/agents && \ + mkdir -p .cache/huggingface/hub && \ + chmod -R 755 .cache + +# Make startup script executable +RUN chmod +x ./scripts/start.sh + +# Set environment variables +ENV PYTHONPATH=/app +ENV PORT=8000 +# Set HuggingFace cache directories +ENV HF_HOME=/app/.cache/huggingface +ENV HF_HUB_CACHE=/app/.cache/huggingface/hub +ENV HF_HUB_DISABLE_TELEMETRY=1 + +# Expose the port +EXPOSE 8000 + +# Run the startup script +CMD ["./scripts/start.sh"] \ No newline at end of file diff --git a/Dockerfile.backend.prod b/Dockerfile.backend.prod new file mode 100644 index 0000000..ff07e1c --- /dev/null +++ b/Dockerfile.backend.prod @@ -0,0 +1,62 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + netcat-traditional \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies. Bump pip's per-request timeout and retry count so +# the large opencv/onnxruntime wheel downloads survive slow links and the +# heavier amd64/x86_64 wheels don't abort with ReadTimeoutError. +ENV PIP_DEFAULT_TIMEOUT=120 +COPY backend/requirements.txt . +RUN pip install --no-cache-dir --retries 10 -r requirements.txt +RUN crawl4ai-setup + +# Node.js + npx and uv/uvx for STDIO MCP servers (npx @elastic/mcp-server-…, +# uvx mcp-server-…). Copied from the official images instead of apt, which +# only ships an EOL Node 18 on bookworm. Kept below the pip layer so an +# upstream node/uv tag bump can't invalidate the cached pip layer. +COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22-slim /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /uvx /usr/local/bin/ + +# Copy application code +COPY backend/app ./app +COPY backend/alembic.ini . +COPY backend/alembic ./alembic +COPY backend/scripts ./scripts +COPY backend/assets ./assets + +# Create required directories including cache directories +RUN mkdir -p uploads/agents && \ + mkdir -p temp && \ + mkdir -p .cache/huggingface/hub && \ + chmod -R 755 .cache && \ + chmod -R 755 temp + +# Make startup script executable +RUN chmod +x ./scripts/start.sh + +# Set environment variables +ENV PYTHONPATH=/app +ENV PORT=8000 +ENV ENVIRONMENT=production + +# Set HuggingFace cache directories +ENV HF_HOME=/app/.cache/huggingface +ENV HF_HUB_CACHE=/app/.cache/huggingface/hub +ENV HF_HUB_DISABLE_TELEMETRY=1 + +# Expose the port +EXPOSE 8000 + +# Run the startup script +CMD ["./scripts/start.sh"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..3573eaa --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,46 @@ +FROM node:20-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + wget \ + curl \ + && rm -rf /var/lib/apt/lists/* + + + +# Copy package files first +COPY frontend/package*.json ./ + + + + +# Copy frontend source code +COPY frontend/ . + +RUN npm install + +# Create startup script in /usr/local/bin +RUN echo '#!/bin/bash\n\ +echo "Waiting for backend..."\n\ +until curl -f http://backend:8000/health; do\n\ + echo "Backend not ready - sleeping"\n\ + sleep 5\n\ +done\n\ +\n\ +echo "Backend is ready - building widget and webclient"\n\ +cd /app && npm run build:widgetdocker\n\ +cd /app && npm run build:webclient\n\ +\n\ +echo "Starting frontend development server"\n\ +cd /app && npm run dev -- --host 0.0.0.0 --port 3000' > /usr/local/bin/start.sh + +# Make startup script executable +RUN chmod +x /usr/local/bin/start.sh + +# Expose port +EXPOSE 3000 + +# Start the application in development mode +CMD ["/bin/bash", "/usr/local/bin/start.sh"] \ No newline at end of file diff --git a/Dockerfile.frontend.prod b/Dockerfile.frontend.prod new file mode 100644 index 0000000..e35b476 --- /dev/null +++ b/Dockerfile.frontend.prod @@ -0,0 +1,64 @@ +# Build stage +FROM node:22-slim AS builder + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + wget \ + curl \ + && rm -rf /var/lib/apt/lists/* + + + +# Copy package files first +COPY frontend/package*.json ./ + +# Copy frontend source code +COPY frontend/ . + +# Install dependencies with legacy peer deps +RUN npm install + +# Build the application, widget, and webclient. Use the :prod webclient build so +# the shipped loader bakes the production fallback URL (the real backend URL is +# resolved at runtime via window.chattermateBaseUrl / APP_CONFIG regardless). +RUN npm run build-only && \ + npm run build:widgetdocker && \ + npm run build:webclient:prod && \ + if [ ! -f dist/index.html ]; then \ + echo "ChatterMate
" > dist/index.html; \ + fi + +# Production stage +FROM nginx:alpine + +# Remove default nginx static assets +RUN rm -rf /usr/share/nginx/html/* + +# Copy the built files to nginx +COPY --from=builder /app/dist/ /usr/share/nginx/html/ + +# Also serve the docker-built widget (widget.js/widget.css). build:widgetdocker +# emits these to /app/backend-assets; without this they are discarded and a +# frontend-served widget (VITE_WIDGET_URL -> frontend) falls back to the stale +# checked-in bundle. Fixed filenames, so this won't clobber hashed dashboard assets. +COPY --from=builder /app/backend-assets/ /usr/share/nginx/html/assets/ + +# Copy nginx configuration +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf + +# Copy entrypoint script +COPY frontend/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost/ || exit 1 + +# Use entrypoint script +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Dockerfile.postgres b/Dockerfile.postgres new file mode 100644 index 0000000..6b30212 --- /dev/null +++ b/Dockerfile.postgres @@ -0,0 +1,31 @@ +FROM postgres:16 + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + git \ + postgresql-server-dev-16 \ + && rm -rf /var/lib/apt/lists/* + +# Install pg_vector extension with compatibility fixes for ARM64 +RUN git clone https://github.com/pgvector/pgvector.git && \ + cd pgvector && \ + git checkout v0.5.1 && \ + make OPTFLAGS="" && \ + make install + +# Add initialization scripts +COPY ./backend/scripts/init-vector.sql /docker-entrypoint-initdb.d/ + +# Set environment variables +ENV POSTGRES_DB=chattermate +ENV POSTGRES_USER=postgres +ENV POSTGRES_PASSWORD=postgres + +# Set PostgreSQL configuration for better stability with pgvector +RUN echo "shared_preload_libraries = 'vector'" >> /usr/share/postgresql/postgresql.conf.sample +RUN echo "max_connections = 200" >> /usr/share/postgresql/postgresql.conf.sample +RUN echo "shared_buffers = 256MB" >> /usr/share/postgresql/postgresql.conf.sample + +# Expose PostgreSQL port +EXPOSE 5432 \ No newline at end of file diff --git a/LOCAL_DEPLOY.md b/LOCAL_DEPLOY.md new file mode 100644 index 0000000..947ac77 --- /dev/null +++ b/LOCAL_DEPLOY.md @@ -0,0 +1,92 @@ +# ReplyPier 本机使用 + +## 打开演示页与客服工作台 + +- 网站悬浮客服演示: +- 管理员 / 人工客服登录: +- 会话工作台: + +### 本机账号 + +| 身份 | 登录邮箱 | 用途 | +| --- | --- | --- | +| 管理员 | `admin@example.com` | 管理配置、查看全部会话与评分 | +| 客服 A | `support.a@example.com` | 接待、处理自己的会话 | +| 客服 B | `support.b@example.com` | 多客服协作与会话隔离 | + +密码保存在 [本机账号文件](deploy/local/.state/test-accounts.json),分别查看 `admin.password`、`agent_a.password`、`agent_b.password`。不要公开或提交该文件。不同客服使用不同浏览器或无痕窗口登录,避免账号互相覆盖。 + +### 使用流程 + +1. 以人工客服登录工作台,保持页面在线。 +2. 在另一个浏览器窗口打开演示页,点击右下角悬浮按钮。 +3. 提问知识库中已有答案的产品或售后问题。 +4. 发“我要转人工。”,在工作台 Inbox 中找到待人工会话,点击 **Take over chat**。 +5. 人工回复后,点击 **End Chat → End Chat & Request Rating**,访客可以提交星级和文字评价。 +6. 管理员在 **Inbox → Closed** 查看对话,在 **Analytics → Agent Performance → Human Agents** 查看评分,在 **Analytics → Customers → View Details** 查看文字评价。 + +本机接待时段为每天全天,两名客服属于 `Local Support` 分组。示例知识为虚构政策,正式使用前应替换为实际业务资料。 + +## 启动、停止与日志 + +Mac 重启后,在终端执行: + +```bash +cd /Users/a11/1020-1030/01-projects/replypier +./deploy/local/local.sh up +``` + +常用命令: + +```bash +./deploy/local/local.sh status +./deploy/local/local.sh logs backend +./deploy/local/local.sh logs knowledge +./deploy/local/local.sh logs frontend +./deploy/local/local.sh restart +./deploy/local/local.sh stop +``` + +`stop` 停止本项目服务及数据库、缓存,不删除数据卷。`restart` 重启应用进程,保留数据库与缓存。未配置系统开机自启动。 + +修改前端源码后,执行 `./deploy/local/local.sh build`,再执行 `restart`。构建会根据已有 Widget ID 生成演示页。`setup` 用于安装依赖和构建,不覆盖已有环境配置,也不创建新管理员。 + +## 部署结构与数据 + +Python 后端、知识库 worker、前端预览服务在宿主机运行;PostgreSQL 与 Redis 在 Colima 虚拟机中运行。 + +| 服务 | 本机地址 | +| --- | --- | +| 工作台 / 演示页 | `127.0.0.1:3018` | +| API / Widget / Socket.IO | `127.0.0.1:8018` | +| PostgreSQL 16 + pgvector | `127.0.0.1:15432` | +| Redis | `127.0.0.1:16380` | + +- Docker context:`colima-bytedesk`。 +- Compose 项目名:`chattermate-local`。 +- 数据库卷:`chattermate-local_postgres_data`。 +- 配置:`backend/.env`、`frontend/.env`、`deploy/local/.env`。 +- 运行文件、日志、构建产物、嵌入模型缓存:`deploy/local/.state/`。 +- Widget 与演示配置:[test-environment.json](deploy/local/.state/test-environment.json)。 +- 模型配置:DeepSeek / `deepseek-flash`;API Key 由应用加密保存在数据库中。 + +启动脚本使用上述 context、项目名和卷。不要随意改名、删除数据卷或关闭承载数据库的 Colima 虚拟机。 + +恢复数据时须同时保留数据库和 `backend/.env` 中的加密配置,否则加密保存的模型配置及消息可能无法读取。不要覆盖密钥、重置管理员,或将私有环境文件提交和对外分享。 + +## 会话权限检查 + +会话读取、接管、转交、加入 Socket 房间和发消息,都需要同时检查组织、角色和会话可见性。相应实现位于 [session_to_agent.py](backend/app/api/session_to_agent.py) 和 [widget_chat.py](backend/app/api/widget_chat.py)。 + +按需执行定向回归: + +```bash +cd /Users/a11/1020-1030/01-projects/replypier/backend +ENV_FILE=/dev/null ENVIRONMENT=test NO_ENTERPRISE=1 .venv/bin/python -m pytest tests/api/test_session_to_agent.py -q -o addopts='' +``` + +## 网站接入与功能边界 + +服务地址仅监听本机。公网接入需要可访问的服务地址、HTTPS、真实网站域名及相应 CORS / Widget 配置,不能将 `127.0.0.1` 作为公网网站的服务地址。 + +固定客户长期绑定负责客服的身份映射与自动路由尚未定制。界面主要为英文;邮件邀请、重置密码、Firebase 离线推送和生产容量需在对应任务中配置或验证。 diff --git a/backend/scripts/encrypt_chat_at_rest.py b/backend/scripts/encrypt_chat_at_rest.py new file mode 100644 index 0000000..0f9bf27 --- /dev/null +++ b/backend/scripts/encrypt_chat_at_rest.py @@ -0,0 +1,206 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +One-off backfill: encrypt conversation content written before encryption at rest. + +New rows are already encrypted by the application; this walks the rows that predate +it. Safe to run against a live database, restartable after a Ctrl-C, and idempotent — +already-encrypted rows are skipped, so re-running it costs one scan and changes +nothing. + +Run it on the HOST, not inside the backend container: + + DATABASE_URL=postgresql://... ENCRYPTION_KEY=... \ + python backend/scripts/encrypt_chat_at_rest.py --dry-run + +Importing the `app` package would pull in the FastAPI app and the whole ML stack +(and has OOM'd production before), so app/core/encryption.py is loaded directly by +file path — the crypto logic itself is never duplicated here. +""" + +import argparse +import importlib.util +import os +import re +import sys +from pathlib import Path +from types import ModuleType + +import psycopg +from psycopg.types.json import Jsonb + +BACKEND_DIR = Path(__file__).resolve().parent.parent +DEFAULT_BATCH_SIZE = 500 + +TEXT = "text" +JSON = "json" + +# What to encrypt, one entry per table so each is scanned once. +# (table, primary key, columns, kind) — kind picks the "already encrypted" test and +# the value transform, which is all that differs between text and JSONB columns. +TARGETS = ( + ("chat_history", "id", ("message",), TEXT), + ("session_to_agents", "session_id", + ("transfer_description", "end_chat_description", "ticket_summary", + "ticket_description"), TEXT), + # The agno agent memory blob. agno's PostgresStorage defaults to the "ai" + # schema, and app.agents.encrypted_storage does not override it, so the table + # is ai.agent_sessions — schema-qualified here or to_regclass won't find it. + ("ai.agent_sessions", "session_id", ("memory",), JSON), + # Guardrail review excerpts: the snippet of the message that tripped a rule, + # encrypted like any other visitor text. Included so the table is covered if + # excerpts are ever written before the column type lands on an environment. + ("guardrail_events", "id", ("excerpt",), TEXT), +) + + +def _load_module(name: str, path: Path) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def load_encryption() -> ModuleType: + """Load app.core.encryption without importing the `app` package. + + Its two dependencies (logger, config) are light — the weight is in app/__init__, + which imports the FastAPI app — so they are registered under their real names + and the import inside encryption.py resolves against these. + """ + for pkg in ("app", "app.core"): + if pkg not in sys.modules: + stub = ModuleType(pkg) + stub.__path__ = [] + sys.modules[pkg] = stub + core = BACKEND_DIR / "app" / "core" + _load_module("app.core.logger", core / "logger.py") + _load_module("app.core.config", core / "config.py") + return _load_module("app.core.encryption", core / "encryption.py") + + +def _plaintext_predicate(crypto, column, kind): + """SQL fragment + parameter matching a value this run still has to encrypt.""" + if kind == TEXT: + return f"{column} NOT LIKE %s", crypto.ENCRYPTED_PREFIX + crypto.SEPARATOR + "%" + # jsonb_exists() rather than the ? operator, which reads as a placeholder. + return f"NOT jsonb_exists({column}, %s)", crypto.JSON_MARKER + + +def _encrypt(crypto, value, kind): + if value is None: + return None + return crypto.encrypt_value(value) if kind == TEXT else Jsonb(crypto.encrypt_json(value)) + + +def _table_exists(conn, table) -> bool: + """agent_sessions is created lazily by agno on the first agent run, so a fresh + or agent-less database legitimately lacks it — skip rather than crash.""" + with conn.cursor() as cur: + cur.execute("SELECT to_regclass(%s)", (table,)) + return cur.fetchone()[0] is not None + + +def backfill(conn, crypto, table, pk, columns, kind, batch_size, dry_run) -> int: + """Encrypt every plaintext value across one table's columns in a single pass. + + Keyset pagination on the primary key with a commit per batch: restartable after + an interruption, and never more than batch_size rows in memory. + """ + predicates, predicate_params = zip( + *(_plaintext_predicate(crypto, column, kind) for column in columns)) + # A row needs work if ANY of its columns is still plaintext. + needs_work = " OR ".join(f"({column} IS NOT NULL AND {p})" + for column, p in zip(columns, predicates)) + selected = ", ".join(columns) + assignments = ", ".join(f"{column} = %s" for column in columns) + + changed = 0 + last_pk = None + + while True: + where, params = f"({needs_work})", list(predicate_params) + if last_pk is not None: + where += f" AND {pk} > %s" + params.append(last_pk) + + with conn.cursor() as cur: + cur.execute( + f"SELECT {pk}, {selected} FROM {table} WHERE {where} " + f"ORDER BY {pk} LIMIT %s", + (*params, batch_size), + ) + rows = cur.fetchall() + + if not rows: + break + last_pk = rows[-1][0] + + if not dry_run: + cur.executemany( + f"UPDATE {table} SET {assignments} WHERE {pk} = %s", + [(*[_encrypt(crypto, value, kind) for value in values], key) + for key, *values in rows], + ) + conn.commit() + + changed += len(rows) + print(f" {table}: {changed} rows", flush=True) + + return changed + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true", + help="report how many rows would be encrypted, change nothing") + parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, + help=f"rows per transaction (default {DEFAULT_BATCH_SIZE})") + args = parser.parse_args() + + database_url = os.getenv("DATABASE_URL") + if not database_url: + print("DATABASE_URL is not set", file=sys.stderr) + return 1 + # The app's URL carries a SQLAlchemy driver suffix (postgresql+psycopg://) that + # libpq — and therefore psycopg.connect — does not understand. Strip it. + database_url = re.sub(r"^(postgresql|postgres)\+\w+://", r"\1://", database_url) + crypto = load_encryption() + # Belt and braces: the module raises on a missing key outside development, but a + # host that happens to export ENVIRONMENT=development would get a generated key, + # and encrypting live data with a throwaway key destroys it. + if not os.getenv(crypto.ENCRYPTION_KEY_ENV): + print(f"{crypto.ENCRYPTION_KEY_ENV} is not set — refusing to encrypt with a " + "generated key", file=sys.stderr) + return 1 + + total = 0 + + with psycopg.connect(database_url) as conn: + for table, pk, columns, kind in TARGETS: + if not _table_exists(conn, table): + print(f" {table}: table not present, skipping", flush=True) + continue + total += backfill(conn, crypto, table, pk, columns, kind, + args.batch_size, args.dry_run) + + verb = "would encrypt" if args.dry_run else "encrypted" + print(f"{verb} {total} rows") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/scripts/start.sh b/backend/scripts/start.sh new file mode 100644 index 0000000..91ca936 --- /dev/null +++ b/backend/scripts/start.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +# Set environment variable to prevent tokenizer deadlock warnings +export TOKENIZERS_PARALLELISM=false + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL..." +while ! nc -z db 5432; do + sleep 0.1 +done +echo "PostgreSQL is ready!" + +# Check if Firebase credentials exist, but don't create them if missing +if [ ! -z "$FIREBASE_CREDENTIALS" ] && [ ! -f "$FIREBASE_CREDENTIALS" ]; then + echo "Warning: Firebase credentials file not found at $FIREBASE_CREDENTIALS. Continuing without Firebase credentials..." +fi + + + +# Preload embedding models to avoid runtime issues +echo "Preloading embedding models..." +python scripts/preload_models.py +if [ $? -eq 0 ]; then + echo "Models preloaded successfully!" +else + echo "Warning: Model preloading failed. Continuing anyway..." +fi + +# Run migrations +echo "Running database migrations..." +alembic upgrade head + +# Use only 2 workers to reduce resource usage and database connection issues +WORKERS=1 + +# Start the application with Gunicorn +echo "Starting FastAPI application with Gunicorn ($WORKERS workers)..." +gunicorn app.main:app \ + --workers $WORKERS \ + --worker-class uvicorn.workers.UvicornWorker \ + --bind 0.0.0.0:8000 \ + --timeout 120 \ + --keep-alive 5 \ + --log-level info \ + --access-logfile - \ + --error-logfile - \ + --preload \ No newline at end of file diff --git a/deploy/local/compose.yaml b/deploy/local/compose.yaml new file mode 100644 index 0000000..3503c34 --- /dev/null +++ b/deploy/local/compose.yaml @@ -0,0 +1,40 @@ +name: chattermate-local + +services: + db: + image: pgvector/pgvector:0.8.2-pg16 + environment: + POSTGRES_DB: chattermate + POSTGRES_USER: chattermate + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Run ./deploy/local/local.sh init first} + ports: + - "127.0.0.1:15432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=80"] + mem_limit: 384m + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U chattermate -d chattermate"] + interval: 5s + timeout: 3s + retries: 20 + + redis: + image: redis:7.4 + ports: + - "127.0.0.1:16380:6379" + volumes: + - redis_data:/data + command: ["redis-server", "--appendonly", "yes", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"] + mem_limit: 96m + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + postgres_data: + redis_data: diff --git a/deploy/local/demo.template.html b/deploy/local/demo.template.html new file mode 100644 index 0000000..7b6b66f --- /dev/null +++ b/deploy/local/demo.template.html @@ -0,0 +1,45 @@ + + + + + + ChatterMate 网站客服测试 + + + +
+
CHATTERMATE · 本机测试
+

你的网页,接入 AI 与人工客服。

+

点击右下角悬浮按钮展开对话。这里连接的是本机部署的 ChatterMate 和你配置的 DeepSeek。

+
+

按这个顺序试一遍

+
    +
  1. 问知识库:青禾测试套餐的退款期多长,校验码是什么?
  2. +
  3. 要求转接:我要转人工。
  4. +
  5. 在客服工作台接管会话,回复后结束接待,再回这里提交评分。
  6. +
+
+

打开客服工作台 ↗

+

套餐、政策与校验码均为虚构测试数据。此页面仅供本机测试;对话会调用已配置的模型 API。

+
+ + + + diff --git a/deploy/local/init_env.py b/deploy/local/init_env.py new file mode 100644 index 0000000..facdaec --- /dev/null +++ b/deploy/local/init_env.py @@ -0,0 +1,62 @@ +"""Create private configuration for the loopback-only local deployment.""" + +import base64 +import json +import secrets +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +LOCAL = ROOT / "deploy" / "local" +STATE = LOCAL / ".state" + + +def write_new(filename: Path, text: str) -> None: + if filename.exists(): + return + with filename.open("x", encoding="utf-8") as stream: + filename.chmod(0o600) + stream.write(text) + + +def main() -> None: + STATE.mkdir(parents=True, exist_ok=True) + STATE.chmod(0o700) + compose_env = LOCAL / ".env" + write_new(compose_env, f"POSTGRES_PASSWORD={secrets.token_hex(24)}\n") + password = compose_env.read_text().strip().split("=", 1)[1] + fernet_key = base64.b64encode(base64.urlsafe_b64encode(secrets.token_bytes(32))).decode() + settings = { + "DATABASE_URL": f"postgresql+psycopg://chattermate:{password}@127.0.0.1:15432/chattermate", + "REDIS_ENABLED": "true", + "REDIS_URL": "redis://127.0.0.1:16380/0", + "ENVIRONMENT": "production", + "JWT_SECRET_KEY": secrets.token_hex(32), + "CONVERSATION_SECRET_KEY": secrets.token_hex(32), + "ENCRYPTION_KEY": fernet_key, + "FRONTEND_URL": "http://127.0.0.1:3018", + "BACKEND_URL": "http://127.0.0.1:8018", + "VITE_WIDGET_URL": "http://127.0.0.1:8018", + "CORS_ORIGINS": json.dumps(["http://127.0.0.1:3018", "http://localhost:3018", "http://127.0.0.1:8018"]), + "WIDGET_ASSETS_DIR": str(STATE / "assets"), + "FASTEMBED_CACHE_PATH": str(STATE / "fastembed"), + "S3_FILE_STORAGE": "false", + "AWS_EC2_METADATA_DISABLED": "true", + "MCP_STDIO_ENABLED": "false", + "AGNO_TELEMETRY": "false", + "AGNO_MONITOR": "false", + "EMBEDDING_MAX_WORKERS": "1", + "KB_MAX_WORKERS": "1", + "OMP_NUM_THREADS": "1", + "POLL_INTERVAL": "30", + "SMTP_SERVER": "127.0.0.1", + "SMTP_PORT": "1025", + "SMTP_USERNAME": "", + "SMTP_PASSWORD": "", + } + write_new(ROOT / "backend" / ".env", "".join(f"{key}={value}\n" for key, value in settings.items())) + write_new(ROOT / "frontend" / ".env", "VITE_API_URL=http://127.0.0.1:8018/api/v1\nVITE_WS_URL=ws://127.0.0.1:8018\nVITE_WIDGET_URL=http://127.0.0.1:8018\n") + print("Local configuration is ready; existing files were not overwritten.") + + +if __name__ == "__main__": + main() diff --git a/deploy/local/local.sh b/deploy/local/local.sh new file mode 100755 index 0000000..4be9aae --- /dev/null +++ b/deploy/local/local.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +LOCAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${LOCAL}/../.." && pwd)" +STATE="${LOCAL}/.state" +PYTHON="${ROOT}/backend/.venv/bin/python" +export ENV_FILE="${ROOT}/backend/.env" +export NO_ENTERPRISE=1 + +compose() { + docker --context colima-bytedesk compose --env-file "${LOCAL}/.env" -f "${LOCAL}/compose.yaml" "$@" +} + +start_process() { + local name="$1" directory="$2" pid + shift 2 + if [[ -f "${STATE}/${name}.pid" ]] && kill -0 "$(cat "${STATE}/${name}.pid")" 2>/dev/null; then + printf '%s is already running.\n' "${name}" + return + fi + pid="$("${PYTHON}" - "${STATE}/${name}.log" "${directory}" "$@" <<'PY' +import subprocess +import sys + +with open(sys.argv[1], "a") as log: + process = subprocess.Popen(sys.argv[3:], cwd=sys.argv[2], stdin=subprocess.DEVNULL, + stdout=log, stderr=log, start_new_session=True) +print(process.pid) +PY +)" + printf '%s\n' "${pid}" > "${STATE}/${name}.pid" +} + +stop_process() { + local name="$1" pid command + if [[ ! -f "${STATE}/${name}.pid" ]]; then return; fi + pid="$(cat "${STATE}/${name}.pid")" + if kill -0 "${pid}" 2>/dev/null; then + command="$(ps -p "${pid}" -o command=)" + if [[ "${command}" != *"${ROOT}"* ]]; then + printf 'Refusing to stop an unrelated process at PID %s.\n' "${pid}" >&2 + return 1 + fi + kill "${pid}" + for attempt in {1..40}; do + if ! kill -0 "${pid}" 2>/dev/null; then break; fi + sleep 0.25 + done + fi + rm -f "${STATE}/${name}.pid" +} + +build_frontend() { + mkdir -p "${STATE}/assets" + cd "${ROOT}/frontend" + node_modules/.bin/vite build --outDir "${STATE}/dashboard" --emptyOutDir + node_modules/.bin/vite build --config vite.widget.config.ts --outDir "${STATE}/assets" --emptyOutDir + if [[ ! -f "${STATE}/assets/widget.css" ]]; then + cp "${STATE}"/assets/unused/*.css "${STATE}/assets/widget.css" + fi + node_modules/.bin/esbuild src/webclient/chattermate.js --bundle --minify --format=iife \ + --target=es2015 --loader:.svg=text --define:process.env.NODE_ENV='"production"' \ + --define:__CHATTERMATE_API_URL__='"http://127.0.0.1:8018/api/v1"' \ + --outfile="${STATE}/dashboard/webclient/chattermate.min.js" + cat > "${STATE}/dashboard/config.js" <<'JS' +self.APP_CONFIG = { + API_URL: "http://127.0.0.1:8018/api/v1", + WS_URL: "ws://127.0.0.1:8018", + WIDGET_URL: "http://127.0.0.1:8018", + NODE_ENV: "production" +}; +JS + if [[ -f "${STATE}/test-environment.json" ]]; then + "${PYTHON}" - "${LOCAL}" <<'PY' +import json +import sys +from pathlib import Path + +local = Path(sys.argv[1]) +state = local / ".state" +environment = json.loads((state / "test-environment.json").read_text()) +template = (local / "demo.template.html").read_text() +(state / "dashboard/local-demo.html").write_text(template.replace("__WIDGET_ID__", environment["widget_id"])) +PY + fi +} + +case "${1:-status}" in + init) + python3 "${LOCAL}/init_env.py" + ;; + setup) + python3 "${LOCAL}/init_env.py" + if [[ ! -x "${PYTHON}" ]]; then + uv venv --python 3.12 "${ROOT}/backend/.venv" + fi + uv pip install --python "${PYTHON}" -r "${LOCAL}/requirements.lock.txt" + (cd "${ROOT}/frontend" && npm ci --no-audit --no-fund) + build_frontend + ;; + build) + build_frontend + ;; + up) + if [[ ! -f "${STATE}/dashboard/index.html" ]]; then + printf 'Run %s setup first.\n' "$0" >&2 + exit 1 + fi + if ! colima --profile bytedesk status >/dev/null 2>&1; then + colima --profile bytedesk start --activate=false --ssh-config=false + fi + compose up -d --wait --wait-timeout 180 + compose exec -T db psql -U chattermate -d chattermate -c 'CREATE EXTENSION IF NOT EXISTS vector;' + (cd "${ROOT}/backend" && "${PYTHON}" -m alembic upgrade head) + start_process backend "${ROOT}/backend" "${PYTHON}" -m uvicorn app.main:app --host 127.0.0.1 --port 8018 + start_process knowledge "${ROOT}/backend" "${PYTHON}" -m app.workers.run_knowledge_processor + start_process frontend "${ROOT}/frontend" "$(command -v node)" "${ROOT}/frontend/node_modules/vite/bin/vite.js" preview --outDir "${STATE}/dashboard" --host 127.0.0.1 --port 3018 --strictPort + for attempt in {1..90}; do + if curl --silent --fail http://127.0.0.1:8018/health >/dev/null && curl --silent --fail http://127.0.0.1:3018/ >/dev/null; then + printf '\nReplyPier: http://127.0.0.1:3018/\n' + exit 0 + fi + sleep 2 + done + printf 'Startup did not become healthy; inspect %s/*.log\n' "${STATE}" >&2 + exit 1 + ;; + stop) + stop_process frontend + stop_process knowledge + stop_process backend + compose stop + ;; + restart) + stop_process frontend + stop_process knowledge + stop_process backend + exec "$0" up + ;; + status) + compose ps + for name in backend knowledge frontend; do + if [[ -f "${STATE}/${name}.pid" ]]; then + ps -p "$(cat "${STATE}/${name}.pid")" -o pid=,etime=,command= || true + fi + done + curl --silent --show-error --fail http://127.0.0.1:8018/health + printf '\n' + ;; + logs) + tail -n 70 "${STATE}/${2:-backend}.log" + ;; + *) + printf 'Usage: %s {init|setup|build|up|stop|restart|status|logs [backend|knowledge|frontend]}\n' "$0" >&2 + exit 2 + ;; +esac diff --git a/deploy/local/requirements.lock.txt b/deploy/local/requirements.lock.txt new file mode 100644 index 0000000..0a8aca7 --- /dev/null +++ b/deploy/local/requirements.lock.txt @@ -0,0 +1,222 @@ +agno==1.7.6 +aiofiles==25.1.0 +aiohappyeyeballs==2.7.1 +aiohttp==3.14.5 +aiosignal==1.4.0 +aiosqlite==0.22.1 +alembic==1.20.0 +alphashape==1.3.1 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anthropic==1.17.0 +anyio==4.15.1 +asyncpg==0.32.0 +attrs==26.1.0 +bcrypt==3.2.2 +beautifulsoup4==4.15.0 +bidict==0.24.1 +boto3==1.43.116 +botocore==1.43.116 +brotli==1.2.0 +bs4==0.0.2 +cachecontrol==0.14.4 +certifi==2026.10.19 +cffi==2.1.1 +chardet==7.7.0 +charset-normalizer==3.5.2 +click==8.5.0 +click-log==0.4.0 +cloudpickle==3.1.2 +coloredlogs==15.0.1 +coverage==7.4.1 +crawl4ai==0.9.5 +cryptography==50.0.1 +cssselect==1.6.0 +defusedxml==0.7.1 +distro==1.9.0 +dnspython==2.9.0 +docstring-parser==0.18.0 +duckduckgo-search==8.1.1 +ecdsa==0.19.2 +email-validator==2.3.0 +eval-type-backport==0.4.0 +fake-useragent==2.2.0 +fastapi==0.137.2 +fastembed==0.7.3 +fastuuid==0.14.0 +filelock==4.2.4 +firebase-admin==6.9.0 +flatbuffers==25.12.19 +frozenlist==1.8.0 +fsspec==2026.9.0 +gitdb==4.0.12 +gitpython==3.2.1 +google-ai-generativelanguage==0.6.15 +google-api-core==2.33.0 +google-api-python-client==2.201.0 +google-auth==2.62.0 +google-auth-httplib2==0.4.4 +google-cloud-aiplatform==1.91.0 +google-cloud-bigquery==3.46.2 +google-cloud-core==2.8.0 +google-cloud-firestore==2.27.0 +google-cloud-resource-manager==1.18.0 +google-cloud-storage==2.19.0 +google-crc32c==1.9.0 +google-generativeai==0.8.6 +google-resumable-media==2.11.0 +googleapis-common-protos==1.75.0 +greenlet==3.5.6 +groq==1.7.0 +grpc-google-iam-v1==0.14.4 +grpcio==1.84.0 +grpcio-status==1.71.2 +gunicorn==26.2.0 +h11==0.16.0 +h2==4.4.1 +hf-xet==1.7.0 +hpack==4.2.0 +httpcore==1.0.9 +httpcore2==2.16.0 +httplib2==0.32.0 +httpx==0.28.1 +httpx-sse==0.4.3 +httpx2==2.16.0 +huggingface-hub==0.36.2 +humanfriendly==10.0 +humanize==4.16.0 +hyperframe==6.1.0 +idna==3.20 +importlib-metadata==9.0.1 +iniconfig==2.3.1 +invoke==2.2.1 +jinja2==3.1.6 +jiter==0.17.0 +jmespath==1.1.0 +joblib==1.6.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +lark==1.3.1 +loguru==0.7.3 +lxml==6.1.3 +mako==1.4.3 +markdown==3.11.2 +markdown-it-py==4.2.0 +markdownify==1.2.3 +markupsafe==3.0.4 +mcp==1.30.0 +mdurl==0.1.2 +mistralai==1.12.4 +mmh3==5.3.1 +mpmath==1.3.0 +msgpack==1.2.3 +multidict==7.1.0 +networkx==3.7 +nh3==0.3.7 +nltk==3.10.3 +numpy==2.2.6 +ollama==0.6.3 +onnxruntime==1.23.0 +openai==3.35.1 +opencv-python==4.12.0.88 +opentelemetry-api==1.45.1 +opentelemetry-exporter-http-transport==0.66b1 +opentelemetry-exporter-otlp-common==0.66b1 +opentelemetry-exporter-otlp-proto-common==1.45.1 +opentelemetry-exporter-otlp-proto-http==1.45.1 +opentelemetry-proto==1.45.1 +opentelemetry-sdk==1.45.1 +opentelemetry-semantic-conventions==0.66b1 +packaging==26.3 +paramiko==3.5.1 +passlib==1.7.4 +patchright==1.64.1 +pgvector==0.5.1 +pillow==11.3.0 +playwright==1.64.0 +playwright-stealth==2.0.3 +pluggy==1.7.0 +primp==2.0.1 +propcache==0.5.4 +proto-plus==1.28.2 +protobuf==5.29.6 +psutil==7.2.2 +psycopg==3.3.6 +psycopg-binary==3.3.6 +py-rust-stemmers==0.1.8 +pyactiveresource==2.2.2 +pyasn1==0.6.4 +pyasn1-modules==0.4.2 +pyclipper==1.4.0 +pycparser==3.11 +pydantic==2.14.1 +pydantic-core==2.50.1 +pydantic-settings==2.16.1 +pyee==14.0.0 +pygments==2.21.0 +pyjwt==2.15.1 +pymysql==1.2.3 +pynacl==1.6.2 +pyopenssl==26.4.0 +pyparsing==3.3.3 +pypdf==6.21.1 +pytest==9.1.1 +pytest-asyncio==1.4.0 +pytest-cov==4.1.0 +pytest-mock==3.17.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.4 +python-engineio==4.14.0 +python-jose==3.5.0 +python-multipart==0.0.32 +python-socketio==5.17.0 +pytz==2026.5 +pyyaml==6.0.3 +rank-bm25==0.2.2 +rapidocr-onnxruntime==1.4.4 +razorpay==2.0.1 +redis==8.1.0 +referencing==0.37.0 +regex==2026.9.29 +requests==2.34.2 +rich==15.0.0 +rpds-py==2026.9.1 +rsa==4.9.1 +rtree==1.4.1 +s3transfer==0.19.3 +scipy==1.18.1 +shapely==2.2.0 +shellingham==1.5.4 +shopifyapi==12.7.0 +simple-websocket==1.1.0 +six==1.17.0 +smmap==5.0.3 +sniffio==1.3.1 +snowballstemmer==2.2.0 +soupsieve==3.0.3 +sqlalchemy==2.1.5 +sqlglot==30.24.0 +sse-starlette==3.5.0 +sshtunnel==0.5.1 +starlette==1.6.0 +sympy==1.14.0 +textblob==0.20.1 +tiktoken==0.14.0 +tokenizers==0.23.3 +tomli==2.5.0 +tqdm==4.70.1 +trimesh==5.1.1 +truststore==0.10.5 +typer==0.27.3 +typing-extensions==4.16.0 +typing-inspection==0.4.4 +unclecode-litellm==1.81.13 +uritemplate==4.2.0 +urllib3==2.8.0 +uv==0.13.4 +uvicorn==0.54.0 +websockets==17.3 +wsproto==1.3.2 +xxhash==3.8.2 +yarl==1.25.1 +zipp==4.1.1 diff --git a/deploy/local/seed_test.py b/deploy/local/seed_test.py new file mode 100644 index 0000000..f3eafce --- /dev/null +++ b/deploy/local/seed_test.py @@ -0,0 +1,107 @@ +"""Create the local demonstration through the application's real HTTP API.""" + +import json +from pathlib import Path + +import httpx + +LOCAL = Path(__file__).resolve().parent +STATE = LOCAL / ".state" +API = "http://127.0.0.1:8018/api/v1/" + + +def checked(response: httpx.Response) -> dict | list: + response.raise_for_status() + return response.json() + + +def main() -> None: + accounts = json.loads((STATE / "test-accounts.json").read_text()) + with httpx.Client(base_url=API, timeout=600, trust_env=False) as client: + admin = accounts["admin"] + login = checked(client.post("users/login", data={"username": admin["email"], "password": admin["password"]})) + client.headers["Authorization"] = "Bearer " + login["access_token"] + org_id = login["user"]["organization_id"] + hours = {day: {"start": "00:00", "end": "23:59", "enabled": True} for day in ( + "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" + )} + checked(client.patch(f"organizations/{org_id}", json={"business_hours": hours})) + roles = checked(client.get("roles")) + role_id = next(role["id"] for role in roles if role["name"] == "Agent") + users = checked(client.get("users")) + staff = [] + for key in ("agent_a", "agent_b"): + account = accounts[key] + user = next((item for item in users if item["email"] == account["email"]), None) + if user is None: + user = checked(client.post("users", json={ + "email": account["email"], "full_name": account["name"], + "password": account["password"], "role_id": role_id, + "see_all_ai_chats": False, "see_all_org_chats": False, + })) + accounts[key]["id"] = user["id"] + staff.append(user) + + groups = checked(client.get("groups")) + group = next((item for item in groups if item["name"] == "Local Support"), None) + if group is None: + group = checked(client.post("groups", json={"name": "Local Support"})) + members = {user["id"] for user in group.get("users", [])} + for user in staff: + if user["id"] not in members: + checked(client.post(f"groups/{group['id']}/users/{user['id']}")) + + agents = checked(client.get("agent/list")) + agent = next((item for item in agents if item["name"] == "网站客服演示"), None) + if agent is None: + agent = checked(client.post("agent", json={ + "name": "网站客服演示", "agent_type": "customer_support", + "instructions": [ + "你是本地客服测试助手,全程使用简体中文,回答简短清楚。", + "涉及青禾测试套餐、退款期限、校验码时,必须先查询知识库,不得凭常识编造。", + "用户明确要求转人工、真人或客服人员时,立即转人工,不要继续追问。", + "本环境的政策、套餐和校验码都是虚构测试数据,不代表真实公司的业务。", + ], + "ai_replies_enabled": True, "transfer_to_human": True, + "ask_for_rating": True, "handoff_collect_email": False, + "handoff_collect_name": False, "ticketing_enabled": False, + "allow_attachments": True, + })) + checked(client.put(f"agent/{agent['id']}/groups", json=[group["id"]])) + checked(client.post(f"agent/{agent['id']}/customization", json={ + "chat_style": "CHATBOT", "collect_email": False, + "accent_color": "#2563eb", "welcome_title": "网站智能客服", + "welcome_subtitle": "AI 解答 · 人工接待 · 服务评价", + "welcome_message": "你好!我是测试客服,可以查询演示知识库,也可以帮你转人工。", + "quick_actions": [], "chat_initiation_messages": [], "allow_new_chat": True, + })) + widgets = checked(client.get(f"widgets/agent/{agent['id']}")) + widget = widgets[0] if widgets else checked(client.post("widgets", json={"name": "Local Website", "agent_id": agent["id"]})) + environment = {"org_id": org_id, "agent_id": agent["id"], "widget_id": widget["id"], + "group_id": group["id"], "staff": [{"id": user["id"], "email": user["email"]} for user in staff], + "demo_url": "http://127.0.0.1:3018/local-demo.html"} + (STATE / "test-accounts.json").write_text(json.dumps(accounts, ensure_ascii=False, indent=2)) + (STATE / "test-environment.json").write_text(json.dumps(environment, ensure_ascii=False, indent=2)) + template = (LOCAL / "demo.template.html").read_text() + (STATE / "dashboard/local-demo.html").write_text(template.replace("__WIDGET_ID__", widget["id"])) + print("Created two scoped human agents, a support group, and the website widget.", flush=True) + + existing = checked(client.get(f"knowledge/agent/{agent['id']}"))["knowledge"] + title = "青禾测试套餐演示规则" + if not any(item.get("name") == title or item.get("source") == title for item in existing): + print("Indexing the demonstration knowledge source; the first run downloads the embedding model.", flush=True) + result = checked(client.post("knowledge/add/text", json={ + "org_id": org_id, "agent_id": agent["id"], "title": title, + "content": "这是一份技术验证专用的虚构知识库,不代表任何真实公司的政策。\n" + "青禾测试套餐的退款期限为购买后17天内;客服校验码为QH-7319。\n" + "申请退款需要提供演示订单编号,不需要提供银行卡密码。\n" + "人工客服可以处理演示订单核对、退款申请和复杂问题;用户要求人工时应立即转接。\n" + "服务完成后,访客可以提交1到5星评分和文字评价,管理员可以查看记录。", + })) + environment["knowledge_id"] = result["knowledge"]["id"] + (STATE / "test-environment.json").write_text(json.dumps(environment, ensure_ascii=False, indent=2)) + print("Local demonstration is ready:", environment["demo_url"]) + + +if __name__ == "__main__": + main() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bfc1dae --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,199 @@ +version: '3.8' + +services: + frontend: + image: chattermate/frontend:latest + ports: + - "80:80" + env_file: + - ./frontend/.env + depends_on: + - backend + networks: + - chattermate-network + restart: unless-stopped + + backend: + image: chattermate/backend:latest + ports: + - "8000:8000" + environment: + - REDIS_ENABLED=true + - REDIS_URL=redis://redis:6379/0 + - WORKERS=1 + - TIMEOUT=120 + - LOG_LEVEL=info + # HuggingFace caching configuration + - HF_HOME=/app/.cache/huggingface + - HF_HUB_CACHE=/app/.cache/huggingface/hub + - HF_HUB_DISABLE_TELEMETRY=1 + # Reduce parallelism to avoid conflicts + - EMBEDDING_MAX_WORKERS=4 + - KB_MAX_WORKERS=4 + # Enable safe embedding mode for Docker + - EMBEDDING_SINGLE_THREADED=true + - EMBEDDING_SEQUENTIAL_FALLBACK=true + env_file: + - ./backend/.env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - chattermate-network + restart: unless-stopped + volumes: + - backend_data:/app/uploads + - temp_data:/app/temp + - ./config:/app/config + # Add caching volumes for models + - huggingface_cache:/app/.cache/huggingface + healthcheck: + test: ["CMD", "curl", "-X", "GET", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + knowledge_processor: + image: chattermate/backend:latest + command: python -m app.workers.knowledge_processor + environment: + - REDIS_ENABLED=true + - REDIS_URL=redis://redis:6379/0 + - LOG_LEVEL=info + - PYTHONUNBUFFERED=1 + # HuggingFace caching configuration (shared with backend) + - HF_HOME=/app/.cache/huggingface + - HF_HUB_CACHE=/app/.cache/huggingface/hub + - HF_HUB_DISABLE_TELEMETRY=1 + # PyTorch configuration (shared with backend) + # Reduce parallelism to avoid conflicts + - EMBEDDING_MAX_WORKERS=2 + - KB_MAX_WORKERS=2 + # Enable safe embedding mode for Docker + - EMBEDDING_SINGLE_THREADED=true + - EMBEDDING_SEQUENTIAL_FALLBACK=true + env_file: + - ./backend/.env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - chattermate-network + restart: always + volumes: + - backend_data:/app/uploads + - temp_data:/app/temp + - ./config:/app/config + # Share the same caching volumes with backend + - huggingface_cache:/app/.cache/huggingface + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + crm_sync: + image: chattermate/backend:latest + command: python -m app.workers.crm_sync + environment: + - LOG_LEVEL=info + - PYTHONUNBUFFERED=1 + env_file: + - ./backend/.env + depends_on: + db: + condition: service_healthy + networks: + - chattermate-network + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + ticket_investigator: + image: chattermate/backend:latest + command: python -m app.workers.ticket_investigator + environment: + - REDIS_ENABLED=true + - REDIS_URL=redis://redis:6379/0 + - LOG_LEVEL=info + - PYTHONUNBUFFERED=1 + # FastEmbed (dedup embeddings) shares the HuggingFace cache + - HF_HOME=/app/.cache/huggingface + - HF_HUB_CACHE=/app/.cache/huggingface/hub + - HF_HUB_DISABLE_TELEMETRY=1 + env_file: + - ./backend/.env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - chattermate-network + restart: always + volumes: + - huggingface_cache:/app/.cache/huggingface + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + db: + build: + context: . + dockerfile: Dockerfile.postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=chattermate + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - chattermate-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - chattermate-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + +networks: + chattermate-network: + driver: bridge + +volumes: + postgres_data: + redis_data: + backend_data: + temp_data: + # Add new volumes for model caching + huggingface_cache: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f29ce5b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,218 @@ +version: '3.8' + +services: + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + ports: + - "3000:3000" + env_file: + - ./frontend/.env + volumes: + - ./frontend:/app + - frontend_node_modules:/app/node_modules + - backend_assets:/app/backend-assets + networks: + - app-network + + + backend: + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + env_file: + - ./backend/.env + environment: + - PYTHONUNBUFFERED=1 + # Development mode + - DEBUG=True + - ENVIRONMENT=development + # HuggingFace caching configuration + - HF_HOME=/app/.cache/huggingface + - HF_HUB_CACHE=/app/.cache/huggingface/hub + - HF_HUB_DISABLE_TELEMETRY=1 + # Reduce parallelism to avoid conflicts + - EMBEDDING_MAX_WORKERS=4 + - KB_MAX_WORKERS=4 + # Enable safe embedding mode for Docker + - EMBEDDING_SINGLE_THREADED=true + - EMBEDDING_SEQUENTIAL_FALLBACK=true + volumes: + # Hot reload: mount source code over the built code + - ./backend/app:/app/app + - ./backend/alembic:/app/alembic + - ./backend/alembic.ini:/app/alembic.ini + - ./backend/requirements.txt:/app/requirements.txt + - ./uploads:/app/uploads + - ./config:/app/config + - backend_assets:/app/assets + # Add caching volumes for models + - huggingface_cache:/app/.cache/huggingface + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-X", "GET", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - app-network + # Use watchdog for file change detection and auto-reload + command: bash -c "python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" + # Keep container running + stdin_open: true + tty: true + + knowledge_processor: + build: + context: . + dockerfile: Dockerfile + command: python -m app.workers.knowledge_processor + env_file: + - ./backend/.env + environment: + - PYTHONUNBUFFERED=1 + - DEBUG=True + - ENVIRONMENT=development + # HuggingFace caching configuration (shared with backend) + - HF_HOME=/app/.cache/huggingface + - HF_HUB_CACHE=/app/.cache/huggingface/hub + - HF_HUB_DISABLE_TELEMETRY=1 + # PyTorch configuration (shared with backend) + # Reduce parallelism to avoid conflicts + - EMBEDDING_MAX_WORKERS=2 + - KB_MAX_WORKERS=2 + # Enable safe embedding mode for Docker + - EMBEDDING_SINGLE_THREADED=true + - EMBEDDING_SEQUENTIAL_FALLBACK=true + volumes: + - ./backend:/app + - ./uploads:/app/uploads + - ./config:/app/config + # Share the same caching volumes with backend + - huggingface_cache:/app/.cache/huggingface + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - app-network + + ticket_investigator: + build: + context: . + dockerfile: Dockerfile + command: python -m app.workers.ticket_investigator + env_file: + - ./backend/.env + environment: + - PYTHONUNBUFFERED=1 + - DEBUG=True + - ENVIRONMENT=development + # FastEmbed (dedup embeddings) shares the HuggingFace cache + - HF_HOME=/app/.cache/huggingface + - HF_HUB_CACHE=/app/.cache/huggingface/hub + - HF_HUB_DISABLE_TELEMETRY=1 + volumes: + - ./backend:/app + - huggingface_cache:/app/.cache/huggingface + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - app-network + + crm_sync: + build: + context: . + dockerfile: Dockerfile + command: python -m app.workers.crm_sync + env_file: + - ./backend/.env + environment: + - PYTHONUNBUFFERED=1 + - DEBUG=True + - ENVIRONMENT=development + volumes: + - ./backend:/app + depends_on: + db: + condition: service_healthy + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - app-network + + db: + build: + context: . + dockerfile: Dockerfile.postgres + ports: + - "5432:5432" + environment: + - POSTGRES_DB=chattermate + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - app-network + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + postgres_data: + redis_data: + frontend_node_modules: + backend_assets: + # Add new volumes for model caching + huggingface_cache: diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh new file mode 100644 index 0000000..b20b158 --- /dev/null +++ b/frontend/docker-entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +# Generate config.js with runtime environment variables +cat < /usr/share/nginx/html/config.js +// Runtime configuration generated at container startup. +// Assigned to \`self\`, not \`window\`: the page sees it as window.APP_CONFIG +// (self === window there), and the service worker can importScripts() this +// same file to read the Firebase config, which has no window object. +self.APP_CONFIG = { + // API URLs + API_URL: "${VITE_API_URL:-http://localhost:8000/api/v1}", + WS_URL: "${VITE_WS_URL:-ws://localhost:8000}", + WIDGET_URL: "${VITE_WIDGET_URL:-http://localhost:8000}", + + // Firebase Configuration + FIREBASE_API_KEY: "${VITE_FIREBASE_API_KEY:-}", + FIREBASE_AUTH_DOMAIN: "${VITE_FIREBASE_AUTH_DOMAIN:-}", + FIREBASE_PROJECT_ID: "${VITE_FIREBASE_PROJECT_ID:-}", + FIREBASE_MESSAGING_SENDER_ID: "${VITE_FIREBASE_MESSAGING_SENDER_ID:-}", + FIREBASE_APP_ID: "${VITE_FIREBASE_APP_ID:-}", + FIREBASE_STORAGE_BUCKET: "${VITE_FIREBASE_STORAGE_BUCKET:-}", + FIREBASE_MEASUREMENT_ID: "${VITE_FIREBASE_MEASUREMENT_ID:-}", + FIREBASE_VAPID_KEY: "${VITE_FIREBASE_VAPID_KEY:-}", + + // Razorpay Checkout + RAZORPAY_KEY_ID: "${VITE_RAZORPAY_KEY_ID:-}", + // Google Tag Manager container (enables purchase/checkout analytics) + GTM_ID: "${VITE_GTM_ID:-}", + + // Google Fonts API + GOOGLE_FONTS_API_KEY: "${VITE_GOOGLE_FONTS_API_KEY:-}", + + // Node Environment + NODE_ENV: "${NODE_ENV:-development}", + HOST: "${HOST:-0.0.0.0}" +}; +EOF + +# Start nginx +exec "$@" \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e5ed2f9 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,98 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Enhanced Security headers. + # This config only ever runs inside the frontend image, i.e. for self-hosted + # deployments - app.chattermate.chat serves the SPA from its own reverse proxy + # and never routes here, so its CSP is set there and is unaffected by this file. + # The CSP is repeated in every location block below because an add_header inside + # a location cancels the inherited ones - change one, change all five. + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "frame-ancestors 'none'; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.shopify.com https://connect.facebook.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.shopify.com; font-src 'self' data: https://fonts.gstatic.com https://cdn.shopify.com; img-src 'self' data: blob: http: https:; connect-src 'self' https: http: ws: wss: blob:; frame-src 'self' http://localhost:* https:; worker-src 'self' blob:;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + location / { + try_files $uri $uri/ /index.html; + } + + # PWA control files must never be long-cached: a stale sw.js pins every + # installed client to an old build, and config.js is runtime-substituted + # per environment. (Exact locations win over the regex cache block below; + # add_header in a location cancels inherited headers, so re-add security.) + location = /sw.js { + add_header Cache-Control "no-cache" always; + add_header Service-Worker-Allowed "/" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "frame-ancestors 'none'; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.shopify.com https://connect.facebook.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.shopify.com; font-src 'self' data: https://fonts.gstatic.com https://cdn.shopify.com; img-src 'self' data: blob: http: https:; connect-src 'self' https: http: ws: wss: blob:; frame-src 'self' http://localhost:* https:; worker-src 'self' blob:;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + } + + location = /config.js { + add_header Cache-Control "no-cache" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "frame-ancestors 'none'; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.shopify.com https://connect.facebook.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.shopify.com; font-src 'self' data: https://fonts.gstatic.com https://cdn.shopify.com; img-src 'self' data: blob: http: https:; connect-src 'self' https: http: ws: wss: blob:; frame-src 'self' http://localhost:* https:; worker-src 'self' blob:;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + } + + location = /manifest.webmanifest { + default_type application/manifest+json; + add_header Cache-Control "no-cache" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "frame-ancestors 'none'; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.shopify.com https://connect.facebook.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.shopify.com; font-src 'self' data: https://fonts.gstatic.com https://cdn.shopify.com; img-src 'self' data: blob: http: https:; connect-src 'self' https: http: ws: wss: blob:; frame-src 'self' http://localhost:* https:; worker-src 'self' blob:;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 30d; + add_header Cache-Control "public, no-transform"; + + # Ensure security headers are also sent for static assets + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "frame-ancestors 'none'; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.shopify.com https://connect.facebook.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.shopify.com; font-src 'self' data: https://fonts.gstatic.com https://cdn.shopify.com; img-src 'self' data: blob: http: https:; connect-src 'self' https: http: ws: wss: blob:; frame-src 'self' http://localhost:* https:; worker-src 'self' blob:;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + } + + # API proxy + location /api/ { + proxy_pass http://backend:8000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + # Forward the real client IP so per-IP rate limiting keys on the client, + # not this proxy container. Overwrites any client-supplied X-Real-IP. + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} \ No newline at end of file