chore(deploy): 本地部署脚本、容器配置与运行说明
PostgreSQL/pgvector 与 Redis 独立容器,后端、worker、前端可一键启停;不覆盖既有密钥。
This commit is contained in:
1 parent
e0baca15a9
commit
18732801b5
18 files changed
+1796
No files matched your search
@@ -0,0 +1,40 @@
|
||||
name: replypier-local
|
||||
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:0.8.2-pg16
|
||||
environment:
|
||||
POSTGRES_DB: replypier
|
||||
POSTGRES_USER: replypier
|
||||
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 replypier -d replypier"]
|
||||
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:
|
||||
@@ -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>ReplyPier 网站客服测试</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">REPLYPIER · 本机测试</div>
|
||||
<h1>你的网页,接入 AI 与人工客服。</h1>
|
||||
<p>点击右下角悬浮按钮展开对话。这里连接的是本机部署的 ReplyPier 和你配置的 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.replypierId = "__WIDGET_ID__";
|
||||
window.replypierBaseUrl = "http://127.0.0.1:8018/api/v1";
|
||||
window.replypierConfig = { position: "bottom-right" };
|
||||
</script>
|
||||
<script src="/webclient/replypier.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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://replypier:{password}@127.0.0.1:15432/replypier",
|
||||
"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()
|
||||
Executable
+159
@@ -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/replypier.js --bundle --minify --format=iife \
|
||||
--target=es2015 --loader:.svg=text --define:process.env.NODE_ENV='"production"' \
|
||||
--define:__REPLYPIER_API_URL__='"http://127.0.0.1:8018/api/v1"' \
|
||||
--outfile="${STATE}/dashboard/webclient/replypier.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 replypier -d replypier -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user