chore(deploy): 本地部署脚本、容器配置与运行说明

PostgreSQL/pgvector 与 Redis 独立容器,后端、worker、前端可一键启停;不覆盖既有密钥。
This commit is contained in:
WangShen007 committed 2026-10-19 21:30:00 +08:00
1 parent 83a16ed07d
commit 5be5ca3376
18 files changed
+1796

No files matched your search

+57
View File
@@ -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"]
+62
View File
@@ -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"]
+46
View File
@@ -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"]
+64
View File
@@ -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 "<!DOCTYPE html><html><head><meta charset='utf-8'><title>ChatterMate</title><link rel='icon' href='/favicon.ico'></head><body><div id='app'></div><script type='module' src='/src/main.ts'></script></body></html>" > 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;"]
+31
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
# ReplyPier 本机使用
## 打开演示页与客服工作台
- 网站悬浮客服演示:<http://127.0.0.1:3018/local-demo.html>
- 管理员 / 人工客服登录:<http://127.0.0.1:3018/>
- 会话工作台:<http://127.0.0.1:3018/conversations>
### 本机账号
| 身份 | 登录邮箱 | 用途 |
| --- | --- | --- |
| 管理员 | `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 离线推送和生产容量需在对应任务中配置或验证。
+206
View File
@@ -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())
+48
View File
@@ -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
+40
View File
@@ -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:
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ChatterMate 网站客服测试</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; color: #172033; background: #f5f7fb; font: 17px/1.8 system-ui, sans-serif; }
main { max-width: 880px; margin: 0 auto; padding: 96px 30px; }
.eyebrow { color: #2563eb; font-size: 14px; font-weight: 700; letter-spacing: .08em; }
h1 { font-size: clamp(30px, 5vw, 48px); line-height: 1.3; margin: 16px 0 22px; }
p { color: #526078; }
section { margin-top: 36px; padding: 26px 30px; background: white; border: 1px solid #e2e8f0; border-radius: 16px; }
h2 { font-size: 20px; margin: 0 0 14px; }
ol { margin: 0; padding-left: 24px; }
li { padding: 7px 0; }
a { color: #1d4ed8; }
.note { font-size: 14px; margin-top: 25px; }
</style>
</head>
<body>
<main>
<div class="eyebrow">CHATTERMATE · 本机测试</div>
<h1>你的网页,接入 AI 与人工客服。</h1>
<p>点击右下角悬浮按钮展开对话。这里连接的是本机部署的 ChatterMate 和你配置的 DeepSeek。</p>
<section>
<h2>按这个顺序试一遍</h2>
<ol>
<li>问知识库:<strong>青禾测试套餐的退款期多长,校验码是什么?</strong></li>
<li>要求转接:<strong>我要转人工。</strong></li>
<li>在客服工作台接管会话,回复后结束接待,再回这里提交评分。</li>
</ol>
</section>
<p><a href="/conversations" target="_blank" rel="noopener">打开客服工作台 ↗</a></p>
<p class="note">套餐、政策与校验码均为虚构测试数据。此页面仅供本机测试;对话会调用已配置的模型 API。</p>
</main>
<script>
window.chattermateId = "__WIDGET_ID__";
window.chattermateBaseUrl = "http://127.0.0.1:8018/api/v1";
window.chattermateConfig = { position: "bottom-right" };
</script>
<script src="/webclient/chattermate.min.js"></script>
</body>
</html>
+62
View File
@@ -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()
+159
View File
@@ -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
+222
View File
@@ -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
+107
View File
@@ -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()
+199
View File
@@ -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:
+218
View File
@@ -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:
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# Generate config.js with runtime environment variables
cat <<EOF > /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 "$@"
+98
View File
@@ -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;
}
}