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。
+
+ 按这个顺序试一遍
+
+ - 问知识库:青禾测试套餐的退款期多长,校验码是什么?
+ - 要求转接:我要转人工。
+ - 在客服工作台接管会话,回复后结束接待,再回这里提交评分。
+
+
+ 打开客服工作台 ↗
+ 套餐、政策与校验码均为虚构测试数据。此页面仅供本机测试;对话会调用已配置的模型 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