Compare commits

...
48 changed files with 1605 additions and 287 deletions
+4 -2
View File
@@ -8,6 +8,8 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|---|---|
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
@@ -53,7 +55,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, and `voice.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
## Commit & Pull Request Guidelines
@@ -61,4 +63,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
## Security & Configuration Tips
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize their chat history, never customer orders; customer login/linking remains pending. Do not assign unowned legacy records to a new visitor.
+9 -5
View File
@@ -121,7 +121,7 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
### Jenkins 灰度部署
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,再配置独立域名的 HTTPS 和全站访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。面向客户开放前仍需补齐鉴权和数据隔离。另附 [Linux 单机参考](docs/deployment-linux.md)。
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,Nginx 提供 HTTPS。客户聊天入口免登录,只有明确列出的客户 API 开放;客服后台、搜索、导出和其他未知路径继续使用访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。上游端口必须仅绑定回环地址。另附 [Linux 单机参考](docs/deployment-linux.md)。
## 目录结构
@@ -246,7 +246,11 @@ data: {"d": 1}
## 数据库与运行数据
订单测试数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改。聊天每次重新查表;这份副本不会自动同步灰度业务库。没有新增管理页面和增删改接口。内部验收可查询已导入的测试样本;配置 `ORDER_DATA_TENANT_ID` 可以在服务器侧固定租户范围,不能将其视为客户登录授权。
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。匿名聊天不读取订单表;客户登录及关联将在后续接入,届时查询必须使用服务端验证的客户范围。`ORDER_DATA_TENANT_ID` 只是额外限制,不能代替客户身份。
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
@@ -258,12 +262,12 @@ data: {"d": 1}
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
## 当前限制
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史中的模拟轨迹保留演示标识。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。公网自用验收可按部署指南使用全站访问密码进入工作台。
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
+16 -3
View File
@@ -111,17 +111,30 @@ install -m 644 "$release/site.htpasswd" "$auth"
install -m 644 "$release/site.conf" "$site"
nginx -t
nginx -s reload
for path in / /api/export_chat /api/get_history; do
for check in /:200 /admin/dashboard:401 /api/export_chat:401 /api/get_history:401 /api/human/queue:401; do
path=${check%:*}
expected=${check##*:}
code=''
# A successful reload signal precedes the new workers accepting connections.
for attempt in {1..10}; do
if code=$(curl --silent --connect-timeout 3 --max-time 8 --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path"); then
[[ "$code" != 401 ]] || break
[[ "$code" != "$expected" ]] || break
fi
sleep 1
done
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
[[ "$code" == "$expected" ]] || { echo 'Guest/staff access verification failed.' >&2; false; }
done
visitor_cookie=$(mktemp "$release/.visitor-smoke.XXXXXX")
trap 'rm -f -- "$visitor_cookie"' EXIT
code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \
-c "$visitor_cookie" -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \
-d '{}' "https://$CHAT_DOMAIN/api/visitor/session")
[[ "$code" == 200 ]] || { echo 'Guest session bootstrap verification failed.' >&2; false; }
code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \
-b "$visitor_cookie" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN/api/get_history")
[[ "$code" == 200 ]] || { echo 'Guest history verification failed.' >&2; false; }
rm -f -- "$visitor_cookie"
trap - EXIT
printf '%s\n' "$release" > "$root/.current-release.new"
mv -f "$root/.current-release.new" "$root/current-release"
trap - ERR
+29 -13
View File
@@ -76,7 +76,7 @@ class GrayConfig:
return ''.join(f'{key}={value}\n' for key, value in fields.items())
def nginx_config(self):
return f'''# Managed for baiyue-logistics-chat-gray; all application routes require authentication.
return f'''# Managed for baiyue-logistics-chat-gray; guest chat is public and staff routes require authentication.
server {{
listen 80;
server_name {self.domain};
@@ -95,20 +95,36 @@ server {{
auth_basic "Baiyue gray acceptance";
auth_basic_user_file {self.auth_dir}/access.htpasswd;
client_max_body_size 12m;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
location = / {{
auth_basic off;
proxy_pass http://127.0.0.1:{self.port};
}}
location ^~ /static/ {{
auth_basic off;
proxy_pass http://127.0.0.1:{self.port};
}}
location ^~ /uploads/ {{
auth_basic off;
proxy_pass http://127.0.0.1:{self.port};
}}
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
auth_basic off;
proxy_pass http://127.0.0.1:{self.port};
}}
location / {{
proxy_pass http://127.0.0.1:{self.port};
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}}
}}
'''
+20 -16
View File
@@ -24,28 +24,32 @@ server {
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Guard all pages, API routes, attachments and history/export operations.
# Staff and unknown routes remain protected; guest APIs check their own visitor owner.
auth_basic "Baiyue private acceptance";
auth_basic_user_file /etc/nginx/.baiyue-preview.htpasswd;
# Allow multipart overhead around the application's 10 MiB file limit.
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# SSE must reach the browser as chunks arrive.
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {
auth_basic off;
proxy_pass http://127.0.0.1:5000;
}
location / { proxy_pass http://127.0.0.1:5000; }
}
+7 -3
View File
@@ -26,7 +26,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 历史接口 | `logistics_chat/history_service.py`:`new_chat`、`get_history`、`get_messages`、`del_history`、`clear_all`;对应 `routes/history.py` | `static/js/chat.js`:`ensureConv`、`loadHistory`、`openConversation` |
| 知识检索 | `logistics_chat/knowledge.py`:`knowledge_list`、`search_knowledge` | [knowledge.md](knowledge.md) 与代码同步,程序不读取该文档 |
| 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史模拟轨迹标为演示数据 |
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史卡片保留兼容渲染 |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
@@ -87,7 +87,10 @@ tests/ # 按业务模块组织的回归
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
- 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
- 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。
- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
@@ -107,7 +110,7 @@ tests/ # 按业务模块组织的回归
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。客户身份尚未接入,匿名 HTTP 查单在读取订单前返回账号关联提示;`g.customer_tenant_id` 只允许后续可信认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
@@ -117,7 +120,8 @@ tests/ # 按业务模块组织的回归
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
- 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
- 游客由 `visitor.js` 用安全随机数生成本地 key,经 `/api/visitor/session` 换取 HttpOnly、SameSite Cookie,HTTPS 时设置 Secure;浏览器存储不可用时可用 Cookie 恢复。数据库 `chat_history.visitor_id` 仅存 key 的 SHA-256,历史增删查、SSE 上下文和 `visitor_uploads` 附件索引均按归属校验。旧数据归属为空时保留,不自动授予新访客。客户端公共人工接口为 `/api/visitor/human/{request,poll,send,close}`,校验会话归属、只接受 customer 消息并去掉内部备注;原 `/api/human/*` 留给受保护客服。客服附件用 `/api/staff/uploads/`,所有客户 JSON 与附件禁止共享缓存。
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。
## 验证与维护
+4 -4
View File
@@ -2,9 +2,9 @@
现行灰度环境使用 [Jenkins 部署流程](deployment.md)。本页保留独立主机的安装方法,不用于覆盖已有灰度服务。
适用场景:Ubuntu / Debian、已有域名、仅自己验收。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx 全站访问密码 → 本机 Waitress → Flask / SQLite。
适用场景:Ubuntu / Debian、已有域名。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx(客户白名单公开,客服及其他路径需密码)→ 本机 Waitress → Flask / SQLite。
当前应用尚无完整登录会话、权限和会话数据隔离。验收期间必须保留全站访问密码,不能只保护 `/admin`,因为历史、导出和人工接口也能直接调用。此配置不代表已具备面向客户开放的权限体系。
游客历史、人工会话和附件已按访客归属隔离;客户账号登录和订单关联尚未接入,匿名不能查询真实订单。必须沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
## 1. 域名与网络
@@ -117,7 +117,7 @@ curl -u preview -I https://chat.example.com/
- 客户页:`https://chat.example.com/`,发送消息检查 DS 实际回复及流式显示。
- 验收工作台:`https://chat.example.com/admin/dashboard`,从客户页申请人工,再检查接待和双向消息。
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是整站验收密码,不是 `/admin` 页里的客服账号。
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是客服及受保护接口的访问账号,不是 `/admin` 页里的客服账号,客户首页无需此账号。
安装证书续期后的 Nginx 重载钩子,并检查续期定时器:
@@ -151,4 +151,4 @@ sudo nginx -t
## 参考
工厂调用与监听参数依据 [Waitress 官方文档](https://docs.pylonsproject.org/projects/waitress/en/stable/runner.html)。流式转发配置依据 [Nginx 代理模块](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering),全站密码依据 [Nginx Basic Auth 模块](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html)。证书和续期使用 [Certbot webroot](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot);服务目录与只读保护依据 [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)。
工厂调用与监听参数依据 [Waitress 官方文档](https://docs.pylonsproject.org/projects/waitress/en/stable/runner.html)。流式转发配置依据 [Nginx 代理模块](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering),客服后台密码依据 [Nginx Basic Auth 模块](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html)。证书和续期使用 [Certbot webroot](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot);服务目录与只读保护依据 [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)。
+4 -4
View File
@@ -1,8 +1,8 @@
# Jenkins 灰度部署
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS 和全站访问密码。
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
@@ -71,7 +71,7 @@
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
新环境自动创建业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
新环境自动创建业务表,不创建默认客服账号或演示数据。专用访问密码用于客服工作台及受保护接口;Nginx 账号不是应用内客服账号。客户首页无需密码,客户端先自动建立访客会话,再访问自己的历史。
## HTTPS 及续期
@@ -103,7 +103,7 @@ nginx -t
- SSH 失败:核对受管凭据和 known_hosts,不禁用指纹检查。
- 证书失败:核对 DNS 和 HTTP 验证路径,保留本服务的验证站点与私有 ACME 状态。
- 容器不健康:检查独立目录的 UID 10001 权限、日志和 `/healthz`;健康检查不计入业务限流。
- 入口 401:未登录时为预期,使用专用验收密码后应返回 200。
- 客户首页应匿名返回 200,不出现密码弹窗;客服后台、全站搜索和导出匿名返回 401。客户历史 API 在未建立访客会话时由应用返回 JSON 401,自动建立 `/api/visitor/session` 后应返回当前访客的记录。发布激活脚本同时检查这些边界,不能只删除原有密码配置。
- 流式显示延迟:检查实际 Nginx 或 CDN 是否重新启用了响应缓冲。
独立主机安装另见 [Linux 手动参考](deployment-linux.md)。凭据与 API 依据 [Jenkins 官方文档](https://www.jenkins.io/doc/pipeline/steps/credentials-binding/) 和 [Remote API](https://www.jenkins.io/doc/book/using/remote-access-api/),证书验证与续期依据 [Certbot 文档](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot)。
+2 -1
View File
@@ -9,7 +9,7 @@ def create_app(config=None):
from flask import Flask
from .config import ROOT_DIR, default_config
from . import db, file_service, hooks
from . import db, file_service, hooks, visitor_service
from .routes import register_routes
from .routes.speech import MemoryAudioRequest
@@ -22,6 +22,7 @@ def create_app(config=None):
app.extensions['human_typing'] = {}
app.extensions['rate_hits'] = defaultdict(list)
app.before_request(hooks.rate_limit)
app.before_request(visitor_service.attach_visitor)
app.after_request(hooks.no_cache_html)
app.teardown_appcontext(db.close_connections)
register_routes(app)
+21 -4
View File
@@ -8,8 +8,17 @@ from .tracking import card_to_text
def feedback(data):
"""收集 👍/👎 反馈,存到 feedback 表"""
if not isinstance(data, dict):
return {'ok': False, 'error': '评价格式无效。'}, 400
raw_vote = data.get('vote')
votes = {'up': 1, 'down': -1, '1': 1, '-1': -1}
vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote
question, answer = data.get('question', ''), data.get('answer', '')
if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str):
return {'ok': False, 'error': '评价内容无效,请重试。'}, 400
conn = connect()
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str()))
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)',
(question[:500], answer[:2000], vote, now_str()))
conn.commit()
conn.close()
return {'ok': True}
@@ -19,8 +28,8 @@ def admin_stats():
conn = connect()
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0]
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0]
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0]
today = datetime.now().strftime('%Y-%m-%d')
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
@@ -39,8 +48,16 @@ def my_orders(phone):
def add_ticket(data):
if not isinstance(data, dict):
return {'ok': False, 'error': '留言格式无效。'}, 400
content, phone = data.get('content'), data.get('phone', '')
if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000:
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
if not isinstance(phone, str) or len(phone.strip()) > 32:
return {'ok': False, 'error': '联系电话格式无效。'}, 400
conn = connect()
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)',
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.commit()
conn.close()
return {'ok': True}
+2 -6
View File
@@ -83,12 +83,8 @@ def create_chat_stream(msg, conv_id):
if not msg:
return (sse({'e': '问题不能为空'}), {})
if conv_id:
connection = connect()
try:
exists = connection.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone()
finally:
connection.close()
if not exists:
from .visitor_service import owns_conversation
if not owns_conversation(conv_id):
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
order_answer = answer_query(msg, conv_id)
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
+8
View File
@@ -37,6 +37,8 @@ def init_db():
Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True)
conn = connect()
cur = conn.cursor()
# Serialize schema inspection and migration when multiple workers start together.
cur.execute('BEGIN IMMEDIATE')
cur.execute('''
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -48,6 +50,12 @@ def init_db():
if 'utime' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN utime TEXT')
cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL')
if 'visitor_id' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)')
cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads (
filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+28 -3
View File
@@ -1,22 +1,36 @@
"""Attachment validation, storage and retention without request parsing."""
import os
import random
import secrets
import re
import time
from datetime import datetime
from urllib.parse import unquote
from .config import get_setting
def cleanup_uploads():
"""删除 uploads/ 里 7 天前的图片"""
"""Keep attachments referenced by chat history; expire unused uploads after 7 days."""
try:
udir = get_setting('UPLOAD_DIR')
if not os.path.isdir(udir):
return
from .db import connect
connection = connect()
try:
referenced = set()
rows = connection.execute("SELECT content FROM messages WHERE content LIKE '%/uploads/%' "
"UNION ALL SELECT content FROM human_messages WHERE content LIKE '%/uploads/%'")
for (content,) in rows:
referenced.update(unquote(name) for name in re.findall(r'/uploads/([A-Za-z0-9_.%-]+)', content or ''))
finally:
connection.close()
cutoff = time.time() - 7 * 86400
for f in os.listdir(udir):
if f in referenced:
continue
fp = os.path.join(udir, f)
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
os.remove(fp)
@@ -40,7 +54,18 @@ def upload_file(files):
if size > get_setting('MAX_UPLOAD_MB') * 1024 * 1024:
return ({'ok': False, 'error': f"文件超过 {get_setting('MAX_UPLOAD_MB')}MB"}, 400)
os.makedirs(get_setting('UPLOAD_DIR'), exist_ok=True)
safe_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{random.randint(1000, 9999)}{ext}"
safe_name = secrets.token_hex(16) + ext
f.save(os.path.join(get_setting('UPLOAD_DIR'), safe_name))
from .db import connect, now_str
from .visitor_service import visitor_id
owner = visitor_id()
if owner:
connection = connect()
try:
with connection:
connection.execute('INSERT INTO visitor_uploads(filename,visitor_id,ctime) VALUES(?,?,?)',
(safe_name, owner, now_str()))
finally:
connection.close()
is_image = ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp')
return {'ok': True, 'name': f.filename, 'url': f'/uploads/{safe_name}', 'is_image': is_image}
+17 -7
View File
@@ -1,16 +1,18 @@
"""Conversation CRUD with unchanged response data."""
from .db import connect, now_str
from .visitor_service import visitor_id, owns_conversation
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。'
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。'
def new_chat():
now = now_str()
conn = connect()
cur = conn.cursor()
cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now))
cur.execute('INSERT INTO chat_history(title, ctime, utime, visitor_id) VALUES (?,?,?,?)',
('新对话', now, now, visitor_id()))
conv_id = cur.lastrowid
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
(conv_id, 'assistant', WELCOME_TEXT, now))
@@ -21,12 +23,15 @@ def new_chat():
def get_history():
conn = connect()
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history ORDER BY COALESCE(utime, ctime) DESC, id DESC').fetchall()
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? '
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),)).fetchall()
conn.close()
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
def get_messages(conv_id):
if not owns_conversation(conv_id):
return []
conn = connect()
rows = conn.execute('SELECT role, content, ctime FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 100', (conv_id,)).fetchall()
conn.close()
@@ -35,6 +40,8 @@ def get_messages(conv_id):
def del_history(hid):
if not owns_conversation(hid):
return {'ok': False}, 404
conn = connect()
cur = conn.cursor()
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions WHERE conv_id=?)', (hid,))
@@ -49,10 +56,13 @@ def del_history(hid):
def clear_all():
conn = connect()
cur = conn.cursor()
cur.execute('DELETE FROM human_messages')
cur.execute('DELETE FROM human_sessions')
cur.execute('DELETE FROM messages')
cur.execute('DELETE FROM chat_history')
owner = visitor_id()
owned = 'SELECT id FROM chat_history WHERE visitor_id=?'
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions '
'WHERE conv_id IN ('+owned+'))', (owner,))
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', (owner,))
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', (owner,))
cur.execute('DELETE FROM chat_history WHERE visitor_id=?', (owner,))
conn.commit()
conn.close()
return {'ok': True}
+2 -2
View File
@@ -7,7 +7,7 @@ from flask import current_app, jsonify, request
def rate_limit():
path = request.path
if path == '/healthz' or path.startswith('/static') or path.startswith('/api/human/'):
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
return None
ip = request.remote_addr or 'unknown'
now = time.time()
@@ -21,7 +21,7 @@ def rate_limit():
def no_cache_html(response):
if response.mimetype in ('text/html', 'text/event-stream'):
if response.mimetype in ('text/html', 'text/event-stream') or request.path.startswith('/api/'):
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
+7 -4
View File
@@ -1,18 +1,21 @@
"""Human conversation lifecycle; typing state is supplied by the app."""
import time
from datetime import datetime
from datetime import datetime, timedelta, timezone
from .db import connect
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
def human_request(data):
conv_id = data.get('conv_id')
if not conv_id:
return ({'ok': False, 'error': 'no conv'}, 400)
if not 8 <= datetime.now().hour < 22:
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
service_time = datetime.now(SERVICE_TIMEZONE)
if not 8 <= service_time.hour < 22:
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'}
now = service_time.strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
+16 -3
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from .config import get_setting
from .order_tracking import build_tracking
from .visitor_service import customer_tenant_id
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
@@ -22,6 +23,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目
'parent':'父订单号', 'updated':'来源更新时间'}
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
ORDER_LOGIN_TEXT = '订单查询将在客户账号登录并关联后开放。您可以先咨询运费、时效,或联系人工客服。'
class OrderDataUnavailable(Exception):
@@ -110,7 +112,7 @@ def import_tracking_records(connection, records, replace=False):
return {'imported': imported, 'preserved': preserved, 'missing': missing}
def lookup_order(reference):
def lookup_order(reference, tenant_id=None):
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
from .db import connect
@@ -120,6 +122,12 @@ def lookup_order(reference):
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)'
values = [reference] * 3
tenant = get_setting('ORDER_DATA_TENANT_ID')
if tenant_id is not None:
if not str(tenant_id).strip():
return []
if tenant and str(tenant_id) != str(tenant):
return []
tenant = str(tenant_id)
if tenant:
query += ' AND tenant_id=?'
values.append(str(tenant))
@@ -210,6 +218,9 @@ def is_order_followup(message):
def answer_query(message, conv_id):
references = extract_reference(message)
tenant = customer_tenant_id()
if references and tenant is None:
return {'t': ORDER_LOGIN_TEXT}
if len(references) > 1:
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
reference = references[0] if references else None
@@ -238,10 +249,12 @@ def answer_query(message, conv_id):
pass
if not reference:
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|物流轨迹|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
return {'t':'请提供订单号、入仓码或 Shipment ID。'}
return {'t':ORDER_LOGIN_TEXT if tenant is None else '请提供订单号、入仓码或 Shipment ID。'}
return None
if tenant is None:
return {'t': ORDER_LOGIN_TEXT}
try:
matches = lookup_order(reference)
matches = lookup_order(reference, tenant_id=tenant)
except OrderDataUnavailable:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
if not matches:
+2 -2
View File
@@ -2,6 +2,6 @@
def register_routes(app):
from . import admin, business, chat, files, history, human, pages, speech
for module in (admin, business, chat, files, history, human, pages, speech):
from . import admin, business, chat, files, history, human, pages, speech, visitor
for module in (admin, business, chat, files, history, human, pages, speech, visitor):
app.register_blueprint(module.bp)
+8 -3
View File
@@ -2,7 +2,7 @@
from flask import Blueprint, request
from .. import business_service
from .. import business_service, visitor_service
bp = Blueprint('business', __name__)
@@ -13,7 +13,7 @@ def my_orders():
@bp.route('/api/ticket', methods=['POST'])
def add_ticket():
return business_service.add_ticket(request.get_json() or {})
return business_service.add_ticket(request.get_json(silent=True))
@bp.route('/api/tickets')
@@ -23,4 +23,9 @@ def list_tickets():
@bp.route('/api/rate', methods=['POST'])
def rate_chat():
return business_service.rate_chat(request.json or {})
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return {'ok': False}, 400
if not visitor_service.owns_human_session(data.get('sid')):
return {'ok': False}, 404
return business_service.rate_chat(data)
+1 -1
View File
@@ -45,4 +45,4 @@ def stream_chat():
@bp.route('/api/feedback', methods=['POST'])
def feedback():
return business_service.feedback(request.get_json(silent=True) or {})
return business_service.feedback(request.get_json(silent=True))
+25 -3
View File
@@ -1,12 +1,17 @@
"""HTTP adapters for files; business logic lives in services."""
from flask import Blueprint, request, send_from_directory
from flask import Blueprint, render_template, request, send_from_directory
from werkzeug.exceptions import NotFound
from .. import file_service
from .. import file_service, visitor_service
from ..config import get_setting
bp = Blueprint('files', __name__)
def unavailable(staff=False):
return render_template('attachment_unavailable.html', staff=staff), 404, {'Cache-Control':'private, no-store'}
@bp.route('/api/upload', methods=['POST'])
def upload_file():
return file_service.upload_file(request.files)
@@ -14,4 +19,21 @@ def upload_file():
@bp.route('/uploads/<path:name>')
def uploaded_file(name):
return send_from_directory(get_setting('UPLOAD_DIR'), name)
if not visitor_service.owns_upload(name):
return unavailable()
try:
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
except NotFound:
return unavailable()
response.headers['Cache-Control'] = 'private, no-store'
return response
@bp.route('/api/staff/uploads/<path:name>')
def staff_uploaded_file(name):
try:
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
except NotFound:
return unavailable(staff=True)
response.headers['Cache-Control'] = 'private, no-store'
return response
+47
View File
@@ -0,0 +1,47 @@
"""Guest bootstrap and owner-checked human support adapters, separate from staff APIs."""
from flask import Blueprint, current_app, request
from werkzeug.exceptions import RequestEntityTooLarge
from .. import human_service, visitor_service
bp = Blueprint('visitor', __name__)
@bp.post('/api/visitor/session')
def visitor_session():
request.max_content_length = 1024
try:
return visitor_service.start_session(request.get_json(silent=True))
except RequestEntityTooLarge:
return {'ok': False, 'error': '访客会话格式无效。'}, 413
@bp.route('/api/visitor/human/<action>', methods=['GET', 'POST'])
def visitor_human(action):
methods = {'poll': 'GET', 'request': 'POST', 'send': 'POST', 'close': 'POST'}
if action not in methods or request.method != methods[action]:
return {'ok': False}, 404
data = request.get_json(silent=True) if request.method == 'POST' else {}
if not isinstance(data, dict):
return {'ok': False}, 400
if action == 'request':
if not visitor_service.owns_conversation(data.get('conv_id')):
return {'ok': False, 'error': 'conversation_not_found'}, 404
return human_service.human_request(data)
sid = request.args.get('sid', type=int) if action == 'poll' else data.get('sid')
if not visitor_service.owns_human_session(sid):
return {'ok': False, 'error': 'session_not_found'}, 404
if action == 'poll':
after = request.args.get('after', 0, type=int)
if not 0 <= after <= 9223372036854775807:
return {'ok': False}, 400
result = human_service.human_poll(sid, after, current_app.extensions['human_typing'])
result.pop('note', None)
return result
if action == 'send':
if data.get('side', 'customer') != 'customer':
return {'ok': False}, 403
return human_service.human_send({**data, 'side': 'customer'})
return human_service.human_close(data)
+100
View File
@@ -0,0 +1,100 @@
"""Guest possession tokens isolate chat data; they never identify an order customer."""
import hashlib
import re
import secrets
from flask import g, has_app_context, jsonify, request
from .db import connect
COOKIE_NAME = 'baiyue_visitor'
KEY_PATTERN = re.compile(r'[0-9a-f]{64}\Z')
CUSTOMER_PATHS = {
'/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history',
'/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback',
'/api/speech/config', '/api/speech/transcribe',
}
def valid_key(value):
return isinstance(value, str) and KEY_PATTERN.fullmatch(value) is not None
def visitor_id():
return getattr(g, 'visitor_id', None) if has_app_context() else None
def customer_tenant_id():
# Future login adapters may set this only after validating a customer session.
# Visitor cookies, request parameters and headers are deliberately not accepted.
value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None
if isinstance(value, bool) or not isinstance(value, (str, int)):
return None
tenant = str(value).strip()
return tenant if tenant and len(tenant) <= 128 else None
def attach_visitor():
key = request.cookies.get(COOKIE_NAME)
g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None
is_customer = request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/')
if is_customer and not visitor_id():
return jsonify({'ok': False, 'e': '访客会话已失效,请刷新页面重试。'}), 401
if (is_customer or request.path == '/api/visitor/session') and request.method not in ('GET', 'HEAD', 'OPTIONS'):
origin = request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):
return jsonify({'ok': False, 'e': '请求来源无效。'}), 403
def start_session(data):
if not isinstance(data, dict):
return {'ok': False, 'error': '访客会话格式无效。'}, 400
key = data.get('key')
if key is None:
existing = request.cookies.get(COOKIE_NAME)
key = existing if valid_key(existing) else secrets.token_hex(32)
if not valid_key(key):
return {'ok': False, 'error': '访客标识无效。'}, 400
response = jsonify({'ok': True, 'key': key})
response.set_cookie(COOKIE_NAME, key, max_age=365*24*3600, httponly=True,
secure=request.is_secure, samesite='Lax', path='/')
response.headers['Cache-Control'] = 'no-store'
return response
def owns_conversation(conv_id):
owner = visitor_id()
if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807:
return False
connection = connect()
try:
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=?',
(conv_id, owner)).fetchone())
finally:
connection.close()
def owns_human_session(sid):
owner = visitor_id()
if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807:
return False
connection = connect()
try:
return bool(connection.execute('SELECT 1 FROM human_sessions s JOIN chat_history h ON h.id=s.conv_id '
'WHERE s.id=? AND h.visitor_id=?', (sid, owner)).fetchone())
finally:
connection.close()
def owns_upload(name):
owner = visitor_id()
if not owner:
return False
connection = connect()
try:
return bool(connection.execute('SELECT 1 FROM visitor_uploads WHERE filename=? AND visitor_id=?',
(name, owner)).fetchone())
finally:
connection.close()
+32 -34
View File
@@ -1,5 +1,5 @@
/* Customer workspace: navigation, welcome, conversation and composer. */
body { padding: 20px; height: 100dvh; overflow: hidden; }
body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.chat-container {
display: flex; width: min(1440px, 100%); height: 100%; margin: auto;
overflow: hidden; position: relative; border: 1px solid var(--border);
@@ -42,9 +42,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; }
.clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
.clear-all-btn:hover { color: var(--danger); }
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
.main-chat { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
.header-copy { min-width: 0; flex: 1; }
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
@@ -64,7 +63,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.ai-avatar svg { width: 27px; height: 27px; }
.welcome-eyebrow { color: var(--brand-text); font-size: 11px; font-weight: 600; letter-spacing: .06em; margin-bottom: 9px; }
.ai-welcome-card h3 { font-size: clamp(22px, 2.2vw, 30px); line-height: 1.4; letter-spacing: -.035em; font-weight: 650; color: var(--text); }
.ai-welcome-card p { color: var(--secondary); font-size: 13px; line-height: 1.8; margin-top: 10px; }
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0 0; }
.suggest-item { display: flex; align-items: center; gap: 13px; min-height: 83px; padding: 17px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--text); text-align: left; width: 100%; }
.suggest-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); }
@@ -72,7 +70,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.suggest-icon svg { width: 19px; height: 19px; }
.suggest-copy { min-width: 0; }
.suggest-copy strong { display: block; font-size: 14px; font-weight: 600; }
.suggest-copy small { display: block; color: var(--secondary); font-size: 12px; margin-top: 4px; line-height: 1.6; }
.suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; }
.hot-questions { margin-top: 24px; }
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
@@ -86,8 +83,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.recent-item:hover { color: var(--brand-text); }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.composer-toolbar { display: flex; gap: 8px; align-items: flex-start; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; }
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
.voice-toggle, .voice-hold { display: none; }
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
.voice-panel p { margin: 9px 0; font-size: 13px; }
@@ -97,7 +94,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
.hot-bar-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); }
.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
@@ -110,12 +106,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
#input-text::placeholder { color: var(--muted); }
#input-text:placeholder-shown { white-space: nowrap; overflow: hidden; }
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
#send-button:hover { background: var(--brand-hover); }
#send-button.stop { background: var(--danger); }
.composer-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 2px 0; }
.disclaimer, .keyboard-hint { color: var(--muted); font-size: 11px; }
.keyboard-hint { white-space: nowrap; }
.msg-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 820px; margin: 0 auto 24px; }
.msg-row.user-line { align-items: flex-end; }
.message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; }
@@ -132,6 +126,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); }
.msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; }
.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); }
.vote-status { align-self: center; color: var(--muted); font-size: 11px; }
.vote-status.is-error { color: var(--danger); }
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
.err-line { margin-bottom: 9px; }
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
@@ -159,7 +155,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.track-expand { width: 100%; margin-top: 3px; padding: 7px 10px; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
.track-expand:hover { background: var(--brand-soft); }
.track-card .msg-actions { margin-top: 8px; padding-top: 8px; }
.order-data-notice { margin: 12px 0; padding: 10px; border-radius: 7px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; line-height: 1.8; }
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
.order-data-fields dt { color: var(--muted); }
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
@@ -177,7 +172,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.attachment-file-icon { display: grid; place-items: center; flex-shrink: 0; width: 42px; height: 42px; font-size: 10px; font-weight: 650; border-radius: 7px; background: var(--brand-soft); color: var(--brand-text); }
.attachment-copy { flex: 1; min-width: 0; }
.attachment-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; }
.attachment-copy small { display: block; font-size: 10px; color: var(--muted); margin-top: 2px; }
.attachment-remove { flex-shrink: 0; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 7px; color: var(--secondary); background: transparent; font-size: 21px; }
.attachment-remove:hover { color: var(--danger); background: var(--danger-soft); }
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
@@ -189,7 +183,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.ai-welcome-card { padding-top: 0; }
.ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; }
.welcome-eyebrow { margin-bottom: 6px; }
.ai-welcome-card p { margin-top: 8px; }
.suggest-grid { margin-top: 20px; gap: 10px; }
.suggest-item { min-height: 72px; padding: 13px 16px; }
.hot-questions { margin-top: 18px; }
@@ -202,23 +195,35 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.composer { width: calc(100% - 50px); }
.service-label { display: none; }
.suggest-item { padding: 13px; gap: 10px; }
.suggest-copy small { font-size: 11px; }
}
@media (max-width: 900px) {
body { padding: 0; }
.chat-container { border: 0; border-radius: 0; height: 100dvh; }
.left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
.left-panel .brand { display: none; }
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
.main-chat { width: 100%; }
.chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; }
#chatDom { padding: 24px 20px 15px; }
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
.ai-welcome-card { padding-top: 20px; }
.composer { width: calc(100% - 40px); }
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
.chat-container.is-composing { --chat-header-height: 56px; }
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
.is-composing .composer-footer { display: none; }
#input-text, .history-search { font-size: max(16px, 1em); }
.history-search { height: 44px; }
.history-search-wrap svg { top: 14px; }
.composer-toolbar { margin-bottom: 8px; }
.hot-bar .hot-capsule { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
.input-area { align-items: center; }
.input-area .emoji-control { display: flex; align-items: center; }
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
.user-message, .robot-message { max-width: 94%; }
.del-history-btn { opacity: 1; }
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; }
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
@@ -234,12 +239,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.ai-avatar { display: none; }
.ai-welcome-card { padding-top: 12px; }
.ai-welcome-card h3 { font-size: 23px; }
.ai-welcome-card p { max-width: 300px; margin: 10px auto 0; font-size: 12px; }
.suggest-grid { gap: 9px; margin-top: 22px; }
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 114px; padding: 13px; gap: 9px; }
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 88px; padding: 13px; gap: 9px; }
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
.suggest-copy strong { font-size: 13px; }
.suggest-copy small { font-size: 11px; }
.suggest-arrow { display: none; }
.hot-questions { margin-top: 22px; }
.hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
@@ -247,18 +250,13 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.hot-capsule { font-size: 10px; padding: 6px 8px; }
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; margin-bottom: 8px; }
.hot-bar-label { display: none; }
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; min-height: 32px; padding: 6px 9px; font-size: 11px; }
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; }
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; padding: 6px 9px; font-size: 11px; }
.input-area { gap: 7px; padding: 7px; }
#send-button { min-width: 57px; padding: 0 12px; }
#upload-btn { flex-basis: 36px; height: 42px; }
#upload-btn { flex-basis: 36px; }
#input-text { font-size: 16px; }
#send-button { min-height: 42px; }
.attachment-remove { width: 40px; height: 40px; }
.keyboard-hint { display: none; }
.composer-footer { justify-content: center; padding-top: 7px; }
.disclaimer { font-size: 10px; }
.track-text { grid-template-columns: 1fr; gap: 2px; }
.track-place { grid-column: 1; }
.robot-message.track-card { padding: 16px; }
+1
View File
@@ -81,6 +81,7 @@ svg { display: block; flex-shrink: 0; }
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
.emoji-picker button:hover { background: var(--surface-hover); }
.attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
::-webkit-scrollbar { width: 5px; height: 5px; }
+3 -2
View File
@@ -11,6 +11,7 @@ var typeTimer = null;
var toolsOpen = false;
var toolsOpener = null;
var replyInput = document.getElementById("replyText");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
function escapeText(value){
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
@@ -191,8 +192,8 @@ function appendMsg(message){
}
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
let content = escapeText(message.content);
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener"><img src="$1" alt="客户附件" class="message-image"></a>');
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
content = content.replace(/\[图片\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener"><img src="/api/staff/uploads/$1" alt="客户附件" class="message-image"></a>');
content = content.replace(/\[文件\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
const avatar = message.side === "customer" ? "客" : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/></svg>';
div.innerHTML = '<span class="message-avatar" aria-hidden="true">'+avatar+'</span><div class="message-stack"><div class="meta">'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'</div><div class="bubble">'+content+'</div></div>';
if(message.side === "agent"){
+208 -106
View File
@@ -1,10 +1,39 @@
document.addEventListener('DOMContentLoaded', function(){
const fetch = VisitorSession.fetch;
const chatDom = document.getElementById("chatDom");
const inputDom = document.getElementById("input-text");
const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input");
const composerStatus = document.getElementById("composer-status");
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
let draftTimer = null;
let draftSnapshot = null;
function flushDraft(){
if(draftTimer !== null){ clearTimeout(draftTimer); draftTimer = null; }
const saved = drafts.write(currentConvId, inputDom.value);
drafts.active(currentConvId);
if(!saved && inputDom.value && !composerStatus.classList.contains('is-error')) setComposerStatus(t('draftSaveFailed'), true);
else if(saved && composerStatus.textContent === t('draftSaveFailed')) setComposerStatus('');
}
function restoreDraft(){
inputDom.value = drafts.read(currentConvId);
draftSnapshot = String(currentConvId) + '\n' + inputDom.value;
drafts.active(currentConvId);
autoGrow();
}
function queueDraft(){
const snapshot = String(currentConvId) + '\n' + inputDom.value;
if(snapshot === draftSnapshot) return;
draftSnapshot = snapshot;
if(draftTimer !== null) clearTimeout(draftTimer);
draftTimer = setTimeout(flushDraft, 250);
}
function focusComposer(force){
if(force || !isNarrow() || document.activeElement === inputDom){
inputDom.focus({preventScroll:true});
}
}
let uploadPending = false;
let uploadVersion = 0;
let uploadController = null;
@@ -22,7 +51,8 @@ let humanPollTimer = null;
let humanSendingToken = null;
let humanClosing = false;
window.humanSid = 0;
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable'));
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function setComposerStatus(message, isError){
@@ -112,20 +142,18 @@ uploadInput.onchange = function(){
copy.className = "attachment-copy";
const name = document.createElement("strong");
name.textContent = data.name;
const hint = document.createElement("small");
hint.textContent = t("attachmentReady");
copy.append(name, hint);
copy.append(name);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "attachment-remove";
remove.textContent = "×";
remove.setAttribute("aria-label", t("removeAttachment"));
remove.onclick = function(){ clearAttachment(); inputDom.focus(); };
remove.onclick = function(){ clearAttachment(); focusComposer(true); };
prev.append(copy, remove);
var area = document.querySelector(".input-area");
area.before(prev);
setComposerStatus("");
inputDom.focus();
focusComposer();
}
})
.catch(function(error){
@@ -210,8 +238,8 @@ const I18N = {
thinking: "正在思考中...",
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。",
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
offlinePrompt: "请输入您的留言:",
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
@@ -220,13 +248,15 @@ const I18N = {
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
humanActive: "人工客服接待中,消息将发送给人工客服。",
returnAI: "返回 AI 问答",
humanSent: "已发送给人工客服",
humanSending: "正在发送给人工客服…",
humanSendFailed: "消息未发送成功,内容已保留,请重试。",
humanConnectFailed: "人工客服连接失败,请稍后重试。",
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
humanReturnFailed: "暂时无法结束人工会话,请重试。",
historyLoading: "正在加载这段会话,请稍候…",
historyLoadFailed: "会话未加载成功,请重新选择该会话重试,或新建对话。",
draftSaveFailed: "草稿暂时无法保存到当前浏览器,刷新前请保留输入内容。",
attachmentUnavailable: "图片暂时无法访问,请重新发送附件。",
recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!",
@@ -234,27 +264,25 @@ const I18N = {
networkFail: "网络连接中断,请检查网络后重试",
stopped: "已停止生成",
stoppedMark: "(已停止生成)",
inputPh: "请输入问题、订单号或入仓码…(Enter 发送,Shift+Enter 换行)",
inputPh: "输入问题、订单号或入仓码…",
send: "发送",
stop: "停止",
copy: "复制",
copied: "已复制",
copyFailed: "复制失败",
voteUp: "这条回复有帮助",
voteDown: "这条回复没有帮助",
retry: "重答",
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答",
welcomeSub: "查询订单状态、了解专线报价,或联系人工客服。",
welcomeEyebrow: "中欧 · 英国物流服务",
serviceScope: "中欧与英国专线咨询",
serviceTag: "物流咨询服务",
quickLabel: "快捷咨询",
footerTrack: "查订单",
footerQuote: "专线报价",
footerException: "异常处理",
footerHuman: "联系人工",
disclaimer: "报价与时效仅供参考,请以客服最终确认为准",
keyboardHint: "Enter 发送 · Shift + Enter 换行",
inputShort: "输入问题、订单号或入仓码…",
inputShort: "输入问题…",
uploading: "附件正在上传,请稍候…",
uploadFailed: "附件上传失败,请重新选择文件",
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
@@ -270,7 +298,6 @@ const I18N = {
voiceEmpty: "没有识别到文字,请靠近麦克风重试。", voiceFailed: "语音输入失败,请重试或使用文字输入。",
voiceTimeout: "语音处理超时,请重试。", voiceWait: "请等待当前操作完成后,再使用语音输入。",
voiceDraft: "输入框还有未发送内容,请先发送或清空后再使用语音。",
attachmentReady: "已准备好,发送后将附在消息中",
removeAttachment: "移除待发送附件",
you: "您",
assistant: "柏悦物流助手",
@@ -284,15 +311,12 @@ const I18N = {
logisticsHistory: "物流轨迹",
noLogisticsHistory: "暂无已发生的物流节点",
estimatedTracking: "预计节点",
legacyTrackingNotice: "历史模拟轨迹,仅供演示,不代表真实订单。",
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
order_parent: "父订单号", order_updated: "来源更新时间",
moreTracking: "展开全部轨迹",
lessTracking: "收起较早轨迹",
quickSubs: ["输入订单号、入仓码或 Shipment ID", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
hot: "热点问题",
recent: "最近对话记录",
sideCollapse: "收起侧栏",
@@ -320,8 +344,8 @@ const I18N = {
thinking: "Thinking...",
busyRetry: "Service busy, please try again later",
sessionFail: "Failed to create session, please check the service is running",
autoWelcome: "Welcome to Baiyue Logistics. Enter an order number, inbound code or Shipment ID, or choose a quick option below.",
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.",
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.",
humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). Please leave a message and we will reply during service hours.",
offlinePrompt: "Please leave your message:",
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
@@ -330,13 +354,15 @@ const I18N = {
humanWaiting: "You are in the queue. A team member will reply after joining, or you can return to AI chat.",
humanActive: "You are chatting with our support team. Messages will be sent to a team member.",
returnAI: "Return to AI chat",
humanSent: "Sent to our support team",
humanSending: "Sending to our support team…",
humanSendFailed: "Message not sent. Your text has been kept. Please retry.",
humanConnectFailed: "Could not connect to our support team. Please try again.",
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
humanReturnFailed: "Could not end the support session. Please try again.",
historyLoading: "Loading this conversation. Please wait…",
historyLoadFailed: "Conversation could not load. Select it again to retry, or start a new chat.",
draftSaveFailed: "Your browser could not save this draft. Keep a copy before refreshing.",
attachmentUnavailable: "Image unavailable. Please upload the attachment again.",
recalled: "This message was recalled",
ratingQuestion: "How was your support experience?",
ratingThanks: "Thank you for your feedback!",
@@ -344,27 +370,25 @@ const I18N = {
networkFail: "Connection lost, please check your network and retry",
stopped: "Stopped",
stoppedMark: "(Generation stopped)",
inputPh: "Ask a question or enter a tracking number... (Enter to send, Shift+Enter for newline)",
inputPh: "Ask a question or enter an order number…",
send: "Send",
stop: "Stop",
copy: "Copy",
copied: "Copied",
copyFailed: "Copy failed",
voteUp: "This reply was helpful",
voteDown: "This reply was not helpful",
retry: "Regenerate",
retryAIOnly: "Return to AI chat before regenerating this answer.",
clickRetry: "Retry",
welcomeTitle: "Your next shipment, made clearer.",
welcomeSub: "Track a parcel, explore freight options, or speak to our team.",
welcomeEyebrow: "EUROPE & UK LOGISTICS",
serviceScope: "Europe & UK freight support",
serviceTag: "Logistics support",
quickLabel: "Quick help",
footerTrack: "Order lookup",
footerQuote: "Get a quote",
footerException: "Parcel issue",
footerHuman: "Human support",
disclaimer: "Quotes and transit times are subject to final confirmation",
keyboardHint: "Enter to send · Shift + Enter for a new line",
inputShort: "Question or tracking number…",
inputShort: "Message…",
uploading: "Uploading your attachment…",
uploadFailed: "Upload failed. Please select the file again.",
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
@@ -382,7 +406,6 @@ const I18N = {
voiceFailed: "Voice input failed. Please try again or type your message.", voiceTimeout: "Voice input timed out. Please try again.",
voiceWait: "Please wait for the current operation to finish before recording.",
voiceDraft: "Please send or clear your existing draft before recording.",
attachmentReady: "Ready to attach to your next message",
removeAttachment: "Remove pending attachment",
you: "You",
assistant: "Baiyue Assistant",
@@ -396,15 +419,12 @@ const I18N = {
logisticsHistory: "Shipment tracking",
noLogisticsHistory: "No actual shipment events available",
estimatedTracking: "Estimated milestones",
legacyTrackingNotice: "Historical simulated tracking for demonstration only; this is not a real order record.",
order_inbound: "Inbound code", order_shipment: "Shipment ID", order_country: "Destination country",
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level",
order_parent: "Parent order", order_updated: "Source updated",
moreTracking: "View full tracking history",
lessTracking: "Show recent updates",
quickSubs: ["Check the latest shipment progress", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"],
welcomeTip: "Type your question, or pick a quick option below.",
hot: "Popular Questions",
recent: "Recent Conversations",
sideCollapse: "Collapse sidebar",
@@ -477,6 +497,7 @@ function applyLanguage(lang){
const n = b.dataset.action;
if(n === "copy") b.textContent = dict.copy;
if(n === "retry") b.textContent = dict.retry;
if(n === 'voteUp' || n === 'voteDown') b.setAttribute('aria-label', dict[n]);
});
document.querySelectorAll(".retry-btn").forEach(function(b){
if(!b.dataset.keep) b.textContent = dict.clickRetry;
@@ -584,7 +605,7 @@ function welcomeHTML(){
const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`;
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong><small>${t("quickSubs")[index]}</small></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
}).join("");
return `
<div class="ai-welcome-card">
@@ -592,7 +613,6 @@ function welcomeHTML(){
<div class="ai-avatar">${welcomeIcon("box")}</div>
<div class="welcome-eyebrow">${t("welcomeEyebrow")}</div>
<h3>${t("welcomeTitle")}</h3>
<p>${t("welcomeSub")}</p>
</div>
<div class="suggest-grid">${cards}</div>
</div>
@@ -640,11 +660,12 @@ clearAllBtn.onclick = function(){
if(newChatBtn){
newChatBtn.onclick = function(){
if(openingConv === 'clearing') return;
const alreadyNew = currentConvId === null;
resetConversation(null);
inputDom.value = "";
if(alreadyNew) inputDom.value = "";
autoGrow();
clearAttachment();
setComposerStatus("");
setComposerStatus('');
chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = 0;
followLatest = true;
@@ -660,10 +681,13 @@ function loadHistory(){
historyList = data;
renderSideHistory();
renderRecentChat();
}).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ });
return data;
}).catch(function(){ return null; /* 保留上次成功加载的历史。 */ });
}
function removeCachedHistory(id){
if(id === undefined){ drafts.clear(); flushDraft(); }
else drafts.remove(id);
historyRequestVersion += 1;
historyList = id === undefined ? [] : historyList.filter(item => item.id !== id);
renderSideHistory();
@@ -810,6 +834,8 @@ function ensureConv(){
throw error;
}
currentConvId = data.id;
drafts.remove('new');
flushDraft();
if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId;
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
@@ -818,6 +844,8 @@ function ensureConv(){
}
function resetConversation(id){
flushDraft();
emojiPicker.close();
if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
@@ -825,6 +853,7 @@ function resetConversation(id){
clearAttachment();
stopHumanMode();
currentConvId = id;
restoreDraft();
openingConv = null;
creatingConv = null;
sendPreparing = false;
@@ -840,7 +869,9 @@ function openConversation(id){
if(openingConv === 'clearing') return;
if(openingConv === id) return; // 防连点
const version = resetConversation(id);
if(isNarrow()){ sideOpen = false; renderSidebar(); }
openingConv = id;
chatDom.innerHTML = '';
setComposerStatus(t('historyLoading'));
renderSideHistory();
requestJson("/api/get_messages?conv_id=" + id)
@@ -871,8 +902,8 @@ function openConversation(id){
requestAnimationFrame(function(){ scrollChatToEnd(true); });
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
openingConv = 'failed';
setComposerStatus(t('historyLoadFailed'), true);
});
}
@@ -896,14 +927,6 @@ function fillTrackCard(el, card){
head.appendChild(st);
el.appendChild(head);
if(!isOrder){
const legacyNotice = document.createElement('p');
legacyNotice.className = 'order-data-notice';
legacyNotice.dataset.i18n = 'legacyTrackingNotice';
legacyNotice.textContent = t('legacyTrackingNotice');
el.appendChild(legacyNotice);
}
if(isOrder){
const fields = document.createElement('dl');
fields.className = 'order-data-fields is-collapsed';
@@ -1032,7 +1055,30 @@ function addTrackCard(card, timeStr){
return bubble;
}
loadHistory();
const restoreId = drafts.active();
currentConvId = restoreId === 'new' ? null : Number(restoreId);
restoreDraft();
if(currentConvId){
const id = currentConvId, version = conversationVersion, initialDraft = inputDom.value;
openingConv = 'restoring';
loadHistory().then(function(history){
if(version !== conversationVersion || currentConvId !== id || openingConv !== 'restoring') return;
if(Array.isArray(history) && !history.some(item=>item.id === id)){
if(inputDom.value !== initialDraft){
openingConv = 'failed';
flushDraft();
setComposerStatus(t('historyLoadFailed'), true);
return;
}
drafts.remove(id);
currentConvId = null;
restoreDraft();
openingConv = null;
return;
}
openConversation(id);
});
}else loadHistory();
window.fillInput = function(words){
inputDom.value = words;
@@ -1061,24 +1107,6 @@ window.sendOrder = function(){
}
}
function myOrders(){
var phone = window.prompt("请输入您的手机号:");
if(!phone) return;
addMsg("查询手机号 "+phone+" 的包裹...", false);
fetch("/api/my_orders?phone="+encodeURIComponent(phone))
.then(r=>r.json())
.then(d=>{
if(!d.orders.length){
addMsg("手机号 "+phone+" 下暂无运单记录。", false);
}else{
var txt = "您名下有 "+d.orders.length+" 个包裹:\n";
d.orders.forEach(function(o){ txt += "• "+o.track_no+"("+o.status+")\n"; });
addMsg(txt, false);
}
})
.catch(function(){ addMsg("查询失败,请重试", false); });
}
function updateHumanMode(){
const banner = document.getElementById('human-session');
const label = document.getElementById('human-session-label');
@@ -1116,11 +1144,10 @@ async function humanRequest(path, data){
window.showHuman = async function(){
if(voiceInput) voiceInput.cancel();
if(openingConv === 'clearing') return;
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
if(humanRequestToken || humanClosing) return;
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
if(window.humanSid) stopHumanMode();
const hour = new Date().getHours();
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
const token = {version:conversationVersion};
humanRequestToken = token;
if(cancelActive){ cancelActive(); cancelActive = null; }
@@ -1129,7 +1156,7 @@ window.showHuman = async function(){
try{
const cid = await ensureConv();
if(humanRequestToken !== token || token.version !== conversationVersion) return;
const data = await humanRequest('/api/human/request', {conv_id:cid});
const data = await humanRequest('/api/visitor/human/request', {conv_id:cid});
if(humanRequestToken !== token || token.version !== conversationVersion) return;
if(data.closed){ await leaveOfflineMessage(); return; }
if(!data.ok || !data.session_id) throw new Error('Human session failed');
@@ -1151,13 +1178,13 @@ async function returnToAI(){
humanClosing = true;
updateHumanMode();
try{
const result = await humanRequest('/api/human/close', {sid:sid});
const result = await humanRequest('/api/visitor/human/close', {sid:sid});
if(!result.ok) throw new Error('Close failed');
if(version !== conversationVersion || window.humanSid !== sid) return;
stopHumanMode();
setComposerStatus('');
addMsg(t('humanClosed'), false);
inputDom.focus();
focusComposer();
}catch(error){
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
}finally{
@@ -1176,14 +1203,14 @@ async function sendHumanMessage(content){
updateHumanMode();
setComposerStatus(t('humanSending'));
try{
const result = await humanRequest('/api/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
const result = await humanRequest('/api/visitor/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
if(!result.ok) throw new Error('Send failed');
if(version !== conversationVersion || humanSendingToken !== token) return;
addMsg(content, true);
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
if(window.__pendingImg === attachment) clearAttachment();
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
inputDom.focus();
setComposerStatus('');
focusComposer();
return true;
}catch(error){
if(version !== conversationVersion || humanSendingToken !== token) return;
@@ -1197,7 +1224,7 @@ async function sendHumanMessage(content){
async function leaveOfflineMessage(){
const version = conversationVersion;
addMsg(t('humanHours'), false);
const message = window.prompt(t('offlinePrompt'));
const message = (window.prompt(t('offlinePrompt')) || '').trim();
if(!message) return;
try{
const data = await humanRequest('/api/ticket', {content:message, phone:''});
@@ -1205,8 +1232,8 @@ async function leaveOfflineMessage(){
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
}catch(error){
if(version !== conversationVersion) return;
if(!inputDom.value) inputDom.value = message;
setComposerStatus(t('networkFail'), true);
if(!inputDom.value){ inputDom.value = message; autoGrow(); }
setComposerStatus(error.status === 400 ? error.message : t('networkFail'), true);
}
}
@@ -1221,7 +1248,7 @@ function startHumanPoll(){
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid){ clearInterval(pollTimer); return; }
if(polling) return;
polling=true;
humanRequest("/api/human/poll?sid="+sid+"&after="+last)
humanRequest("/api/visitor/human/poll?sid="+sid+"&after="+last)
.then(d=>{
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid) return;
if(hadPollError){ setComposerStatus(''); hadPollError=false; }
@@ -1253,7 +1280,7 @@ function startHumanPoll(){
stopHumanMode();
setBusy(false);
addMsg(t('humanClosed'), false);
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed')});
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed'), fetch:fetch});
scrollChatToEnd();
return;
}
@@ -1403,26 +1430,34 @@ function attachActions(bubble, rawText, question, showRetry){
copyBtn.dataset.action = "copy";
copyBtn.textContent = t("copy");
copyBtn.onclick = function(){
if(copyBtn.disabled) return;
copyBtn.disabled = true;
const plain = mdToPlain(rawText);
const done = ()=> {
copyBtn.textContent = t("copied");
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200);
const done = function(copied){
copyBtn.disabled = false;
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
};
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(plain).then(done).catch(fallback);
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
}else{
fallback();
return fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.readOnly = true;
ta.style.position = "fixed";
ta.style.opacity = "0";
const previousFocus = document.activeElement;
document.body.appendChild(ta);
ta.select();
try{ document.execCommand("copy"); }catch(e){}
document.body.removeChild(ta);
done();
let copied = false;
try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){}
finally{
ta.remove();
if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true});
}
done(copied);
}
};
bar.appendChild(copyBtn);
@@ -1437,23 +1472,52 @@ function attachActions(bubble, rawText, question, showRetry){
}
// 👍/👎 反馈
let votePending = false;
const voteButtons = [];
const voteStatus = document.createElement('span');
voteStatus.className = 'vote-status';
voteStatus.setAttribute('role', 'status');
function addVote(vote, label){
const b = document.createElement("button");
b.className = "msg-action-btn";
b.textContent = label;
b.onclick = function(){
if(b.classList.contains("voted")) return;
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); });
b.classList.add("voted");
fetch("/api/feedback", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({question: question, answer: rawText, vote: vote})
}).catch(function(){});
b.dataset.action = vote === 'up' ? 'voteUp' : 'voteDown';
b.setAttribute('aria-label', t(vote === 'up' ? 'voteUp' : 'voteDown'));
b.setAttribute('aria-pressed', 'false');
b.onclick = async function(){
if(votePending || b.classList.contains('voted')) return;
votePending = true;
voteButtons.forEach(button => { button.disabled = true; });
voteStatus.textContent = '';
voteStatus.removeAttribute('data-i18n');
voteStatus.classList.remove('is-error');
try{
const data = await requestJson('/api/feedback', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({question:question, answer:rawText, vote:vote})
});
if(!data.ok) throw new Error('Feedback failed');
voteButtons.forEach(function(button){
button.classList.toggle('voted', button === b);
button.setAttribute('aria-pressed', String(button === b));
});
voteStatus.textContent = t('ratingThanks');
voteStatus.setAttribute('data-i18n', 'ratingThanks');
}catch(error){
voteStatus.textContent = t('ratingFailed');
voteStatus.setAttribute('data-i18n', 'ratingFailed');
voteStatus.classList.add('is-error');
}finally{
votePending = false;
voteButtons.forEach(button => { button.disabled = false; });
}
};
voteButtons.push(b);
bar.appendChild(b);
}
addVote("up", "👍");
addVote("down", "👎");
bar.appendChild(voteStatus);
bubble.appendChild(bar);
}
@@ -1488,7 +1552,7 @@ async function doSend(retryText){
var tag = window.__pendingTag || "[图片]";
const draft = inputDom.value, attachmentVersion = uploadVersion;
if(!sendWord && !imgUrl){
inputDom.focus();
focusComposer(true);
return;
}
if(uploadPending){ setComposerStatus(t("uploading")); return; }
@@ -1496,7 +1560,7 @@ async function doSend(retryText){
var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
setComposerStatus("");
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; }
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
@@ -1521,7 +1585,7 @@ async function doSend(retryText){
addMsg(fullContent, true);
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
if(attachmentVersion === uploadVersion) clearAttachment();
inputDom.focus();
focusComposer();
}
const aiBubble = addMsg(t("thinking"), false);
@@ -1554,7 +1618,6 @@ async function doSend(retryText){
setBusy(false);
aiBubble.classList.remove("typing");
if(activeBubble === aiBubble) activeBubble = null;
inputDom.focus();
let raw = aiBubble.dataset.raw || "";
// 模型有时会把卡片标记原样输出,这里兜底转成时间轴
@@ -1731,14 +1794,20 @@ sendBtn.addEventListener("click", function(){
// ===== 多行输入框:自动增高 =====
function autoGrow(){
queueDraft();
if(!inputDom.value){
inputDom.style.height = '';
return;
}
inputDom.style.height = "auto";
inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px";
const viewport = window.visualViewport;
const height = viewport && Math.abs(viewport.scale - 1) < 0.01 ? viewport.height : window.innerHeight;
const limit = isNarrow() && height > 0 ? Math.min(140, Math.max(44, Math.floor(height / 4))) : 140;
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
}
inputDom.addEventListener("input", autoGrow);
window.addEventListener('pagehide', flushDraft);
document.addEventListener('visibilitychange', function(){ if(document.hidden) flushDraft(); });
inputDom.addEventListener("keydown",(e)=>{
// Enter 发送,Shift+Enter 换行
@@ -1794,12 +1863,30 @@ renderSidebar();
function syncChatViewport(){
const viewport = window.visualViewport;
chatContainer.style.height = viewport && isNarrow() && viewport.scale === 1
? Math.round(viewport.height) + 'px' : '';
const narrow = isNarrow();
const unzoomed = !viewport || Math.abs(viewport.scale - 1) < 0.01;
if(narrow && unzoomed){
const height = viewport ? viewport.height : window.innerHeight;
if(Number.isFinite(height) && height > 0){
chatContainer.style.height = Math.round(height) + 'px';
// Keyboard focus may pan the visual viewport without changing its height.
chatContainer.style.top = Math.max(0, Math.round(viewport && viewport.offsetTop || 0)) + 'px';
}
}else{
chatContainer.style.height = '';
chatContainer.style.top = '';
}
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
autoGrow();
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
updateToBottom();
}
let viewportSyncQueued = false;
function scheduleChatViewport(){
if(viewportSyncQueued) return;
viewportSyncQueued = true;
requestAnimationFrame(function(){ viewportSyncQueued = false; syncChatViewport(); });
}
toggleBtn.onclick = function(){
sideOpen = !sideOpen;
@@ -1827,10 +1914,25 @@ window.addEventListener("resize", function(){
renderSidebar();
syncChatViewport();
});
if(window.visualViewport) window.visualViewport.addEventListener('resize', syncChatViewport);
if(window.visualViewport){
window.visualViewport.addEventListener('resize', scheduleChatViewport);
window.visualViewport.addEventListener('scroll', scheduleChatViewport);
}
window.addEventListener('pageshow', scheduleChatViewport);
inputDom.addEventListener('focus', scheduleChatViewport);
inputDom.addEventListener('blur', scheduleChatViewport);
// Let toolbar clicks complete before blur changes the keyboard and composer layout.
['pointerdown', 'mousedown'].forEach(function(name){
document.querySelector('.composer').addEventListener(name, function(event){
if(event.button === 0 && document.activeElement === inputDom && event.target.closest('button')){
event.preventDefault();
}
});
});
syncChatViewport();
voiceInput = ChatVoice.create({
fetch:fetch,
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
+77
View File
@@ -0,0 +1,77 @@
// Text drafts stay in this visitor's browser, independently for each conversation.
var ChatDrafts = {
create(options){
const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null;
const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null;
const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null;
const memory = new Map();
const dirty = new Set();
let current = 'new';
function id(value){
if(value === null || value === 'new') return 'new';
const key = String(value);
return /^[1-9][0-9]{0,18}$/.test(key) ? key : null;
}
function storage(name){
try{ return options[name](); }catch(error){ return null; }
}
function read(value){
const key = id(value);
if(!key) return '';
if(dirty.has(key)) return memory.get(key) || '';
if(prefix){
try{
const saved = storage('storage').getItem(prefix + key);
if(saved !== null){
const data = JSON.parse(saved);
if(data && data.version === 1 && typeof data.text === 'string'){
memory.set(key, data.text);
return data.text;
}
}
memory.delete(key);
return '';
}catch(error){}
}
return memory.get(key) || '';
}
function write(value, text){
const key = id(value);
if(!key || typeof text !== 'string') return false;
if(text) memory.set(key, text); else memory.delete(key);
dirty.add(key);
if(!prefix) return false;
try{
const target = storage('storage');
if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text}));
else target.removeItem(prefix + key);
dirty.delete(key);
return true;
}catch(error){ return false; }
}
function active(value){
if(arguments.length){
current = id(value) || 'new';
if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){}
}else if(activeKey){
try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){}
}
return current;
}
function clear(){
memory.clear();
dirty.clear();
if(prefix){
try{
const target = storage('storage');
for(let index = target.length - 1; index >= 0; index--){
const key = target.key(index);
if(key && key.startsWith(prefix)) target.removeItem(key);
}
}catch(error){}
}
active('new');
}
return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear};
}
};
+30 -9
View File
@@ -1,5 +1,17 @@
// Shared controls for the customer page and service workspace.
var SupportUI = {
initAttachments(container, label){
container.addEventListener('error', function(event){
const image = event.target;
if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return;
image.dataset.unavailable = '1';
image.hidden = true;
const hint = document.createElement('span');
hint.className = 'attachment-unavailable';
hint.textContent = typeof label === 'function' ? label() : label;
image.after(hint);
}, true);
},
async enableNotifications(button){
if(!('Notification' in window)) return;
try{
@@ -15,6 +27,7 @@ var SupportUI = {
}catch(error){}
},
addRating(container, sid, labels){
const request = labels.fetch || fetch;
const box = document.createElement('section');
box.className = 'service-rating';
const question = document.createElement('p');
@@ -40,7 +53,7 @@ var SupportUI = {
sending = true;
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
try{
const response = await fetch('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
const response = await request('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
const data = await response.json();
if(!response.ok || !data.ok) throw new Error('Rating failed');
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
@@ -57,6 +70,10 @@ var SupportUI = {
},
initEmoji(button, panel, input, onInsert){
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
function close(){
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
button.onclick = function(){
panel.hidden = !panel.hidden;
button.setAttribute('aria-expanded', String(!panel.hidden));
@@ -69,20 +86,24 @@ var SupportUI = {
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
const end = input.selectionEnd == null ? start : input.selectionEnd;
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
input.focus();
close();
input.focus({preventScroll:true});
input.setSelectionRange(start + emoji.length, start + emoji.length);
if(onInsert) onInsert();
};
panel.appendChild(choice);
});
panel.addEventListener('keydown', function(event){
if(event.key === 'Escape'){
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
button.focus();
['pointerdown','mousedown'].forEach(function(name){
document.addEventListener(name, function(event){
if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close();
});
});
document.addEventListener('keydown', function(event){
if(event.key === 'Escape' && !panel.hidden){
close();
button.focus({preventScroll:true});
}
});
return {close:close};
}
};
+49
View File
@@ -0,0 +1,49 @@
// A local random guest key restores this browser's history, never customer order identity.
var VisitorSession = (function(){
const storageName = 'baiyue-visitor-key';
const nativeFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : fetch;
let ready = null, established = false;
function localKey(){
try{
const saved = localStorage.getItem(storageName);
if(saved && /^[0-9a-f]{64}$/.test(saved)) return saved;
const crypto = window.crypto;
if(!crypto || !crypto.getRandomValues) return null;
const bytes = crypto.getRandomValues(new Uint8Array(32));
const key = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
localStorage.setItem(storageName, key);
return key;
}catch(error){ return null; } // HttpOnly cookie can still retain this guest when storage is disabled.
}
function ensure(){
if(ready) return ready;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
ready = nativeFetch('/api/visitor/session', {method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json'},body:JSON.stringify({key:localKey()}),signal:controller.signal})
.then(async response => {
const data = await response.json();
if(!response.ok || !data.ok || typeof data.key !== 'string' || !/^[0-9a-f]{64}$/.test(data.key)){
throw new Error('访客会话暂时无法建立,请刷新后重试。');
}
try{ localStorage.setItem(storageName, data.key); }catch(error){}
established = true;
}).catch(error => { ready = null; established = false; throw error; }).finally(() => clearTimeout(timer));
return ready;
}
function request(path, options){
const init = Object.assign({}, options, {credentials:'same-origin'});
function send(){
if(init.signal && init.signal.aborted) return Promise.reject(Object.assign(new Error('Aborted'), {name:'AbortError'}));
const session = ready;
return nativeFetch(path, init).then(async response => {
if(response.status !== 401) return response;
if(ready === session){ ready = null; established = false; }
await ensure();
return nativeFetch(path, init);
});
}
return established ? send() : ensure().then(send);
}
return {fetch:request,localKey:localKey};
})();
+2 -1
View File
@@ -1,6 +1,7 @@
// Short recordings use the same server ASR in mobile browsers and embedded browsers.
var ChatVoice = {
create(options){
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
let session = null, mode = false;
const active = current => session === current;
@@ -167,7 +168,7 @@ var ChatVoice = {
toggle.onclick = function(){
if(!mode && !supported()) return;
setMode(!mode);
if(!mode) input.focus();
if(!mode) input.focus({preventScroll:true});
};
if(window.PointerEvent){
hold.addEventListener('pointerdown', function(event){
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>附件暂时无法访问 · 柏悦物流</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
body { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
main { width: min(420px, 100%); padding: 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
h1 { font-size: 22px; margin-bottom: 14px; }
p { color: var(--secondary); margin: 10px 0; }
.english { font-size: 13px; }
a { display: inline-block; margin-top: 12px; }
</style>
</head>
<body><main>
<h1>附件暂时无法访问</h1>
<p>文件可能已过期、被删除或当前无法访问。请返回会话重新发送,或联系人工客服。</p>
<p lang="en" class="english">This attachment is unavailable. Please return to your conversation to upload it again or contact support.</p>
<a href="{{ url_for('admin.admin_dash') if staff else url_for('pages.index') }}">{{ '返回客服工作台' if staff else '返回聊天' }}</a>
</main></body>
</html>
+4 -6
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="theme-color" content="#315fd5">
<title>柏悦智能物流客服系统</title>
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
@@ -22,7 +22,6 @@
<div class="history-wrap" id="historyWrap"></div>
<div class="sidebar-footer">
<button type="button" class="clear-all-btn">清空全部历史</button>
<div class="sidebar-note"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceScope">中欧与英国专线咨询</span></div>
</div>
</aside>
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
@@ -44,14 +43,12 @@
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
<div class="welcome-eyebrow">中欧 · 英国物流服务</div>
<h3>物流问题,随时为您解答</h3>
<p>查询订单状态、了解专线报价,或联系人工客服。<br>从这里开始您的物流咨询。</p>
</div>
</section>
</div>
<footer class="composer">
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
<div class="composer-toolbar"><div class="hot-bar">
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
@@ -61,18 +58,19 @@
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
<button type="button" id="send-button">发送</button>
</div>
<p id="composer-status" class="composer-status" role="status" hidden></p>
<div class="composer-footer"><span class="disclaimer" data-i18n="disclaimer">AI 回答仅供参考,具体以人工客服回复为准</span><span class="keyboard-hint" data-i18n="keyboardHint">Enter 发送 · Shift + Enter 换行</span></div>
</footer>
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
</main>
</div>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body>
+3 -2
View File
@@ -32,6 +32,7 @@ async function workspace(compact = false){
setAttribute(name, value){ this.attributes[name] = value; }
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
appendChild(element){ this.children.push(element); }
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; }
querySelector(selector){
@@ -47,7 +48,7 @@ async function workspace(compact = false){
const quickButtons = [new Element(), new Element()];
document = {
body: new Element(), activeElement: null,
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
querySelector(selector){
@@ -55,7 +56,7 @@ async function workspace(compact = false){
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
},
addEventListener(name, callback){ documentListeners[name] = callback; },
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
const calls = [];
const hooks = {};
+353 -31
View File
@@ -6,6 +6,14 @@ const vm = require('node:vm');
const flush = () => new Promise(setImmediate);
async function customerPage(pageOptions = {}){
const storageValues = pageOptions.storage || {};
if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64);
const tabValues = pageOptions.tabStorage || {};
function testStorage(values){return {
getItem:key=>values[key] ?? null,setItem:(key,value)=>{values[key]=String(value);},
removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null,
get length(){return Object.keys(values).length;}
};}
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
let voiceOptions;
@@ -13,7 +21,7 @@ async function customerPage(pageOptions = {}){
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
this.listeners = {};
this.listeners = {}; this.focusCalls = []; this.isConnected = true;
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
@@ -32,7 +40,10 @@ async function customerPage(pageOptions = {}){
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
appendChild(child){ child.parent = this; this.children.push(child); }
append(...children){ children.forEach(child => this.appendChild(child)); }
focus(){}
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); }
blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } }
select(){ document.activeElement=this; }
querySelector(selector){
return this.querySelectorAll(selector)[0] || null;
}
@@ -49,28 +60,30 @@ async function customerPage(pageOptions = {}){
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
}
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
element('emoji-panel').hidden=true;
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
const document = {
body: new Element(), documentElement: {},
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
createElement: () => new Element(),
addEventListener: (name, callback) => { listeners[name] = callback; },
execCommand: () => pageOptions.copyResult !== false,
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]};
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
requestAnimationFrame:callback => callback(),
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
window:{innerWidth:pageOptions.width || 1200,
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
matchMedia:query => ({matches:pageOptions.width ? pageOptions.width <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1]) : false}),
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
prompt:() => pageOptions.offlineMessage || null},
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
@@ -80,7 +93,8 @@ async function customerPage(pageOptions = {}){
fetch: async (url, options) => {
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options);
if(url.startsWith('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
if(url === '/stream_chat'){
let read = false;
@@ -91,14 +105,15 @@ async function customerPage(pageOptions = {}){
}})}};
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/human/poll') ? state
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'voice.js', 'chat.js']){
for(const file of ['support.js', 'visitor.js', 'drafts.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
@@ -109,17 +124,26 @@ async function customerPage(pageOptions = {}){
await flush();
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, hooks, quickButtons, voiceOptions,
return {context, element, calls, hooks, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
resize:async height => {
context.window.innerHeight=height;
if(context.window.visualViewport) context.window.visualViewport.height=height;
(windowListeners.resize || []).forEach(callback => callback());
await flush();
},
resizeViewport:async (height,scale=1) => {
Object.assign(context.window.visualViewport,{height,scale});
resizeViewport:async (height,scale=1,offsetTop=0) => {
Object.assign(context.window.visualViewport,{height,scale,offsetTop});
(viewportListeners.resize || []).forEach(callback => callback());
await flush();
},
panViewport:async top => {
context.window.visualViewport.offsetTop=top;
(viewportListeners.scroll || []).forEach(callback => callback());
await flush();
},
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
setState:value => { state=value; },
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
@@ -362,7 +386,7 @@ test('all three footer questions leave the human queue and send to separate AI c
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
});
@@ -377,7 +401,7 @@ test('welcome and hot question buttons use the same AI routing as the footer', a
}
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
});
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
@@ -386,7 +410,7 @@ test('the welcome contact button opens the human queue and does not send an AI q
button.setAttribute('data-human-support', '');
app.element('chatDom').listeners.click({target:button});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
assert.equal(app.element('human-session').hidden, false);
});
@@ -397,7 +421,7 @@ test('new chat clears human routing and the next quote is sent to AI', async ()
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.__busy, false);
});
@@ -454,7 +478,7 @@ test('a late history response cannot overwrite the latest quick conversation', a
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/human/request'] = () => new Promise(done => { resolve=done; });
app.hooks['/api/visitor/human/request'] = () => new Promise(done => { resolve=done; });
const connecting = app.context.window.showHuman();
await flush();
await app.click(app.quickButtons[1]);
@@ -468,15 +492,15 @@ test('a delayed human connection cannot capture a newly selected AI topic', asyn
test('repeated contact clicks create one human request and show a visible queue state', async () => {
const app = await customerPage({human:false});
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.element('human-session').hidden, false);
assert.match(app.element('human-session-label').textContent, /正在排队/);
});
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'});
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
assert.match(app.element('chatDom').textContent, /留言已提交/);
assert.equal(app.context.window.humanSid, 0);
@@ -484,20 +508,27 @@ test('the contact shortcut outside service hours submits a ticket without enteri
test('a failed human send preserves the text and makes retry possible', async () => {
const app = await customerPage();
app.hooks['/api/human/send'] = () => response({ok:false}, 503);
app.hooks['/api/visitor/human/send'] = () => response({ok:false}, 503);
await app.context.window.fillInput('请确认这批货的价格');
assert.equal(app.element('input-text').value, '请确认这批货的价格');
assert.match(app.element('composer-status').textContent, /内容已保留/);
assert.equal(app.element('send-button').disabled, false);
});
test('a visitor whose phone says late at night can still reach an open support service', async () => {
const app=await customerPage({human:false,hour:23});
await app.context.window.showHuman();
assert.equal(app.context.window.humanSid,7);
assert.equal(app.calls.filter(call=>call.url==='/api/ticket').length,0);
});
test('a duplicate human send cannot submit an in-flight message twice', async () => {
const app = await customerPage();
let resolve;
app.hooks['/api/human/send'] = () => new Promise(done => { resolve=done; });
app.hooks['/api/visitor/human/send'] = () => new Promise(done => { resolve=done; });
const first = app.context.window.fillInput('请确认时效');
await app.context.window.fillInput('请确认时效');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 1);
resolve(response({ok:true}));
await first;
assert.equal(app.element('input-text').value, '');
@@ -508,7 +539,7 @@ test('returning to AI explicitly closes the human session and preserves the conv
await app.click(app.element('return-ai'));
await app.context.window.fillInput('请继续解答');
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 1);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/close').length, 1);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
assert.equal(app.context.window.humanSid, 0);
});
@@ -634,12 +665,99 @@ test('clearing a sent draft restores a single-row composer instead of sizing to
assert.equal(input.style.height,'');
});
test('keyboard panning keeps the whole mobile frame in the visual viewport and restores after closing', async () => {
const app=await customerPage({human:false,width:390,viewportHeight:844});
const input=app.element('input-text'), frame=app.element('.chat-container');
input.focus();
await app.resizeViewport(430,1,180);
assert.equal(frame.style.height,'430px');
assert.equal(frame.style.top,'180px');
assert.ok(frame.classList.contains('is-composing'));
await app.panViewport(260);
assert.equal(frame.style.top,'260px');
assert.equal(frame.style.height,'430px');
await app.resizeViewport(0,1,0);
assert.equal(frame.style.height,'430px');
input.blur();
await app.resizeViewport(844);
assert.equal(frame.style.top,'0px');
assert.equal(frame.style.height,'844px');
assert.equal(frame.classList.contains('is-composing'),false);
});
test('embedded browsers without visualViewport use the resized window and recover on pageshow', async () => {
const app=await customerPage({human:false,width:390,height:844});
const frame=app.element('.chat-container');
assert.equal(frame.style.height,'844px');
await app.resize(410);
assert.equal(frame.style.height,'410px');
app.context.window.innerHeight=844;
await app.pageShow();
assert.equal(frame.style.height,'844px');
app.context.window.innerWidth=1200;
await app.resize(720);
assert.equal(frame.style.height,'');
assert.equal(frame.style.top,'');
});
test('a multiline draft leaves room for messages when the keyboard reduces the viewport', async () => {
const app=await customerPage({human:false,width:390,viewportHeight:844});
const input=app.element('input-text');
input.value='多行草稿';
input.scrollHeight=300;
input.listeners.input();
assert.equal(input.style.height,'140px');
await app.resizeViewport(340);
assert.equal(input.style.height,'85px');
await app.resizeViewport(844);
assert.equal(input.style.height,'140px');
});
test('late AI completion does not steal focus or reopen a dismissed mobile keyboard', async () => {
for(const width of [390,1200]){
const app=await customerPage({human:false,width,viewportHeight:844});
const input=app.element('input-text');
let reply;
app.hooks['/stream_chat']=()=>new Promise(resolve=>{reply=resolve;});
input.focus();
await app.context.window.fillInput('报价问题');
await flush();
input.blur();
app.element('history-search').focus();
const before=input.focusCalls.length;
reply(streamResponse(['data: {"t":"完成回复"}\n\ndata: {"d":1}\n\n']));
await flush();await flush();
assert.equal(input.focusCalls.length,before);
assert.equal(app.context.document.activeElement,app.element('history-search'));
assert.match(app.element('chatDom').textContent,/完成回复/);
}
});
test('mobile quick questions keep the keyboard closed and composer controls finish clicking before blur changes layout', async () => {
const app=await customerPage({human:false,width:390});
const input=app.element('input-text');
await app.context.window.fillInput('快捷咨询');await flush();
assert.equal(input.focusCalls.length,0);
input.focus();
for(const event of ['pointerdown','mousedown']){
for(const name of ['send-button','voice-toggle','emoji-toggle','upload-btn']){
let prevented=false;
app.element('.composer').listeners[event]({button:0,target:{closest:()=>app.element(name)},preventDefault(){prevented=true;}});
assert.ok(prevented);
}
}
let blockedTextSelection=false;
app.element('.composer').listeners.pointerdown({button:0,target:{closest:()=>null},preventDefault(){blockedTextSelection=true;}});
assert.equal(blockedTextSelection,false);
assert.equal(app.context.document.activeElement,input);
});
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
for(const human of [false,true]){
const app=await customerPage({human,width:390});
await app.voiceOptions.onText('语音识别的物流问题');
await flush();
const call=app.calls.find(call=>call.url===(human ? '/api/human/send' : '/stream_chat'));
const call=app.calls.find(call=>call.url===(human ? '/api/visitor/human/send' : '/stream_chat'));
assert.ok(call);
assert.equal(human ? call.body.content : call.body.msg,'语音识别的物流问题');
assert.match(app.element('chatDom').querySelector('.user-message').textContent,/语音识别的物流问题/);
@@ -682,7 +800,211 @@ test('an AI regeneration is not forwarded to a human session', async () => {
await app.context.window.fillInput('之前的问题');await flush();
const retry=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
await app.context.window.showHuman();await app.click(retry);
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,0);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/human/send').length,0);
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
});
test('feedback waits for server confirmation, ignores rapid duplicates and allows retry after failure', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('反馈测试');await flush();
const log=app.element('chatDom'), buttons=log.querySelectorAll('.msg-action-btn');
const up=buttons.find(button=>button.textContent==='👍'), down=buttons.find(button=>button.textContent==='👎');
let finish;
app.hooks['/api/feedback']=()=>new Promise(resolve=>{finish=resolve;});
const sending=up.onclick();
await flush();
await down.onclick();
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,1);
assert.equal(up.classList.contains('voted'),false);
assert.ok(up.disabled && down.disabled);
finish(response({ok:false},503));await sending;
assert.equal(up.classList.contains('voted'),false);
assert.match(log.textContent,/评价未提交/);
assert.equal(up.disabled,false);
app.hooks['/api/feedback']=()=>response({ok:true});
await up.onclick();
assert.ok(up.classList.contains('voted'));
assert.equal(up.getAttribute('aria-pressed'),'true');
assert.match(log.textContent,/感谢您的评价/);
await up.onclick();
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,2);
});
test('clipboard rejection and a failed fallback never report a successful copy', async () => {
const app=await customerPage({human:false,copyResult:false,clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
await app.context.window.fillInput('复制测试');await flush();
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
app.element('input-text').focus();
await copy.onclick();await flush();
assert.equal(copy.textContent,'复制失败');
assert.equal(copy.disabled,false);
assert.equal(app.context.document.activeElement,app.element('input-text'));
assert.equal(app.element('input-text').focusCalls.at(-1).preventScroll,true);
assert.equal(app.context.document.body.children.length,0);
await app.runTimers(3000);
assert.equal(copy.textContent,'复制');
});
test('successful clipboard fallback reports copied and keeps text focus stable', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('复制测试');await flush();
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
await copy.onclick();
assert.equal(copy.textContent,'已复制');
assert.equal(copy.disabled,false);
await app.runTimers(1200);
assert.equal(copy.textContent,'复制');
});
test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => {
const empty=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:' '});
await empty.context.window.showHuman();await flush();
assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0);
const app=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:'需要保留的留言'});
app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400);
await app.context.window.showHuman();await flush();
assert.equal(app.element('input-text').value,'需要保留的留言');
assert.notEqual(app.element('input-text').style.height,'');
assert.match(app.element('composer-status').textContent,/1–2000/);
});
test('mobile history selection closes the drawer while desktop navigation stays visible', async () => {
for(const width of [390,1200]){
const app=await customerPage({human:false,width,history:[{id:9,title:'历史会话',ctime:'now'}]});
if(width===390) await app.click(app.element('.toggle-divider'));
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),'true');
await app.click(app.element('historyWrap').children[0]);
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),String(width>900));
}
});
test('emoji panel dismisses outside or on Escape, stays open for its own controls, and closes on conversation change', async () => {
const app=await customerPage({human:false,width:390});
const toggle=app.element('emoji-toggle'), panel=app.element('emoji-panel');
await app.click(toggle);
assert.equal(panel.hidden,false);
app.documentEvent('pointerdown',{target:panel.children[0]});
assert.equal(panel.hidden,false);
app.documentEvent('pointerdown',{target:app.element('input-text')});
assert.equal(panel.hidden,true);
assert.equal(toggle.getAttribute('aria-expanded'),'false');
await app.click(toggle);
app.documentEvent('keydown',{key:'Escape'});
assert.equal(panel.hidden,true);
await app.click(toggle);
await app.click(app.element('.new-chat-btn'));
assert.equal(panel.hidden,true);
});
test('drafts stay with their own conversation and restore across a page reload', async () => {
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
const app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A的未发送草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[1]);
assert.equal(app.element('input-text').value,'');
app.element('input-text').value='B的未发送草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[0]);
assert.equal(app.element('input-text').value,'A的未发送草稿');
await app.pageHide();
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
await flush();
assert.equal(restored.element('input-text').value,'A的未发送草稿');
assert.ok(restored.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
await restored.click(restored.element('historyWrap').children[1]);
assert.equal(restored.element('input-text').value,'B的未发送草稿');
});
test('a new conversation draft restores after refresh and moves to the created conversation on send', async () => {
const app=await customerPage({human:false});
app.element('input-text').value='尚未发送';app.element('input-text').listeners.input();await app.pageHide();
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage});
assert.equal(restored.element('input-text').value,'尚未发送');
await restored.context.window.fillInput('尚未发送');await restored.pageHide();
const store=restored.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>restored.context.localStorage,tabStorage:()=>restored.context.sessionStorage});
assert.equal(store.read(null),'');assert.equal(store.read(1),'');assert.equal(store.active(),'1');
});
test('draft recovery stays silent but quota failure remains visible until storage recovers', async () => {
const history=[{id:9,title:'A',ctime:'now'}],app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='已保存草稿';app.element('input-text').listeners.input();await app.pageHide();
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
assert.equal(restored.element('input-text').value,'已保存草稿');
assert.equal(restored.element('composer-status').textContent,'');
const setItem=restored.context.localStorage.setItem;
restored.context.localStorage.setItem=()=>{throw Error('quota');};
restored.element('input-text').value='新的未保存输入';restored.element('input-text').listeners.input();await restored.runTimers(250);
assert.match(restored.element('composer-status').textContent,/无法保存/);
assert.equal(restored.element('input-text').value,'新的未保存输入');
restored.context.localStorage.setItem=setItem;await restored.pageHide();
assert.equal(restored.element('composer-status').textContent,'');
});
test('restoring an old conversation cannot replace a newer user-selected draft', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();await app.pageHide();
let release;
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
await restored.click(restored.element('.new-chat-btn'));
restored.element('input-text').value='新话题草稿';restored.element('input-text').listeners.input();
release(response([{id:9,title:'A',ctime:'now'}]));await flush();
assert.equal(restored.element('input-text').value,'新话题草稿');
});
test('failed history loading cannot send a question under a hidden conversation context', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.context.window.fillInput('原会话');await flush();
app.hooks.messages=()=>response({ok:false},503);
await app.click(app.element('historyWrap').children[0]);
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
await app.context.window.fillInput('禁止发到未加载会话');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
assert.match(app.element('composer-status').textContent,/会话未加载成功/);
assert.doesNotMatch(app.element('chatDom').textContent,/原会话/);
});
test('startup removes a deleted active conversation draft without erasing a newer edit', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='旧会话草稿';app.element('input-text').listeners.input();await app.pageHide();
const noEdit=await customerPage({human:false,storage:{...app.storage},tabStorage:{...app.tabStorage}});
assert.equal(noEdit.element('input-text').value,'');
assert.ok(!noEdit.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
let release;
const edited=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
edited.element('input-text').value='加载期间的新输入';edited.element('input-text').listeners.input();
release(response([]));await flush();
assert.equal(edited.element('input-text').value,'加载期间的新输入');
await edited.context.window.fillInput('加载期间的新输入');
assert.ok(!edited.calls.some(call=>call.url==='/stream_chat'));
assert.match(edited.element('composer-status').textContent,/会话未加载成功/);
});
test('deleting a conversation removes only its saved draft', async () => {
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
const app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[1]);
app.element('input-text').value='B草稿';app.element('input-text').listeners.input();await app.pageHide();
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
const store=app.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>app.context.localStorage,tabStorage:()=>app.context.sessionStorage});
assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿');
});
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
const app=await customerPage({human:false});
let shown;
const image={tagName:'IMG',classList:{contains:name=>name==='message-image'},dataset:{},after:hint=>{shown=hint;}};
app.element('chatDom').listeners.error({target:image});
assert.equal(image.hidden,true);
assert.match(shown.textContent,/图片暂时无法访问/);
const first=shown;
app.element('chatDom').listeners.error({target:image});
assert.equal(shown,first);
});
+48
View File
@@ -0,0 +1,48 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const vm=require('node:vm');
const path=require('node:path');
const context=vm.createContext({});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context);
function storage(){
const data=new Map();
return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null,
getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)};
}
function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){
return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab});
}
test('conversation drafts and active tab restore independently of another visitor or tab',()=>{
const local=storage(),tab=storage(),a=drafts(undefined,local,tab);
a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9);
const restored=drafts(undefined,local,tab);
assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW');
assert.equal(restored.active(),'9');
const otherTab=drafts(undefined,local);otherTab.write(10,'B updated');
assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A');
otherTab.remove(10);assert.equal(restored.read(10),'');
assert.equal(drafts('2'.repeat(64),local,tab).read(9),'');
});
test('malformed records and invalid identifiers cannot become restored drafts',()=>{
const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9';
for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){
local.setItem(key,value);assert.equal(a.read(9),'');
}
assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),'');
});
test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{
const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local);
local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE');
a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B');
a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark');
});
test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{
const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
const a=drafts(undefined,broken,broken);
assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9');
const local=storage(),b=drafts(undefined,local);b.write(9,'old');
local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new');
const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only');
assert.equal(drafts(null,local).read(9),'');
});
+79
View File
@@ -0,0 +1,79 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const vm=require('node:vm');
const {webcrypto}=require('node:crypto');
const flush=()=>new Promise(setImmediate);
function sessionPage(options={}){
const storage=options.storage || new Map(),calls=[],timers=new Map();let timerId=0;
const response=(data,status=200)=>({ok:status>=200&&status<300,status,json:async()=>data});
const context=vm.createContext({console,AbortController,Uint8Array,
window:{crypto:webcrypto},
localStorage:{getItem:key=>{if(options.storageBlocked) throw Error();return storage.get(key);},
setItem:(key,value)=>{if(options.storageBlocked) throw Error();storage.set(key,value);}},
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},clearTimeout:id=>timers.delete(id),
fetch:async(url,init)=>{calls.push({url,init});
if(options.fetch) return options.fetch(url,init,response);
return url==='/api/visitor/session' ? response({ok:true,key:JSON.parse(init.body).key || 'f'.repeat(64)}) : response([]);
},
});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/visitor.js'),'utf8'),context);
return {fetch:context.VisitorSession.fetch,calls,storage,
expire:async()=>{for(const timer of [...timers.values()])timer.callback();await flush();}};
}
test('a local random key is restored across reloads and differs in another browser',async()=>{
const a=sessionPage();await a.fetch('/api/get_history');
const key=JSON.parse(a.calls[0].init.body).key;
assert.match(key,/^[0-9a-f]{64}$/);
const restored=sessionPage({storage:a.storage});await restored.fetch('/api/get_history');
assert.equal(JSON.parse(restored.calls[0].init.body).key,key);
const b=sessionPage();await b.fetch('/api/get_history');
assert.notEqual(JSON.parse(b.calls[0].init.body).key,key);
assert.equal(a.calls[1].init.credentials,'same-origin');
assert.equal(a.calls.some(call=>call.url.includes(key)),false);
});
test('concurrent requests wait for one bootstrap before sending customer data',async()=>{
let release;
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
? new Promise(resolve=>{release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));}) : response([])});
const a=app.fetch('/api/get_history'),b=app.fetch('/api/new_chat',{method:'POST'});
assert.equal(app.calls.length,1);
release();await Promise.all([a,b]);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,1);
assert.equal(app.calls.length,3);
});
test('blocked local storage uses a server cookie without falling back to global history',async()=>{
const app=sessionPage({storageBlocked:true});await app.fetch('/api/get_history');
assert.equal(JSON.parse(app.calls[0].init.body).key,null);
assert.equal(app.calls[1].init.credentials,'same-origin');
const failed=sessionPage({fetch:(url,init,response)=>response({ok:false},503)});
await assert.rejects(failed.fetch('/api/get_history'));
assert.equal(failed.calls.length,1);
});
test('expired sessions rebootstrap once and retry the original request once',async()=>{
let attempts=0;
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
? response({ok:true,key:JSON.parse(init.body).key}) : response([],++attempts===1 ? 401 : 200)});
const result=await app.fetch('/api/get_history');
assert.equal(result.status,200);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,2);
assert.equal(attempts,2);
});
test('a cancelled request waiting for bootstrap does not upload its body',async()=>{
let release;
const app=sessionPage({fetch:(url,init,response)=>new Promise(resolve=>{
release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));
})});
const controller=new AbortController();
const pending=app.fetch('/api/upload',{method:'POST',body:'private file',signal:controller.signal});
controller.abort();release();
await assert.rejects(pending,{name:'AbortError'});
assert.equal(app.calls.length,1);
});
+11 -2
View File
@@ -14,8 +14,8 @@ function voicePage(options={}){
setAttribute(name,value){this.attributes[name]=value;}
addEventListener(name,callback){this.listeners[name]=callback;}
setPointerCapture(){}
focus(){}
blur(){}
focus(options){this.focusOptions=options;}
blur(){this.blurred=true;}
}
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
@@ -70,6 +70,15 @@ test('hold and release transcribes audio and sends text once',async()=>{
assert.equal(app.messages.length,1);
});
test('switching between voice and keyboard avoids scrolling the page on focus',()=>{
const app=voicePage();
app.open();
assert.ok(app.elements.input.blurred);
app.open();
assert.equal(app.elements.input.focusOptions.preventScroll,true);
assert.equal(app.elements.toggle.attributes['aria-pressed'],'false');
});
test('sliding up cancels without uploading and supports another recording',async()=>{
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
assert.equal(app.messages.length,0);
+44 -1
View File
@@ -45,6 +45,8 @@ class ApiTests(unittest.TestCase):
self.root = Path(self.directory.name)
self.app = self.application(self.root)
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
self.app.extensions['rate_hits'].clear()
from logistics_chat.order_data import import_records
with self.app.app_context():
connection = db.connect()
@@ -55,9 +57,14 @@ class ApiTests(unittest.TestCase):
connection.close()
def application(self, root):
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
app = create_app({'TESTING': True, 'DATABASE': root/'history.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups',
'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''})
@app.before_request
def fixture_customer():
from flask import g
g.customer_tenant_id = 'fixture'
return app
def conversation(self):
return self.client.post('/api/new_chat').json['id']
@@ -371,6 +378,42 @@ class ApiTests(unittest.TestCase):
self.client.delete('/api/clear_all')
self.assertEqual(self.client.get('/api/get_history').json, [])
def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self):
for vote in ('up', 'down', 1, -1):
self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200)
with self.app.app_context():
connection=db.connect()
connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)])
connection.commit(); connection.close()
stats=self.client.get('/api/admin/stats').json
self.assertEqual((stats['good'],stats['bad']),(3,3))
def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self):
for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'},
{'vote':'up','answer':{}}, {'vote':'down','question':[]}):
with self.subTest(feedback=data):
response=self.client.post('/api/feedback',json=data)
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001},
{'content':'help','phone':[]}, {'content':'help','phone':'1'*33}):
with self.subTest(ticket=data):
response=self.client.post('/api/ticket',json=data)
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
for path in ('/api/feedback','/api/ticket'):
response=self.client.post(path,data='{bad',content_type='application/json')
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
with self.app.app_context():
connection=db.connect()
for table in ('feedback','tickets'):
self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0)
connection.close()
self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200)
ticket=self.client.get('/api/tickets').json[0]
self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123'))
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
with self.app.app_context():
connection = db.connect()
+1
View File
@@ -29,6 +29,7 @@ class DeploymentTests(unittest.TestCase):
self.addCleanup(close_handlers)
client = app.test_client()
client.post('/api/visitor/session', json={'key':'1'*64})
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
self.assertEqual(client.get(path).status_code, 200, path)
self.assertEqual(client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).status_code, 401)
+15 -1
View File
@@ -1,6 +1,6 @@
"""Remote dev behavior on the modular backend and the light workspace."""
from datetime import datetime
from datetime import datetime, timezone
import io
from pathlib import Path
import tempfile
@@ -21,6 +21,7 @@ class DevIntegrationTests(unittest.TestCase):
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key': '1' * 64})
def session(self):
cid = self.client.post('/api/new_chat').json['id']
@@ -49,6 +50,19 @@ class DevIntegrationTests(unittest.TestCase):
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
def test_service_hours_use_china_time_even_when_the_host_clock_is_utc(self):
cid = self.client.post('/api/new_chat').json['id']
for utc_hour, expected in ((0, True),(13, True),(14, False),(23, False)):
instant = datetime(2026,10,31,utc_hour,tzinfo=timezone.utc)
class HostUtcClock(datetime):
@classmethod
def now(cls, tz=None):
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
with self.subTest(utc_hour=utc_hour), patch('logistics_chat.human_service.datetime', HostUtcClock):
result = self.client.post('/api/visitor/human/request',json={'conv_id':cid}).json
self.assertEqual(result['ok'],expected)
if not expected: self.assertTrue(result['closed'])
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
sid = self.session()
self.client.post('/api/human/accept', json={'sid': sid})
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+21 -1
View File
@@ -41,7 +41,17 @@ elif name == 'curl' and '-w' in args:
if scenario == 'proxy-unsafe':
print('200', end='')
sys.exit(0)
print('401', end='')
path = '/' + args[-1].split('://', 1)[-1].partition('/')[2]
if '-c' in args:
pathlib.Path(args[args.index('-c') + 1]).write_text('fixture-visitor-cookie')
if '-b' in args:
assert pathlib.Path(args[args.index('-b') + 1]).read_text() == 'fixture-visitor-cookie'
status = '200' if path == '/' or path == '/api/visitor/session' or (path == '/api/get_history' and '-b' in args) else '401'
if scenario == 'visitor-bootstrap-fail' and path == '/api/visitor/session':
status = '503'
if scenario == 'visitor-history-fail' and path == '/api/get_history' and '-b' in args:
status = '503'
print(status, end='')
elif name == 'sleep':
pass
'''
@@ -97,6 +107,7 @@ class GrayActivationTests(unittest.TestCase):
env=environment, capture_output=True, text=True, timeout=30)
self.assertEqual((root / 'data/history.db').read_bytes(), b'preserve-existing-data')
self.assertEqual(unrelated.read_text(), '# unrelated site\n')
self.assertFalse(list(release.glob('.visitor-smoke.*')), 'Temporary visitor cookies must be removed on success and rollback')
calls = [json.loads(line) for line in log.read_text().splitlines()]
return result, root, proxy, previous, release, old_site, calls
@@ -119,6 +130,7 @@ class GrayActivationTests(unittest.TestCase):
self.assertEqual((proxy.parent / 'baiyue-logistics-chat-gray-proxy/access.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
for name, args in calls), result.stderr)
return result
def test_failed_application_restores_previous_release(self):
self.assert_rolled_back('app-fail')
@@ -132,6 +144,14 @@ class GrayActivationTests(unittest.TestCase):
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
self.assert_rolled_back('proxy-unsafe')
def test_failed_visitor_bootstrap_rolls_back_and_removes_cookie_file(self):
result = self.assert_rolled_back('visitor-bootstrap-fail')
self.assertIn('Guest session bootstrap verification failed.', result.stderr)
def test_failed_cookie_history_rolls_back_and_removes_cookie_file(self):
result = self.assert_rolled_back('visitor-history-fail')
self.assertIn('Guest history verification failed.', result.stderr)
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
self.assertNotEqual(result.returncode, 0)
+21
View File
@@ -4,6 +4,7 @@ from dataclasses import replace
from pathlib import Path
import tempfile
import unittest
import re
from dotenv import dotenv_values
@@ -57,6 +58,26 @@ class GrayConfigTests(unittest.TestCase):
self.assertNotIn(key, (path / name).read_text(encoding='utf-8'))
self.assertIn('auth_basic_user_file', (path / 'site.conf').read_text(encoding='utf-8'))
def test_public_allowlist_keeps_staff_search_exports_and_order_endpoints_protected(self):
config = GrayConfig.from_environment(self.settings()).nginx_config()
self.assertTrue(config.startswith('# Managed for baiyue-logistics-chat-gray;'))
match = re.search(r'location ~ (\S+) \{([^}]+)\}', config)
self.assertIsNotNone(match)
pattern, body = match.groups()
self.assertIn('auth_basic off;', body)
self.assertIn('auth_basic "Baiyue gray acceptance";', config)
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
'/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',
'/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'):
with self.subTest(public_path=path):self.assertRegex(path,pattern)
for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets',
'/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt',
'/api/visitor/human/accept','/api/get_history/unknown','/api/unknown'):
with self.subTest(staff_path=path):self.assertNotRegex(path,pattern)
example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8')
self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern)
self.assertIn('location = / {\n auth_basic off;',config)
def test_invalid_model_credential_does_not_create_a_release_directory(self):
config = GrayConfig.from_environment(self.settings())
with tempfile.TemporaryDirectory() as directory:
+5 -1
View File
@@ -19,8 +19,11 @@ class OrderDataTests(unittest.TestCase):
'UPLOAD_DIR':root/'uploads', 'BACKUP_DIR':root/'backups', 'LOG_DIR':root/'logs',
'DEEPSEEK_API_KEY':'', 'ORDER_DATA_TENANT_ID':'', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
self.context = self.app.app_context()
self.context.push()
from flask import g
g.customer_tenant_id = 'tenant-a'
self.addCleanup(self.context.pop)
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
@@ -88,7 +91,8 @@ class OrderDataTests(unittest.TestCase):
def test_server_tenant_scope_and_ambiguous_identifiers(self):
self.import_rows([{**self.record, 'tenant_id':'tenant-b', 'gross_weight_kg':'99'}])
self.assertIn('多个订单', self.events(self.record['order_no'])[0]['t'])
fields = self.events(self.record['order_no'])[0]['card']['fields']
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '30.000 kg')
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
rows = order_data.lookup_order(self.record['order_no'])
self.assertEqual(len(rows), 1)
+1
View File
@@ -37,6 +37,7 @@ class SpeechTests(unittest.TestCase):
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
+37
View File
@@ -6,6 +6,8 @@ from pathlib import Path
import tempfile
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from flask import Flask
from werkzeug.datastructures import FileStorage
@@ -29,6 +31,27 @@ class StorageTests(unittest.TestCase):
self.addCleanup(self.context.pop)
db.init_db()
def test_concurrent_startup_migrates_legacy_history_once_without_claiming_it(self):
connection = db.connect()
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
'(id INTEGER PRIMARY KEY, title TEXT NOT NULL, ctime TEXT NOT NULL);'
"INSERT INTO chat_history VALUES(1,'旧记录','now');")
connection.close()
gate = Barrier(4)
def initialize():
with self.app.app_context():
gate.wait(timeout=5)
db.init_db()
with ThreadPoolExecutor(max_workers=4) as workers:
for result in workers.map(lambda _: initialize(), range(4)):
self.assertIsNone(result)
connection = db.connect()
self.assertEqual(connection.execute('SELECT title, visitor_id FROM chat_history').fetchall(), [('旧记录', None)])
self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=1').fetchone()[0], 1)
connection.close()
def test_old_schema_is_migrated_and_legacy_title_backfilled(self):
connection = db.connect()
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
@@ -100,6 +123,20 @@ class StorageTests(unittest.TestCase):
self.assertNotIn('..', name)
self.assertFalse(result['is_image'])
def test_old_attachments_referenced_by_ai_or_human_history_survive_cleanup(self):
uploads=self.root/'uploads'; uploads.mkdir()
for name in ('ai.png','human.pdf','unused.txt'):
file=uploads/name;file.write_bytes(b'fixture')
os.utime(file,(time.time()-20*86400,time.time()-20*86400))
connection=db.connect()
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(1,'user','[图片]/uploads/ai.png','now')")
connection.execute("INSERT INTO human_messages(session_id,side,content,ctime) VALUES(1,'customer','[文件]/uploads/human.pdf','now')")
connection.commit();connection.close()
file_service.cleanup_uploads()
self.assertTrue((uploads/'ai.png').exists())
self.assertTrue((uploads/'human.pdf').exists())
self.assertFalse((uploads/'unused.txt').exists())
def test_invalid_and_oversized_uploads_are_rejected(self):
self.assertEqual(file_service.upload_file({})[1], 400)
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
+157
View File
@@ -0,0 +1,157 @@
"""Independent guests cannot read, mutate or claim each other's chat data."""
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from datetime import datetime
from logistics_chat import create_app, db
class VisitorTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.app = create_app({'TESTING':True, 'DATABASE':root/'history.db', 'UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
self.a, self.b = self.app.test_client(), self.app.test_client()
for client, key in ((self.a,'1'*64),(self.b,'2'*64)):
self.assertEqual(client.post('/api/visitor/session',json={'key':key}).status_code,200)
def conversation(self, client, text):
cid = client.post('/api/new_chat').json['id']
with self.app.app_context(): db.save_message(cid,'user',text)
return cid
def human(self, client, cid):
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026,10,30,12)
return client.post('/api/visitor/human/request',json={'conv_id':cid}).json['session_id']
def test_entry_is_public_but_customer_apis_need_a_guest_session_without_basic_login(self):
fresh = self.app.test_client()
self.assertEqual(fresh.get('/').status_code,200)
response = fresh.get('/api/get_history')
self.assertEqual(response.status_code,401)
self.assertNotIn('WWW-Authenticate',response.headers)
self.assertEqual(fresh.post('/api/new_chat').status_code,401)
self.assertEqual(self.a.get('/api/get_history').json,[])
def test_guest_key_is_restorable_but_only_its_hash_is_stored(self):
cid = self.conversation(self.a,'A_PRIVATE')
restored = self.app.test_client()
response = restored.post('/api/visitor/session',json={'key':'1'*64},base_url='https://localhost')
for flag in ('HttpOnly','Secure','SameSite=Lax'):
self.assertIn(flag,response.headers['Set-Cookie'])
self.assertEqual(restored.get('/api/get_history').json[0]['id'],cid)
with self.app.app_context():
connection=db.connect()
owner=connection.execute('SELECT visitor_id FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
connection.close()
self.assertEqual(len(owner),64)
self.assertNotEqual(owner,'1'*64)
def test_listing_loading_and_streaming_never_expose_another_guest(self):
a = self.conversation(self.a,'A_PRIVATE')
b = self.conversation(self.b,'B_PRIVATE')
self.assertEqual([row['id'] for row in self.a.get('/api/get_history').json],[a])
self.assertEqual([row['id'] for row in self.b.get('/api/get_history').json],[b])
self.assertEqual(self.a.get(f'/api/get_messages?conv_id={b}').json,[])
self.assertEqual(self.b.get(f'/api/get_messages?conv_id={a}').json,[])
with patch('logistics_chat.ai_client.post_completion') as model:
response=self.a.post('/stream_chat',json={'msg':'继续上一段对话','conv_id':b})
self.assertIn('新建对话',response.get_data(as_text=True))
self.assertNotIn('B_PRIVATE',response.get_data(as_text=True))
self.assertEqual(len(self.b.get(f'/api/get_messages?conv_id={b}').json),2)
model.assert_not_called()
def test_delete_and_clear_only_affect_the_current_guest_and_keep_unowned_legacy_data(self):
a = self.conversation(self.a,'A_PRIVATE')
b = self.conversation(self.b,'B_PRIVATE')
sid_a,sid_b=self.human(self.a,a),self.human(self.b,b)
self.a.post('/api/visitor/human/send',json={'sid':sid_a,'content':'A_SUPPORT'})
self.b.post('/api/visitor/human/send',json={'sid':sid_b,'content':'B_SUPPORT'})
with self.app.app_context():
connection=db.connect()
legacy=connection.execute("INSERT INTO chat_history(title,ctime) VALUES('LEGACY_PRIVATE','now')").lastrowid
connection.commit();connection.close()
self.assertEqual(self.a.delete(f'/api/del_history?id={b}').status_code,404)
self.assertTrue(self.a.delete('/api/clear_all').json['ok'])
self.assertEqual(self.a.get('/api/get_history').json,[])
self.assertEqual(self.b.get('/api/get_history').json[0]['id'],b)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid_b}').json['messages'][0]['content'],'B_SUPPORT')
with self.app.app_context():
connection=db.connect()
self.assertEqual(connection.execute('SELECT id FROM chat_history ORDER BY id').fetchall(),[(b,),(legacy,)])
connection.close()
def test_customer_human_routes_enforce_owner_and_role_and_hide_internal_notes(self):
a=self.conversation(self.a,'A_PRIVATE')
sid=self.human(self.a,a)
self.assertEqual(self.b.post('/api/visitor/human/request',json={'conv_id':a}).status_code,404)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
for action in ('send','close'):
self.assertEqual(self.b.post('/api/visitor/human/'+action,json={'sid':sid,'content':'attack'}).status_code,404)
self.assertEqual(self.a.post('/api/visitor/human/send',json={'sid':sid,'side':'agent','content':'fake agent'}).status_code,403)
self.a.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'})
response=self.a.get(f'/api/visitor/human/poll?sid={sid}')
self.assertNotIn('note',response.json)
self.assertNotIn('STAFF_PRIVATE',response.get_data(as_text=True))
self.assertEqual(self.b.post('/api/rate',json={'sid':sid,'stars':5}).status_code,404)
self.assertEqual(self.a.post('/api/rate',json=[1]).status_code,400)
self.assertEqual(self.a.post('/api/rate',json={'sid':sid,'stars':5}).status_code,200)
def test_uploads_are_private_and_legacy_files_are_not_assigned_to_new_visitors(self):
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'A_PRIVATE'),'note.txt')})
url=response.json['url']
with self.a.get(url) as downloaded:
self.assertEqual(downloaded.data,b'A_PRIVATE')
self.assertIn('no-store',downloaded.headers['Cache-Control'])
self.assertEqual(self.b.get(url).status_code,404)
self.assertEqual(self.app.test_client().get(url).status_code,404)
root=Path(self.app.config['UPLOAD_DIR'])
(root/'legacy.txt').write_text('LEGACY_PRIVATE')
self.assertEqual(self.a.get('/uploads/legacy.txt').status_code,404)
with self.a.get('/api/staff'+url) as staff:
self.assertEqual(staff.data,b'A_PRIVATE')
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001'):
with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model:
response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'})
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
lookup.assert_not_called();model.assert_not_called()
def test_missing_attachments_show_a_readable_private_page_without_disclosing_ownership(self):
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'fixture'),'missing.txt')})
url=response.json['url']
(Path(self.app.config['UPLOAD_DIR'])/url.rsplit('/',1)[1]).unlink()
own=self.a.get(url);other=self.b.get(url)
self.assertEqual(own.status_code,404)
self.assertIn('附件暂时无法访问',own.get_data(as_text=True))
self.assertIn('no-store',own.headers['Cache-Control'])
self.assertEqual(own.data,other.data)
self.assertNotIn(url,own.get_data(as_text=True))
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
cid=self.conversation(self.a,'A_PRIVATE')
for key in ('short','x'*64,'1'*65,[],{},123):
self.assertEqual(self.a.post('/api/visitor/session',json={'key':key}).status_code,400)
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'2'*64},headers={'Origin':'https://attacker.example'}).status_code,403)
self.assertEqual(self.a.post('/api/visitor/session',data={'key':'2'*64}).status_code,400)
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'1'*64,'padding':'x'*2000}).status_code,413)
self.assertEqual(self.a.get('/api/get_history').json[0]['id'],cid)
def test_owned_support_polling_is_not_counted_as_chat_generation_and_bounds_its_cursor(self):
cid=self.conversation(self.a,'A_PRIVATE')
sid=self.human(self.a,cid)
self.app.config['RATE_LIMIT_PER_MIN']=1
self.app.extensions['rate_hits'].clear()
self.assertEqual(self.a.get('/api/get_history').status_code,200)
self.assertEqual(self.a.get('/api/get_history').status_code,429)
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}').status_code,200)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}&after=9999999999999999999999999').status_code,400)