32 changed files with 2017 additions and 267 deletions
+2
View File
@@ -3,3 +3,5 @@
# The process environment takes precedence, including an explicitly empty value.
# Keep real credentials in .env; never put them in this example or source files.
DEEPSEEK_API_KEY=
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
ORDER_DATA_TENANT_ID=
+1
View File
@@ -10,6 +10,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| 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` |
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
+18 -4
View File
@@ -8,11 +8,12 @@
- **知识库优先**:优先使用本地知识库回答,减少模型编造
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
- **运单号查询**:发送 8~20 位运单号,返回轨迹卡片;先展示最近节点,可展开全部轨迹
- **订单查询**:按订单号、入仓码、Shipment ID 查询本地订单测试表;展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
- **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试
- **会话恢复**:连接中断保留已收到的正文;切换话题取消旧附件,等待建会话时保留新草稿;历史加载失败保留已有列表
- **深色模式**:右上角切换,偏好本地保存
- **响应式**:窄屏下侧栏自动变为抽屉
- **附件预览**:发送前查看文件名或缩略图,可移除待发送附件
@@ -23,6 +24,8 @@
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|---|---|---|
| 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 |
@@ -132,7 +135,8 @@ baiyue-logistics-chat/
│ ├── knowledge.py 知识与关键词检索
│ ├── pricing.py 运费解析与计算
│ ├── rates_extra.py 扩展报价唯一数据源
│ ├── tracking.py 模拟轨迹与卡片协议
│ ├── tracking.py 卡片协议与旧演示函数兼容
│ ├── order_data.py 本地订单测试表、精确查询与快照导入
│ ├── ai_client.py 模型 HTTP、压缩和追问
│ ├── chat_service.py 问答与 SSE 编排
│ ├── history_service.py 会话 CRUD
@@ -238,13 +242,23 @@ data: {"d": 1}
## 数据库与运行数据
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders` 和 `tickets` 九张表,并兼容已有数据,不预置账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
订单测试数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改。聊天每次重新查表;这份副本不会自动同步灰度业务库。没有新增管理页面和增删改接口。内部验收可查询已导入的测试样本;配置 `ORDER_DATA_TENANT_ID` 可以在服务器侧固定租户范围,不能将其视为客户登录授权。
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
```powershell
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
```
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。
首次启动会自动创建 `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。历史会话的自动清理仍已关闭。
## 当前限制
- 运单轨迹为模拟数据,尚未接入真实物流查询接口。
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史中的模拟轨迹保留演示标识。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。公网自用验收可按部署指南使用全站访问密码进入工作台。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
+23 -4
View File
@@ -12,7 +12,7 @@ release=${2:?}
# Parse a small allowlist of non-secret release fields; never source a credential file.
while IFS='=' read -r name value; do
case "$name" in
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY) ;;
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_AUTH_DIR) ;;
*) echo 'Unexpected release setting.' >&2; exit 2 ;;
esac
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
@@ -24,7 +24,9 @@ done < "$release/release.env"
[[ "$CHAT_IMAGE" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$HOST_PORT" =~ ^[0-9]+$ ]] && ((HOST_PORT >= 1024 && HOST_PORT <= 65535)) || exit 2
[[ -d "$CHAT_PROXY_DIR" && -r "$CHAT_TLS_CERT" && -r "$CHAT_TLS_KEY" ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$CHAT_IMAGE_ID" ]] || exit 2
loaded_id=$(awk -F= '$1 == "CHAT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded application image changed.' >&2; exit 2; }
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
@@ -41,7 +43,8 @@ if [[ -n "$(ss -H -ltn "sport = :$HOST_PORT")" && "$old_port" != "127.0.0.1:$HOS
fi
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
auth="$CHAT_PROXY_DIR/.baiyue-logistics-chat.htpasswd"
[[ "$CHAT_AUTH_DIR" == /*/baiyue-logistics-chat-gray-proxy && "$CHAT_AUTH_DIR" != *..* ]] || exit 2
auth="$CHAT_AUTH_DIR/access.htpasswd"
if [[ -f "$site" ]] && ! head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray'; then
echo 'The proxy file is not owned by this application.' >&2
exit 2
@@ -53,6 +56,13 @@ for candidate in "$CHAT_PROXY_DIR/"*.conf; do
exit 2
fi
done
# The Nginx config directory may be root-only (for example BaoTa). Keep this
# application's password hash in a separate worker-readable owned directory.
if [[ -e "$CHAT_AUTH_DIR" ]]; then
[[ -f "$CHAT_AUTH_DIR/.project" && "$(cat "$CHAT_AUTH_DIR/.project")" == baiyue-logistics-chat-gray ]] || exit 2
fi
install -d -m 755 "$CHAT_AUTH_DIR"
printf 'baiyue-logistics-chat-gray\n' > "$CHAT_AUTH_DIR/.project"
previous=''
if [[ -f "$root/current-release" ]]; then
previous=$(cat "$root/current-release")
@@ -68,6 +78,8 @@ proxy_changed=0
rollback() {
result=$?
trap - ERR
# ERR is inherited by command substitutions; only the parent may roll back.
if ((BASH_SUBSHELL)); then exit "$result"; fi
set +e
if ((proxy_changed)); then
if [[ -f "$release/previous-site.conf" ]]; then cp -p "$release/previous-site.conf" "$site"; else rm -f -- "$site"; fi
@@ -100,7 +112,14 @@ install -m 644 "$release/site.conf" "$site"
nginx -t
nginx -s reload
for path in / /api/export_chat /api/get_history; do
code=$(curl --silent --show-error --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path")
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
fi
sleep 1
done
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
done
printf '%s\n' "$release" > "$root/.current-release.new"
+3 -1
View File
@@ -29,6 +29,7 @@ class GrayConfig:
image: str
image_id: str
certbot_image_id: str
auth_dir: str = '/var/lib/baiyue-logistics-chat-gray-proxy'
@classmethod
def from_environment(cls, environment):
@@ -70,6 +71,7 @@ class GrayConfig:
'CHAT_RUNTIME_ENV': self.release + '/runtime.env',
'CHAT_DOMAIN': self.domain, 'CHAT_PROXY_DIR': self.proxy_dir,
'CHAT_TLS_CERT': self.certificate, 'CHAT_TLS_KEY': self.private_key,
'CHAT_AUTH_DIR': self.auth_dir,
}
return ''.join(f'{key}={value}\n' for key, value in fields.items())
@@ -91,7 +93,7 @@ server {{
ssl_certificate_key {self.private_key};
ssl_protocols TLSv1.2 TLSv1.3;
auth_basic "Baiyue gray acceptance";
auth_basic_user_file {self.proxy_dir}/.baiyue-logistics-chat.htpasswd;
auth_basic_user_file {self.auth_dir}/access.htpasswd;
client_max_body_size 12m;
location / {{
proxy_pass http://127.0.0.1:{self.port};
+3 -1
View File
@@ -18,7 +18,9 @@ done < "$release/release.env"
[[ "$CHAT_TLS_CERT" == "$root/tls/config/live/$CHAT_DOMAIN/fullchain.pem" ]] || exit 2
[[ "$CHAT_TLS_KEY" == "$root/tls/config/live/$CHAT_DOMAIN/privkey.pem" ]] || exit 2
[[ "$CHAT_CERTBOT_IMAGE" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$CHAT_CERTBOT_IMAGE_ID" ]] || exit 2
loaded_id=$(awk -F= '$1 == "CERTBOT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded certificate image changed.' >&2; exit 2; }
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_CERTBOT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
+33 -3
View File
@@ -40,7 +40,7 @@ release=$2
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* ]] || exit 2
[[ "$(id -u)" == 0 ]] || { echo 'The managed gray deployment account must have the required host permissions.' >&2; exit 2; }
for program in docker nginx openssl curl ss systemctl; do command -v "$program" >/dev/null; done
for program in docker nginx openssl curl ss systemctl sha256sum; do command -v "$program" >/dev/null; done
if [[ -e "$root" && ! -f "$root/.project" ]] && [[ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
echo 'Refusing to reuse an unowned deployment directory.' >&2
exit 2
@@ -55,8 +55,38 @@ chown 10001:10001 "$root/data" "$root/logs"
install -d -m 700 "$release"
PREPARE
# Transport only tested images built without runtime credentials.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip | run_ssh "bash -o pipefail -c 'gzip -dc | docker load'"
# Verify the exact exported archive before loading. Classic and containerd image
# stores can report different IDs for the same archive, so retain target IDs too.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip > "$temporary/images.tar.gz"
archive_sha=$(sha256sum "$temporary/images.tar.gz" | awk '{print $1}')
scp "${ssh_options[@]}" "$temporary/images.tar.gz" "$target:$release/images.tar.gz"
run_ssh bash -s -- "$root" "$release" "$archive_sha" "$CHAT_IMAGE" "$CERTBOT_IMAGE" "$SOURCE_COMMIT" <<'LOAD'
set -Eeuo pipefail
umask 077
root=$1
release=$2
checksum=$3
app_image=$4
certbot_image=$5
source_commit=$6
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
[[ "$checksum" =~ ^[a-f0-9]{64}$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
[[ "$app_image" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$certbot_image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
printf '%s %s\n' "$checksum" "$release/images.tar.gz" | sha256sum --check --status
gzip -dc "$release/images.tar.gz" | docker load
for image in "$app_image" "$certbot_image"; do
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" == "$source_commit" ]] || exit 2
done
app_id=$(docker image inspect --format '{{.Id}}' "$app_image")
certbot_id=$(docker image inspect --format '{{.Id}}' "$certbot_image")
[[ "$app_id" =~ ^sha256:[a-f0-9]{64}$ && "$certbot_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
printf 'ARCHIVE_SHA256=%s\nCHAT_IMAGE_ID=%s\nCERTBOT_IMAGE_ID=%s\n' "$checksum" "$app_id" "$certbot_id" > "$release/loaded-image-ids.env"
rm -f -- "$release/images.tar.gz"
echo 'Image archive checksum and source labels verified; target image IDs recorded.'
LOAD
scp "${ssh_options[@]}" "$temporary/release/"* "$target:$release/"
run_ssh bash "$release/prepare-tls.sh" "$root" "$release"
run_ssh bash "$release/activate.sh" "$root" "$release"
+12 -6
View File
@@ -26,7 +26,8 @@ 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`:`extract_tracking_no`、`build_track`、`CARD_PREFIX`、`card_to_text` | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;模拟节点时间取当前时刻,回归对照需固定时钟 |
| 卡片协议 | `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 |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
@@ -49,6 +50,7 @@ logistics_chat/
├── pricing.py # 运费规则、解析、换算、附加费说明
├── rates_extra.py # 扩展报价数据
├── tracking.py # 单号识别、模拟轨迹、卡片协议
├── order_data.py # 可编辑订单测试表、精确查询、快照导入与卡片事实
├── ai_client.py # 模型 HTTP、压缩与追问
├── chat_service.py # 问答编排与 SSE 帧
├── history_service.py # 历史会话 CRUD
@@ -86,6 +88,7 @@ tests/ # 按业务模块组织的回归
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
## 后续结构调整顺序
@@ -101,15 +104,18 @@ tests/ # 按业务模块组织的回归
## 必须保留的接口与数据约定
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。
- `chat_service.create_chat_stream` 在识别运单号时忽略 `[图片]/uploads/...` 和 `[文件]/uploads/...` 路径,避免附件文件名中的时间戳误触发查询;存储与请求仍保留原始附件标记和 URL。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。
- 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`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
- 后台鉴权和会话隔离仍不完整,轨迹为模拟数据。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
- 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
## 验证与维护
+4 -2
View File
@@ -37,11 +37,11 @@
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名、源提交和镜像 ID,生成私有配置并发送镜像及发布文件。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名和源提交,生成私有配置并发送镜像及发布文件。服务器验证镜像归档的 SHA-256 后加载,记录目标主机的镜像 ID。
5. 首次签发域名证书,等待应用健康,再安装本服务的 Nginx 配置。已有业务的容器和站点配置不参与本次发布。
6. Jenkins 使用验收账号验证公网 HTTPS `/healthz`;之后在浏览器验证 AI 流式回复、上传、转人工和工作台。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,接收主机再次核验镜像 ID,不依赖 `latest`。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,不依赖 `latest`。Docker 的 classic 与 containerd 镜像存储可能返回不同 ID;跨主机以归档校验和与源提交标签核验传输,激活前再与 `loaded-image-ids.env` 中的目标 ID 比较,拒绝加载后被替换的镜像。归档加载后删除,构建机 ID 保留在 `release.env` 供追溯。
## 数据与失败回退
@@ -58,6 +58,8 @@
模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。
密码哈希位于独立的 `/var/lib/baiyue-logistics-chat-gray-proxy/access.htpasswd`,目录 755、文件 644,供 Nginx 工作进程读取;不放在可能只有 root 可访问的宝塔站点配置目录,也不放明文密码。该目录有本服务所有权标记,不复用未知目录。
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
新环境自动创建八张业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
+8
View File
@@ -29,6 +29,7 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
"""回答超长时,让模型压缩到规定字数内;失败则原样返回"""
if len(text) <= limit:
return text
r = None
try:
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': (EN_COMPRESS_RULE if has_chinese(text) is False else '') + f'你是文字精简助手。把用户给的内容压缩到 {limit} 字以内(必须严格满足,超出即为不合格),必须保留所有数字、金额、时效和渠道名称,删除重复表述、客套话和次要说明,结构保持不变。'}, {'role': 'user', 'content': text}], 'stream': False, 'max_tokens': 800}
r = post_completion(body, timeout=(5, 30))
@@ -39,6 +40,9 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
print(f'[压缩失败] 状态码 {r.status_code}')
except Exception as e:
print(f'[压缩异常] {e}')
finally:
if r is not None:
r.close()
return text
@@ -46,6 +50,7 @@ def gen_followups(user_msg, answer_text):
"""根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)"""
if not get_setting('DEEPSEEK_API_KEY'):
return []
r = None
try:
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': '你是物流客服助手。根据用户问题和你的回答,生成3个客户最可能接着问的简短问题(每个不超过20字),只输出JSON字符串数组,不要任何解释。'}, {'role': 'user', 'content': f'用户问:{user_msg}\n你的回答:{answer_text[:500]}\n\n请生成3个追问:'}], 'stream': False, 'max_tokens': 200}
r = post_completion(body, timeout=(5, 15))
@@ -58,4 +63,7 @@ def gen_followups(user_msg, answer_text):
return [str(x).strip() for x in arr if str(x).strip()][:3]
except Exception:
pass
finally:
if r is not None:
r.close()
return []
+107 -27
View File
@@ -9,8 +9,10 @@ from .config import get_setting
from .db import connect, last_user_question, load_context, save_message
from .knowledge import search_knowledge
from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island)
from .tracking import CARD_PREFIX, extract_tracking_no, build_track
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island,
is_shipping_destination_reply)
from .tracking import CARD_PREFIX
from .order_data import answer_query, extract_reference
@@ -20,45 +22,108 @@ def sse(payload):
return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n'
QUOTE_FOLLOWUP_PREFIX = r'^(?:那(?:么)?|换成|改成|改为|如果|还是|再算|同样)'
def is_shipping_followup(message, intent=None):
"""Recognize bounded quote edits without carrying facts across new topics."""
if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外一?)(?:票|批)|新(?:的)?(?:货|订单)|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message):
return False
intent = intent if intent is not None else parse_shipping_intent(message)
text = message.strip().strip(',,。.!!??、:: ')
prefix = re.match(QUOTE_FOLLOWUP_PREFIX, text)
if prefix and any(intent.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
return True
if intent['region'] is not None and is_shipping_destination_reply(text):
return True
short = re.sub(QUOTE_FOLLOWUP_PREFIX, '', text, count=1).strip()
short = re.sub(r'^(?:请问|请|再问一下)', '', short).strip()
if re.fullmatch(r'(?:海运|海派|空运|空派|铁路|班列|卡航|卡车|sea(?:\s+freight)?|air(?:\s+freight)?|rail|truck)'
r'(?:报价|价格|费用|运费|时效)?(?:呢|怎么样|如何|多少钱|多久|几天)?', short, re.I):
return True
if re.fullmatch(r'(?:价格|费用|运费|时效)(?:呢|是多少|多少|怎么样|如何|多久|几天)?|多少钱', short):
return True
if intent['weight'] is not None and re.fullmatch(r'\d+(?:\.\d+)?\s*(?:kg|公斤|千克|吨|lbs?|tons?|kilograms?)(?:呢)?', short, re.I):
return True
return bool(intent['dims'] and re.fullmatch(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I))
def shipping_intent_with_context(message, conv_id):
intent = parse_shipping_intent(message)
intent['dim_unit'] = detect_dim_unit(message)
if conv_id and is_shipping_followup(message, intent):
connection = connect()
try:
recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 6", (conv_id,)).fetchall()
finally:
connection.close()
for (previous,) in recent:
if extract_reference(previous):
break
earlier = parse_shipping_intent(previous)
if not any(earlier.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
if is_shipping_followup(previous, earlier):
continue
break
for key in ('weight', 'channel', 'region'):
if intent.get(key) is None:
intent[key] = earlier.get(key)
if intent['dims'] is None and earlier['dims']:
intent['dims'] = earlier['dims']
intent['dim_unit'] = detect_dim_unit(previous)
if not is_shipping_followup(previous, earlier):
break
if intent['channel']:
base = intent['channel'].removesuffix('_uk')
intent['channel'] = base + '_uk' if intent['region'] == 'uk' else base
return intent
def create_chat_stream(msg, conv_id):
if not msg:
return (sse({'e': '问题不能为空'}), {})
if '[图片]' in msg and len(msg) < 300:
return (sse({'t': '收到您的图片,请描述一下您的问题。'}) + sse({'d': 1}), {})
track_no = extract_tracking_no(re.sub(r'\[(?:图片|文件)\]/uploads/\S+', '', msg))
if track_no:
card = build_track(track_no)
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:
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
order_answer = answer_query(msg, conv_id)
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
if ship and ship['region'] is None and (ship['weight'] is not None or ship['dims'] or
re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or
(ship['channel'] and (is_shipping_followup(msg, ship) or re.search(r'报价|价格|费用|运费|多少钱|\b(?:quote|cost|price)\b', msg, re.I)))):
order_answer = {'t':'请提供目的国家或地区、目的仓或邮编,以便匹配对应费率;当前无法确定报价,可联系人工客服确认。'}
if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or
is_shipping_destination_reply(msg) or re.search(r'报价|价格|运费|费用|多少钱', msg)):
order_answer = {'t':'当前报价表尚未包含该目的地的费率,请提供目的仓或邮编、货物品名、件数和尺寸,由人工客服确认报价。'}
if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']:
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
if order_answer is not None:
def track_stream():
save_message(conv_id, 'user', msg)
yield sse({'card': card})
save_message(conv_id, 'assistant', CARD_PREFIX + json.dumps(card, ensure_ascii=False))
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
if 'card' in order_answer else order_answer['t'])
save_message(conv_id, 'assistant', content)
yield sse(order_answer)
yield sse({'d': 1})
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
if not get_setting('DEEPSEEK_API_KEY'):
return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {})
know_text = search_knowledge(msg)
ship = parse_shipping_intent(msg)
ship['dim_unit'] = detect_dim_unit(msg)
freight_text = None
if not ship['weight'] and (not ship['dims']) and ship['channel']:
prev_q = last_user_question(conv_id)
if prev_q:
prev_ship = parse_shipping_intent(prev_q)
if prev_ship['weight'] or prev_ship['dims']:
ship['weight'] = prev_ship['weight']
ship['dims'] = prev_ship['dims']
ship['dim_unit'] = detect_dim_unit(prev_q)
if ship['weight'] or ship['dims']:
if re.search('英国|UK|伦敦|英格兰', msg, re.I):
uk_map = {'sea': 'sea_uk', 'rail': 'rail_uk', 'truck': 'truck_uk', 'air': 'air_uk'}
ship['channel'] = uk_map.get(ship['channel'])
freight_text = freight_reply(ship)
if not ship['channel']:
freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)'
extra_parts = []
cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I)
if cbm_m:
if cbm_m and ship['region'] != 'uk':
cbm = float(cbm_m.group(1))
ch = '海运'
if re.search('卡航|卡车', msg):
@@ -144,8 +209,11 @@ def create_chat_stream(msg, conv_id):
return
parts = []
interrupted = False
answer_saved = False
try:
got_text = False
completed = False
finish_reason = None
for raw in resp.iter_lines():
if not raw:
continue
@@ -154,10 +222,15 @@ def create_chat_stream(msg, conv_id):
continue
data = line[5:].strip()
if data == '[DONE]':
completed = True
break
try:
j = json.loads(data)
chunk = j['choices'][0]['delta'].get('content') or ''
choice = j['choices'][0]
chunk = choice.get('delta', {}).get('content') or ''
finish_reason = choice.get('finish_reason') or finish_reason
if finish_reason == 'stop':
completed = True
except Exception:
continue
if chunk:
@@ -167,6 +240,10 @@ def create_chat_stream(msg, conv_id):
if not got_text:
yield sse({'e': '模型没有返回内容,请重试'})
return
if not completed or finish_reason not in (None, 'stop'):
interrupted = True
yield sse({'e': '回答未生成完整,请点击重试'})
return
full_text = ''.join(parts)
limit = get_setting('MAX_ANSWER_CHARS') if has_chinese(full_text) else get_setting('MAX_ANSWER_CHARS_EN')
if len(full_text) > limit:
@@ -177,6 +254,7 @@ def create_chat_stream(msg, conv_id):
full_text = short
yield sse({'replace': short})
save_message(conv_id, 'assistant', full_text)
answer_saved = True
if is_first_turn:
try:
fu = gen_followups(msg, full_text)
@@ -193,7 +271,9 @@ def create_chat_stream(msg, conv_id):
yield sse({'e': '传输中断,请点击重试'})
finally:
text = ''.join(parts)
if interrupted and len(text) >= 20:
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
resp.close()
try:
if interrupted and text and not answer_saved:
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
finally:
resp.close()
return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
+1
View File
@@ -20,6 +20,7 @@ def default_config():
'BACKUP_DIR': ROOT_DIR / 'backups',
'LOG_DIR': ROOT_DIR / 'logs',
'DEEPSEEK_API_KEY': (api_key or '').strip(),
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(),
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
'MODEL_NAME': 'deepseek-chat',
'MAX_CONTEXT': 4,
+8 -1
View File
@@ -126,6 +126,8 @@ def init_db():
status TEXT
)
''')
from .order_data import init_order_table
init_order_table(conn)
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall()
for cid, title in old:
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
@@ -201,7 +203,12 @@ def save_message(conv_id, role, content):
if last and last[0] == 'user' and (last[1] == content):
conn.close()
return 'duplicate'
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (conv_id, role, content, now_str()))
inserted = cur.execute('INSERT INTO messages(conv_id, role, content, ctime) '
'SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM chat_history WHERE id=?)',
(conv_id, role, content, now_str(), conv_id)).rowcount
if not inserted:
conn.close()
return 'deleted'
if role == 'user':
row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone()
if row and (not row[0] or row[0] == '新对话'):
+5 -1
View File
@@ -3,7 +3,7 @@
from .db import connect, now_str
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。'
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。'
def new_chat():
@@ -37,6 +37,8 @@ def get_messages(conv_id):
def del_history(hid):
conn = connect()
cur = conn.cursor()
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions WHERE conv_id=?)', (hid,))
cur.execute('DELETE FROM human_sessions WHERE conv_id=?', (hid,))
cur.execute('DELETE FROM messages WHERE conv_id=?', (hid,))
cur.execute('DELETE FROM chat_history WHERE id=?', (hid,))
conn.commit()
@@ -47,6 +49,8 @@ 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')
conn.commit()
+18 -4
View File
@@ -14,6 +14,10 @@ def human_request(data):
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
now = datetime.now().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():
conn.close()
return ({'ok':False, 'error':'conversation_not_found'}, 404)
row = conn.execute("SELECT id FROM human_sessions WHERE conv_id=? AND status!='closed' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
if row:
sid = row[0]
@@ -42,11 +46,20 @@ def human_poll(sid, after, typing_map):
def human_send(data):
sid = data.get('sid')
side = data.get('side', 'customer')
content = (data.get('content') or '').strip()
if not sid or not content:
content = data.get('content') or ''
if not sid or not isinstance(content, str) or not content.strip() or side not in ('customer', 'agent'):
return ({'ok': False}, 400)
content = content.strip()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
row = conn.execute('SELECT conv_id, status FROM human_sessions WHERE id=?', (sid,)).fetchone()
if not row:
conn.close()
return ({'ok': False, 'error': 'session_not_found'}, 404)
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
conn.close()
return ({'ok': False, 'error': 'conversation_changed'}, 409)
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
conn.commit()
conn.close()
@@ -70,10 +83,11 @@ def human_active():
def human_accept(data):
sid = data.get('sid')
conn = connect()
conn.execute("UPDATE human_sessions SET status='active' WHERE id=?", (sid,))
changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') "
'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount
conn.commit()
conn.close()
return {'ok': True}
return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409)
def human_close(data):
+205
View File
@@ -0,0 +1,205 @@
"""Local, editable order test data. This repository can later be replaced by MCP."""
import json
import re
import sqlite3
from datetime import datetime
from .config import get_setting
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
'history_json', 'data_note', 'source_updated_at', 'snapshot_at')
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目的国',
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
'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'状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
class OrderDataUnavailable(Exception):
pass
def init_order_table(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS order_test_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_order_id TEXT, tenant_id TEXT NOT NULL DEFAULT 'test',
order_no TEXT NOT NULL COLLATE NOCASE,
inbound_code TEXT COLLATE NOCASE, shipment_id TEXT COLLATE NOCASE,
status TEXT NOT NULL, country_code TEXT, transport_mode TEXT, warehouse_code TEXT,
order_role TEXT, parent_order_no TEXT,
package_count INTEGER, gross_weight_kg TEXT, volume_cbm TEXT,
history_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
source_updated_at TEXT, snapshot_at TEXT,
UNIQUE(tenant_id, order_no)
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_inbound ON order_test_data(inbound_code)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_shipment ON order_test_data(shipment_id)')
def import_records(connection, records, replace=False):
"""Import an explicitly supplied snapshot; preserve manual edits unless requested."""
init_order_table(connection)
inserted = skipped = 0
for source in records:
if not isinstance(source, dict) or not str(source.get('order_no') or '').strip():
raise ValueError('Each record requires an order_no')
row = {key: source.get(key) for key in FIELDS}
row['tenant_id'] = str(row['tenant_id'] or 'test')
row['source_order_id'] = str(row['source_order_id']) if row['source_order_id'] is not None else None
row['order_no'] = str(row['order_no']).strip()
row['status'] = str(row['status'] or 'DRAFT')
history = row['history_json'] or []
if isinstance(history, str): history = json.loads(history)
if not isinstance(history, list): raise ValueError('history_json must contain a list')
row['history_json'] = json.dumps(history, ensure_ascii=False)
row['snapshot_at'] = row['snapshot_at'] or datetime.now().isoformat(timespec='seconds')
exists = connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?',
(row['tenant_id'], row['order_no'])).fetchone()
if exists and not replace:
skipped += 1
continue
columns = ','.join(FIELDS)
placeholders = ','.join('?' for _ in FIELDS)
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no'))
connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) '
f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS])
inserted += 1
return {'imported': inserted, 'preserved': skipped}
def lookup_order(reference):
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
from .db import connect
connection = connect()
connection.row_factory = sqlite3.Row
try:
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:
query += ' AND tenant_id=?'
values.append(str(tenant))
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
except sqlite3.Error:
raise OrderDataUnavailable from None
finally:
connection.close()
def extract_reference(message):
message = re.sub(r'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message)
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
r'(?![A-Za-z0-9_-])', ' ', message)
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
r'(?:\s*(?:cm|mm|m|厘米|毫米|米))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
r'(?:kg|lbs?|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
references = []
for token in IDENTIFIER.findall(message):
if sum(character.isdigit() for character in token) < 3:
continue
if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I):
continue
if token.casefold() not in [value.casefold() for value in references]:
references.append(token)
return references
def build_card(row):
fields = []
def field(key, value):
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
field('inbound', row['inbound_code'])
field('shipment', row['shipment_id'])
field('country', row['country_code'])
field('warehouse', row['warehouse_code'])
field('transport', {'SEA':'海运', 'AIR':'空运', 'RAIL':'铁路', 'TRUCK':'卡航'}.get(row['transport_mode'], row['transport_mode']))
field('packages', row['package_count'])
field('weight', str(row['gross_weight_kg'])+' kg' if row['gross_weight_kg'] is not None else None)
field('volume', str(row['volume_cbm'])+' m³' if row['volume_cbm'] is not None else None)
field('role', {'CHILD':'子订单', 'MASTER':'主订单'}.get(row['order_role']))
field('parent', row['parent_order_no'])
field('updated', row['source_updated_at'])
try:
history = json.loads(row['history_json'])
except (ValueError, TypeError):
history = []
nodes = [{'time': str(event.get('time') or ''),
'text': STATUS_LABELS.get(str(event.get('status')), str(event.get('status') or '状态更新')), 'place': ''}
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
nodes.reverse()
status = STATUS_LABELS.get(row['status'], row['status'])
summary = f"订单号 {row['order_no']},业务状态:{status}。"
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields)
return {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
'nodes':nodes, 'summary':summary}
def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question."""
if not re.search(ORDER_DETAIL, message):
return False
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
return True
question = re.sub(r'[\s,,。.!!??、::]', '', message)
return bool(re.fullmatch(
r'(?:那|那么|还有|请问|请|查一下|看一下)?(?:它的|订单的)?'
r'(?:(?:状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
r'(?:(?:是|有|为)?(?:多少|什么|怎么样|怎样|如何|哪个))?|(?:有)?(?:几件|几箱|多少公斤|多少千克|多少钱))'
r'(?:呢|吗|呀|啊)?', question))
def answer_query(message, conv_id):
references = extract_reference(message)
if len(references) > 1:
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
reference = references[0] if references else None
if not reference and conv_id and is_order_followup(message):
from .db import connect
connection = connect()
try:
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall()
finally:
connection.close()
for role, content in recent:
if role == 'user':
earlier_references = extract_reference(content)
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
break
except (ValueError, KeyError, TypeError):
pass
if not reference:
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
return {'t':'请提供订单号、入仓码或 Shipment ID。'}
return None
try:
matches = lookup_order(reference)
except OrderDataUnavailable:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
if not matches:
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
if len(matches) > 1:
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
return {'card': build_card(matches[0])}
+47 -25
View File
@@ -95,35 +95,50 @@ RATES = {
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
def parse_dims(t):
"""解析 长x宽x高,例如 120*100*100 / 100x80x60 / 120×100×100
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
for m in re.finditer(r"(\d+)[*xX×](\d+)[*xX×](\d+)", t):
# 逐段向后延长,保证拿到的是完整数字而不是半截
dims = []
ok = True
pos = m.start()
for g in (1, 2, 3):
end = m.end(g)
# 若后面还紧跟数字,说明这一组没取全,放弃这个候选
if end < len(t) and t[end].isdigit():
ok = False
break
# 若是第一组,前面不能紧挨数字
if g == 1 and m.start(1) > 0 and t[m.start(1) - 1].isdigit():
ok = False
break
dims.append(int(m.group(g)))
if ok:
return [float(x) for x in dims]
return None
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
return [float(value) for value in match.groups()] if match else None
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
('unsupported', r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|'
r'(?<![A-Za-z0-9_])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z0-9_])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])'),
('uk', r'英国|伦敦|英格兰|(?<![A-Za-z0-9_])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z0-9_])(?:United Kingdom|Britain)(?![A-Za-z])'),
('eu', r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
r'(?<![A-Za-z0-9_])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
r'(?<![A-Za-z0-9_])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))'),
('unsupported', r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b'),
))
def shipping_region(text):
return next((region for pattern, region in SHIPPING_REGION_PATTERNS if pattern.search(text)), None)
def is_shipping_destination_reply(text):
text = text.strip().strip(',,。.!!??、:: ')
for pattern, _ in SHIPPING_REGION_PATTERNS:
match = pattern.search(text)
if not match:
continue
prefix = re.fullmatch(r'\s*(?:(?:目的地|目的国家|国家)(?:是|为|:|:)?|(?:发|寄|运|送)(?:往|到)?|到|往|去|to)?\s*', text[:match.start()], re.I)
suffix = re.fullmatch(r'\s*(?:的)?\s*(?:FBA\s*)?(?:(?:海外)?仓(?:库)?|warehouse)?(?:呢)?\s*', text[match.end():], re.I)
if prefix and suffix:
return True
return False
def parse_shipping_intent(text):
"""从话里抠出:重量(kg)、渠道、长宽高(cm)"""
"""解析重量、渠道、尺寸和费率区域;尺寸单位由调用方识别。"""
t = text.replace(" ", "")
intent = {"weight": None, "channel": None, "dims": None}
intent = {"weight": None, "channel": None, "dims": None, "region": shipping_region(text)}
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
intent["dims"] = parse_dims(text)
@@ -192,17 +207,21 @@ def pick_rate(weight_kg, tiers):
def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
"""海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档
country 传入国家代码(DE/FR/PL/CZ/ES)时,优先选择包含该后缀的仓点组。
country 传入国家代码(DE/FR/PL/CZ/ES)时,仅选择包含该后缀的仓点组;无匹配不报价。
"""
if not RX or not RX.WAREHOUSE_CARD:
return None
keys = list(RX.WAREHOUSE_CARD)
key = warehouse_key
if key and key not in RX.WAREHOUSE_CARD:
return None
if not key and country:
for k in keys:
if ("-" + country) in k.upper():
key = k
break
if not key:
return None
if not key:
key = keys[0]
row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]]
@@ -213,7 +232,9 @@ def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
tier = "1CBM+" if cbm >= 1 else "0.5CBM+"
stores = [s for s in key.splitlines() if s.strip()]
if country:
stores = [s for s in stores if ("-" + country) in s.upper()] or stores
stores = [s for s in stores if ("-" + country) in s.upper()]
if not stores:
return None
else:
stores = stores[:1]
note = "报价表中的仓点分组:" + "、".join(stores)
@@ -315,7 +336,8 @@ def freight_reply(intent):
dims = intent["dims"]
unit = intent.get("dim_unit") or "cm"
lines = []
channels = [intent["channel"]] if intent["channel"] else ["sea", "air"]
default_channels = ['sea_uk', 'air_uk'] if intent.get('region') == 'uk' else ['sea', 'air']
channels = [intent["channel"]] if intent["channel"] else default_channels
for ch in channels:
c = calc_freight(w, ch, dims, unit)
parts = [f"实重 {w:g} kg" if w else "实重:未提供"]
+27 -8
View File
@@ -8,17 +8,36 @@ bp = Blueprint('chat', __name__)
@bp.route('/stream_chat', methods=['GET', 'POST'])
def stream_chat():
def error(message):
return Response(chat_service.sse({'e':message}) + chat_service.sse({'d':1}), mimetype='text/event-stream')
if request.method == 'POST':
data = request.get_json(silent=True) or {}
msg = (data.get('msg') or '').strip()
data = request.get_json(silent=True)
if not isinstance(data, dict):
return error('请求格式无效,请重新发送问题。')
msg = data.get('msg', '')
conv_id = data.get('conv_id')
try:
conv_id = int(conv_id) if conv_id else None
except (TypeError, ValueError):
conv_id = None
else:
msg = (request.args.get('msg') or '').strip()
conv_id = request.args.get('conv_id', type=int)
msg = request.args.get('msg', '')
conv_id = request.args.get('conv_id')
if not isinstance(msg, str):
return error('问题必须是文本,请重新输入。')
msg = msg.strip()
if isinstance(conv_id, (bool, float)):
return error('会话编号无效,请新建对话后重试。')
if conv_id not in (None, '', 0):
if not isinstance(conv_id, (str, int)):
return error('会话编号无效,请新建对话后重试。')
if isinstance(conv_id, str) and (len(conv_id) > 19 or not conv_id.isascii() or not conv_id.isdecimal()):
return error('会话编号无效,请新建对话后重试。')
try:
conv_id = int(conv_id)
except (ValueError, TypeError):
return error('会话编号无效,请新建对话后重试。')
if not 0 < conv_id <= 9223372036854775807:
return error('会话编号无效,请新建对话后重试。')
else:
conv_id = None
frames, headers = chat_service.create_chat_stream(msg, conv_id)
body = frames if isinstance(frames, str) else stream_with_context(frames)
return Response(body, mimetype='text/event-stream', headers=headers)
+4 -2
View File
@@ -13,7 +13,9 @@ def card_to_text(content):
return content
try:
card = json.loads(content[len(CARD_PREFIX):])
return f"(已为运单 {card.get('no', '')} 查询过轨迹,当前状态:{card.get('status', '')})"
if card.get('kind') == 'order':
return card.get('summary', '')
return f"(历史演示轨迹,非真实物流信息:单号 {card.get('no', '')},演示状态:{card.get('status', '')})"
except Exception:
return "(已为该运单查询过轨迹)"
@@ -57,7 +59,7 @@ def extract_tracking_no(text):
return best
def build_track(no):
"""生成固定的模拟轨迹:同一个单号每次查都一样"""
"""Legacy demo helper for old imports; chat queries use order_test_data."""
rnd = random.Random(no)
steps = [
("已揽收", "义乌仓"),
+32
View File
@@ -0,0 +1,32 @@
"""Import an explicit, private order snapshot into the chat SQLite database."""
import argparse
import json
from pathlib import Path
import sqlite3
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from logistics_chat.order_data import import_records
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--snapshot', type=Path, required=True)
parser.add_argument('--db', type=Path, required=True)
parser.add_argument('--replace', action='store_true', help='Replace existing rows, including manual edits')
args = parser.parse_args()
if not args.snapshot.is_file() or args.snapshot.stat().st_size > 10*1024*1024:
parser.error('Snapshot must be an existing JSON file smaller than 10 MB')
records = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
if isinstance(records, dict): records = records.get('orders')
if not isinstance(records, list) or len(records) > 3000:
parser.error('Snapshot must contain a list of at most 3000 orders')
if not args.db.parent.is_dir(): parser.error('Database parent directory must exist')
with sqlite3.connect(args.db) as connection:
result = import_records(connection, records, replace=args.replace)
print(json.dumps(result))
if __name__ == '__main__':
main()
+8
View File
@@ -87,6 +87,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.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); }
.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); }
@@ -149,6 +151,12 @@ 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; }
.order-history-empty { color: var(--muted); font-size: 11px; margin: 10px 0; }
.order-history-title { margin: 18px 0 10px; font-size: 13px; font-weight: 600; }
.to-bottom { display: none; position: absolute; right: 24px; bottom: calc(var(--composer-height, 150px) + 12px); z-index: 4; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--secondary); box-shadow: var(--shadow); }
.to-bottom.show { display: block; }
.to-bottom:hover { color: var(--brand-text); border-color: var(--brand); }
+436 -155
View File
@@ -7,9 +7,20 @@ const uploadInput = document.getElementById("upload-input");
const composerStatus = document.getElementById("composer-status");
let uploadPending = false;
let uploadVersion = 0;
let uploadController = null;
let historyRequestVersion = 0;
let followLatest = true;
let creatingConv = null;
let sendPreparing = false;
let conversationVersion = 0;
let activeQuickTopic = null;
let humanConvId = null;
let humanStatus = '';
let humanRequestToken = null;
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(); });
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
@@ -19,8 +30,31 @@ function setComposerStatus(message, isError){
composerStatus.classList.toggle("is-error", Boolean(isError));
}
async function requestJson(path, options){
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
try{
const response = await fetch(path, Object.assign({}, options, {signal:controller.signal}));
const data = await response.json();
if(!response.ok){
const error = new Error(data && (data.e || data.error) || t('networkFail'));
error.status = response.status;
throw error;
}
return data;
}finally{ clearTimeout(timer); }
}
function clearAttachment(){
uploadVersion += 1;
if(uploadController){
uploadController.abort();
uploadController = null;
uploadPending = false;
uploadBtn.disabled = false;
uploadBtn.removeAttribute('aria-busy');
uploadBtn.textContent = '+';
}
window.__pendingImg = "";
window.__pendingTag = "[图片]";
const preview = document.getElementById("imgPreview");
@@ -44,7 +78,10 @@ uploadInput.onchange = function(){
uploadBtn.setAttribute("aria-busy", "true");
uploadBtn.textContent = "…";
setComposerStatus(t("uploading"));
fetch("/api/upload", {method:"POST", body:fd})
const controller = new AbortController();
uploadController = controller;
const timer = setTimeout(function(){ controller.abort(); }, 15000);
fetch("/api/upload", {method:"POST", body:fd, signal:controller.signal})
.then(async function(response){
const data = await response.json();
if(!response.ok || !data.ok) throw new Error(data.error || t("uploadFailed"));
@@ -91,15 +128,18 @@ uploadInput.onchange = function(){
}
})
.catch(function(error){
const message = error instanceof SyntaxError || error instanceof TypeError ? t("uploadFailed") : (error.message || t("uploadFailed"));
const message = error.name === 'AbortError' || error instanceof SyntaxError || error instanceof TypeError ? t("uploadFailed") : (error.message || t("uploadFailed"));
if(version === uploadVersion) setComposerStatus(message, true);
})
.finally(function(){
uploadPending = false;
uploadBtn.disabled = false;
uploadBtn.removeAttribute("aria-busy");
uploadBtn.textContent = "+";
if(version !== uploadVersion) setComposerStatus("");
clearTimeout(timer);
if(uploadController === controller){
uploadController = null;
uploadPending = false;
uploadBtn.disabled = false;
uploadBtn.removeAttribute("aria-busy");
uploadBtn.textContent = "+";
}
});
uploadInput.value = "";
};
@@ -169,12 +209,23 @@ const I18N = {
thinking: "正在思考中...",
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。",
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
offlinePrompt: "请输入您的留言:",
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
humanReply: "客服回复",
humanConnecting: "正在为您连接人工客服…",
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
humanActive: "人工客服接待中,消息将发送给人工客服。",
returnAI: "返回 AI 问答",
humanSent: "已发送给人工客服",
humanSending: "正在发送给人工客服…",
humanSendFailed: "消息未发送成功,内容已保留,请重试。",
humanConnectFailed: "人工客服连接失败,请稍后重试。",
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
humanReturnFailed: "暂时无法结束人工会话,请重试。",
historyLoading: "正在加载这段会话,请稍候…",
recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!",
@@ -182,7 +233,7 @@ const I18N = {
networkFail: "网络连接中断,请检查网络后重试",
stopped: "已停止生成",
stoppedMark: "(已停止生成)",
inputPh: "请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)",
inputPh: "请输入问题、订单号或入仓码…(Enter 发送,Shift+Enter 换行)",
send: "发送",
stop: "停止",
copy: "复制",
@@ -190,18 +241,18 @@ const I18N = {
retry: "重答",
clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答",
welcomeSub: "查询物流轨迹、了解专线报价,或联系人工客服。",
welcomeSub: "查询订单状态、了解专线报价,或联系人工客服。",
welcomeEyebrow: "中欧 · 英国物流服务",
serviceScope: "中欧与英国专线咨询",
serviceTag: "物流咨询服务",
quickLabel: "快捷咨询",
footerTrack: "查运单",
footerTrack: "查订单",
footerQuote: "专线报价",
footerException: "异常处理",
footerHuman: "联系人工",
disclaimer: "报价与时效仅供参考,请以客服最终确认为准",
keyboardHint: "Enter 发送 · Shift + Enter 换行",
inputShort: "输入问题或运单号…",
inputShort: "输入问题、订单号或入仓码…",
uploading: "附件正在上传,请稍候…",
uploadFailed: "附件上传失败,请重新选择文件",
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
@@ -211,9 +262,17 @@ const I18N = {
assistant: "柏悦物流助手",
humanAgent: "人工客服",
trackingNo: "运单号",
orderNo: "订单号",
orderHistory: "订单业务状态历史",
noOrderHistory: "未提供订单状态历史",
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: ["输入运单号,查看运输进度", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
quickSubs: ["输入订单号、入仓码或 Shipment ID", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
hot: "热点问题",
recent: "最近对话记录",
@@ -223,7 +282,7 @@ const I18N = {
langTip: "中英文切换(Ctrl+L)",
toBottom: "回到底部",
quick: [
"帮我查询物流轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
"查询订单状态", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
],
hotList: [
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
@@ -242,12 +301,23 @@ 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. How can we help? Enter a tracking number or choose a quick option below.",
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.",
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.",
humanReply: "Support reply",
humanConnecting: "Connecting you to our support team…",
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…",
recalled: "This message was recalled",
ratingQuestion: "How was your support experience?",
ratingThanks: "Thank you for your feedback!",
@@ -268,7 +338,7 @@ const I18N = {
serviceScope: "Europe & UK freight support",
serviceTag: "Logistics support",
quickLabel: "Quick help",
footerTrack: "Tracking",
footerTrack: "Order lookup",
footerQuote: "Get a quote",
footerException: "Parcel issue",
footerHuman: "Human support",
@@ -284,6 +354,14 @@ const I18N = {
assistant: "Baiyue Assistant",
humanAgent: "Support agent",
trackingNo: "Tracking number",
orderNo: "Order number",
orderHistory: "Order workflow history",
noOrderHistory: "No order status history provided",
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"],
@@ -339,6 +417,7 @@ function applyLanguage(lang){
if(tb) tb.title = dict.toBottom;
const tg = document.querySelector(".toggle-divider");
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
updateHumanMode();
// 欢迎页(若当前显示的是欢迎页则重绘)
if(chatDom.querySelector(".ai-welcome-card")){
@@ -494,13 +573,25 @@ function welcomeHTML(){
// 清空全部历史
if(clearAllBtn){
clearAllBtn.onclick = function(){
if(openingConv === 'clearing') return;
if(!confirm(t("confirmClear"))) return;
if(cancelActive){ cancelActive(); cancelActive = null; }
fetch("/api/clear_all", {method:"DELETE"})
.then(()=>{
currentConvId = null;
const version = resetConversation(null);
openingConv = 'clearing';
chatDom.innerHTML = welcomeHTML();
setComposerStatus(t('historyLoading'));
requestJson("/api/clear_all", {method:"DELETE"})
.then(data=>{
if(!data.ok) throw new Error('Clear failed');
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus('');
chatDom.innerHTML = welcomeHTML();
removeCachedHistory();
loadHistory();
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
}
@@ -508,13 +599,12 @@ clearAllBtn.onclick = function(){
// 新建对话:只清界面,等用户开口时再建会话
if(newChatBtn){
newChatBtn.onclick = function(){
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
if(openingConv === 'clearing') return;
resetConversation(null);
inputDom.value = "";
autoGrow();
clearAttachment();
setComposerStatus("");
currentConvId = null;
chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = 0;
followLatest = true;
@@ -523,19 +613,27 @@ newChatBtn.onclick = function(){
}
function loadHistory(){
fetch("/api/get_history")
.then(res=>res.json())
const version = ++historyRequestVersion;
return requestJson("/api/get_history")
.then(data=>{
if(version !== historyRequestVersion || !Array.isArray(data)) return;
historyList = data;
renderSideHistory();
renderRecentChat();
})
}).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ });
}
function removeCachedHistory(id){
historyRequestVersion += 1;
historyList = id === undefined ? [] : historyList.filter(item => item.id !== id);
renderSideHistory();
renderRecentChat();
}
function renderSideHistory(){
leftHistoryWrap.innerHTML = "";
const kw = (document.getElementById("history-search")?.value || "").trim().toLowerCase();
const pinned = JSON.parse(localStorage.getItem("pinned_conv") || "[]");
const pinned = pinnedConversations();
let list = kw
? historyList.filter(item => (item.title || "").toLowerCase().indexOf(kw) >= 0)
: historyList.slice();
@@ -584,10 +682,10 @@ function renderSideHistory(){
pinBtn.style.marginRight = "4px";
pinBtn.onclick = (e)=>{
e.stopPropagation();
let p = JSON.parse(localStorage.getItem("pinned_conv") || "[]");
let p = pinnedConversations();
if(p.includes(item.id)){ p = p.filter(x=>x!==item.id); }
else { p.push(item.id); }
localStorage.setItem("pinned_conv", JSON.stringify(p));
try{ localStorage.setItem("pinned_conv", JSON.stringify(p)); }catch(error){}
renderSideHistory();
};
const delBtn = document.createElement("button");
@@ -597,14 +695,19 @@ function renderSideHistory(){
delBtn.onclick = (e)=>{
e.stopPropagation();
if(confirm(t("confirmDel"))){
fetch(`/api/del_history?id=${item.id}`, {method:"DELETE"})
.then(()=>{
const version = conversationVersion;
return requestJson(`/api/del_history?id=${item.id}`, {method:"DELETE"})
.then(data=>{
if(!data.ok) throw new Error('Delete failed');
if(item.id === currentConvId){
currentConvId = null;
resetConversation(null);
chatDom.innerHTML = welcomeHTML();
}
loadHistory();
})
removeCachedHistory(item.id);
return loadHistory();
}).catch(function(){
if(version === conversationVersion) setComposerStatus(t('networkFail'), true);
});
}
}
div.appendChild(textWrap);
@@ -615,6 +718,13 @@ function renderSideHistory(){
})
}
function pinnedConversations(){
try{
const stored = JSON.parse(localStorage.getItem('pinned_conv') || '[]');
return Array.isArray(stored) ? stored.filter(Number.isInteger) : [];
}catch(error){ return []; }
}
function renderRecentChat(){
const wrap = document.getElementById("recentChatWrap");
if(!wrap) return; // 切过对话后旧节点会失效,每次重新取
@@ -647,33 +757,57 @@ function renderRecentChat(){
function ensureConv(){
if(currentConvId) return Promise.resolve(currentConvId);
if(creatingConv) return creatingConv;
creatingConv = fetch("/api/new_chat", {method:"POST"})
.then(async function(res){
const data = await res.json();
if(!res.ok || !data.id) throw new Error('Session failed');
const version = conversationVersion;
const pending = requestJson("/api/new_chat", {method:"POST"})
.then(function(data){
if(!data || !data.id) throw new Error('Session failed');
return data;
})
.then(data=>{
if(version !== conversationVersion){
const error = new Error('Conversation changed');
error.name = 'AbortError';
throw error;
}
currentConvId = data.id;
if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId;
}).finally(function(){ creatingConv = null; });
return creatingConv;
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
creatingConv = pending;
return pending;
}
function resetConversation(id){
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
conversationVersion += 1;
clearAttachment();
stopHumanMode();
currentConvId = id;
openingConv = null;
creatingConv = null;
sendPreparing = false;
activeQuickTopic = null;
setBusy(false);
setComposerStatus('');
return conversationVersion;
}
// 打开历史会话:还原整段对话,而不是把标题重发一遍
let openingConv = null;
function openConversation(id){
if(openingConv === 'clearing') return;
if(openingConv === id) return; // 防连点
const version = resetConversation(id);
openingConv = id;
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
currentConvId = id;
setComposerStatus(t('historyLoading'));
renderSideHistory();
fetch("/api/get_messages?conv_id=" + id)
.then(res=>res.json())
requestJson("/api/get_messages?conv_id=" + id)
.then(msgs=>{
if(version !== conversationVersion) return;
if(!Array.isArray(msgs)) throw new Error('History failed');
openingConv = null;
setComposerStatus('');
chatDom.innerHTML = "";
if(!msgs.length){
chatDom.innerHTML = welcomeHTML();
@@ -682,18 +816,23 @@ function openConversation(id){
msgs.forEach(m=>{
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
try{
addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)));
const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime);
attachActions(bubble, m.content, '', false);
}catch(e){
addMsg(m.content, false);
addMsg(m.content, false, undefined, m.ctime);
}
}else{
const b = addMsg(m.content, m.role === "user");
const b = addMsg(m.content, m.role === "user", undefined, m.ctime);
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
}
});
// 等渲染完再滚到底部
requestAnimationFrame(function(){ scrollChatToEnd(true); });
})["catch"](function(){ openingConv = null; });
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
const CARD_PREFIX = "__CARD__";
@@ -701,11 +840,13 @@ const CARD_PREFIX = "__CARD__";
// 把轨迹数据画成时间轴
function fillTrackCard(el, card){
el.innerHTML = "";
const isOrder = card.kind === 'order';
const head = document.createElement("div");
head.className = "track-head";
const no = document.createElement("div");
no.className = "track-no";
no.innerHTML = '<span data-i18n="trackingNo">' + t("trackingNo") + "</span>" + escHtml(card.no);
const numberLabel = isOrder ? 'orderNo' : 'trackingNo';
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(card.no);
const st = document.createElement("div");
st.className = "track-status";
st.textContent = card.status;
@@ -713,6 +854,40 @@ 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';
(card.fields || []).forEach(function(field){
const label = document.createElement('dt');
label.dataset.i18n = 'order_'+field.key;
label.textContent = t('order_'+field.key) || field.key;
const value = document.createElement('dd');
value.textContent = field.value;
fields.append(label, value);
});
el.appendChild(fields);
const title = document.createElement('p');
title.className = 'order-history-title';
title.dataset.i18n = 'orderHistory';
title.textContent = t('orderHistory');
el.appendChild(title);
if(!(card.nodes || []).length){
const empty = document.createElement('p');
empty.className = 'order-history-empty';
empty.dataset.i18n = 'noOrderHistory';
empty.textContent = t('noOrderHistory');
el.appendChild(empty);
}
}
const timeline = document.createElement("div");
timeline.className = "track-timeline";
el.appendChild(timeline);
@@ -749,23 +924,34 @@ function fillTrackCard(el, card){
}
}
function createMessageRow(isUser){
function createMessageRow(isUser, authorRole){
const row = document.createElement("div");
row.className = "msg-row " + (isUser ? "user-line" : "robot-line");
const author = document.createElement("div");
author.className = "message-author";
const role = isUser ? "you" : (window.humanSid ? "humanAgent" : "assistant");
const role = isUser ? "you" : (authorRole || "assistant");
author.innerHTML = (isUser ? "" : '<span class="author-mark">' + welcomeIcon("box") + '</span>') + '<span data-i18n="' + role + '">' + t(role) + '</span>';
row.appendChild(author);
return row;
}
function addTrackCard(card){
function appendMessageTime(row, timeStr){
const ts = document.createElement('div');
ts.className = 'msg-time';
const stored = typeof timeStr === 'string' && timeStr.match(/(?:^|[T\s])(\d{2}:\d{2})/);
const now = new Date();
ts.textContent = stored ? stored[1] : String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');
if(timeStr) ts.title = timeStr;
row.appendChild(ts);
}
function addTrackCard(card, timeStr){
const row = createMessageRow(false);
const bubble = document.createElement("div");
bubble.className = "robot-message track-card";
fillTrackCard(bubble, card);
row.appendChild(bubble);
appendMessageTime(row, timeStr);
chatDom.appendChild(row);
scrollChatToEnd();
return bubble;
@@ -775,19 +961,21 @@ loadHistory();
window.fillInput = function(words){
inputDom.value = words;
doSend();
return doSend();
}
// 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史
window.askNewTopic = function(words){
if(cancelActive){ cancelActive(); cancelActive = null; }
currentConvId = null; // 下次发送时自动新建会话
if(openingConv === 'clearing') return;
if(activeQuickTopic === words && (sendPreparing || window.__busy)) return;
resetConversation(null);
activeQuickTopic = words;
// 新话题先重置界面,发送第一条消息时再收起欢迎内容。
if(!chatDom.querySelector('.ai-welcome-card')){
chatDom.innerHTML = welcomeHTML();
}
inputDom.value = words;
doSend();
return doSend();
}
window.sendOrder = function(){
@@ -816,64 +1004,151 @@ function myOrders(){
.catch(function(){ addMsg("查询失败,请重试", false); });
}
function updateHumanMode(){
const banner = document.getElementById('human-session');
const label = document.getElementById('human-session-label');
const button = document.getElementById('return-ai');
if(banner) banner.hidden = !window.humanSid && !humanRequestToken;
const text = t(humanRequestToken ? 'humanConnecting' : humanStatus === 'active' ? 'humanActive' : 'humanWaiting');
if(label && label.textContent !== text) label.textContent = text;
if(button){
button.textContent = t('returnAI');
button.disabled = Boolean(humanRequestToken || humanClosing || humanSendingToken);
}
sendBtn.disabled = Boolean(humanRequestToken || humanClosing || humanSendingToken);
}
function stopHumanMode(){
if(humanPollTimer !== null) clearInterval(humanPollTimer);
humanPollTimer = null;
humanConvId = null;
humanStatus = '';
humanRequestToken = null;
humanSendingToken = null;
humanClosing = false;
window.humanSid = 0;
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
updateHumanMode();
}
async function humanRequest(path, data){
return requestJson(path, {method:data ? 'POST' : 'GET',
headers:data ? {'Content-Type':'application/json'} : {},
body:data ? JSON.stringify(data) : undefined});
}
window.showHuman = async function(){
if(window.humanSid){
addMsg("您已在人工客服会话中,请直接提问。", false);
return;
if(openingConv === 'clearing') 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; }
setBusy(false);
updateHumanMode();
try{
const cid = await ensureConv();
if(humanRequestToken !== token || token.version !== conversationVersion) return;
const data = await humanRequest('/api/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');
window.humanSid = data.session_id;
humanConvId = cid;
humanStatus = 'waiting';
addMsg(t('humanWaiting'), false);
startHumanPoll();
}catch(error){
if(humanRequestToken === token && token.version === conversationVersion) setComposerStatus(t('humanConnectFailed'), true);
}finally{
if(humanRequestToken === token){ humanRequestToken = null; updateHumanMode(); }
}
};
async function returnToAI(){
if(!window.humanSid || humanClosing || humanSendingToken) return;
const sid = window.humanSid, version = conversationVersion;
humanClosing = true;
updateHumanMode();
try{
const result = await humanRequest('/api/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();
}catch(error){
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
}finally{
if(version === conversationVersion){ humanClosing = false; updateHumanMode(); }
}
}
const returnAIButton = document.getElementById('return-ai');
if(returnAIButton) returnAIButton.onclick = returnToAI;
async function sendHumanMessage(content){
if(humanSendingToken || humanClosing) return;
const sid = window.humanSid, cid = currentConvId, version = conversationVersion;
const draft = inputDom.value, attachment = window.__pendingImg;
const token = {};
humanSendingToken = token;
updateHumanMode();
setComposerStatus(t('humanSending'));
try{
const result = await humanRequest('/api/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();
}catch(error){
if(version !== conversationVersion || humanSendingToken !== token) return;
if(error.status === 404 || error.status === 409) stopHumanMode();
setComposerStatus(t('humanSendFailed'), true);
}finally{
if(humanSendingToken === token){ humanSendingToken = null; updateHumanMode(); }
}
var now = new Date();
var h = now.getHours();
var work = h >= 8 && h < 22;
if(!work){
leaveOfflineMessage();
return;
}
addMsg("正在为您转接人工客服...", false);
if(!currentConvId){
try{ await ensureConv(); }catch(e){ setComposerStatus(t('sessionFail'), true); return; }
}
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId||0})})
.then(function(r){return r.json()})
.then(function(d){
if(d.ok){
window.humanSid = d.session_id;
addMsg("您已加入人工排队,请稍候...", false);
startHumanPoll();
}else if(d.closed){
leaveOfflineMessage();
}else{
addMsg("转接失败,请稍后再试。", false);
}
})
.catch(function(){
addMsg("网络错误,请稍后再试。", false);
});
}
window.humanSid = 0;
async function leaveOfflineMessage(){
const version = conversationVersion;
addMsg(t('humanHours'), false);
const message = window.prompt(t('offlinePrompt'));
if(!message) return;
try{
const response = await fetch('/api/ticket', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({content:message, phone:''})});
const data = await response.json();
if(!response.ok || !data.ok) throw new Error('Ticket failed');
addMsg(t('offlineSaved'), false);
}catch(error){ setComposerStatus(t('networkFail'), true); }
const data = await humanRequest('/api/ticket', {content:message, phone:''});
if(!data.ok) throw new Error('Ticket failed');
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
}catch(error){
if(version !== conversationVersion) return;
if(!inputDom.value) inputDom.value = message;
setComposerStatus(t('networkFail'), true);
}
}
function startHumanPoll(){
let last=0;
let polling=false;
const sid=window.humanSid;
const cid=humanConvId, version=conversationVersion;
let hadPollError=false;
if(humanPollTimer !== null) clearInterval(humanPollTimer);
const pollTimer=setInterval(()=>{
if(window.humanSid !== sid){ clearInterval(pollTimer); return; }
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid){ clearInterval(pollTimer); return; }
if(polling) return;
polling=true;
fetch("/api/human/poll?sid="+sid+"&after="+last)
.then(r=>r.json())
humanRequest("/api/human/poll?sid="+sid+"&after="+last)
.then(d=>{
if(window.humanSid !== sid) return;
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid) return;
if(hadPollError){ setComposerStatus(''); hadPollError=false; }
humanStatus=d.status;
updateHumanMode();
d.messages.forEach(m=>{
if(m.id <= last) return;
last = m.id;
@@ -888,7 +1163,7 @@ function startHumanPoll(){
system.textContent = m.content;
chatDom.appendChild(system);
}else if(m.side === 'agent'){
const bubble = addMsg(m.content, false);
const bubble = addMsg(m.content, false, 'humanAgent');
bubble.closest('.msg-row').setAttribute('data-human-message-id', String(m.id));
SupportUI.notify(t('humanReply'), m.content.substring(0, 50));
}
@@ -896,12 +1171,9 @@ function startHumanPoll(){
if(typing) typing.remove();
});
if(d.status==="closed"){
clearInterval(pollTimer);
window.humanSid=0;
window.__busy=false;
if(typeof setBusy==="function") setBusy(false);
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
if(humanClosing) return;
stopHumanMode();
setBusy(false);
addMsg(t('humanClosed'), false);
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed')});
scrollChatToEnd();
@@ -920,13 +1192,18 @@ function startHumanPoll(){
oldT.remove();
}
})
.catch(()=>{}).finally(function(){ polling=false; });
.catch(function(){
if(version !== conversationVersion || window.humanSid !== sid) return;
hadPollError=true;
setComposerStatus(t('humanPollFailed'), true);
}).finally(function(){ polling=false; });
}, 1000);
humanPollTimer = pollTimer;
}
function addMsg(content, isUser){
function addMsg(content, isUser, authorRole, timeStr){
if(chatDom.querySelector('.ai-welcome-card')) chatDom.innerHTML = "";
const rowDiv = createMessageRow(isUser);
const rowDiv = createMessageRow(isUser, authorRole);
let robotMsg;
// 检测图片附件
let imgUrl = null;
@@ -980,11 +1257,7 @@ function addMsg(content, isUser){
rowDiv.appendChild(robotMsg);
}
// 时间戳
const ts = document.createElement("div");
ts.className = "msg-time";
const d = new Date();
ts.textContent = String(d.getHours()).padStart(2,'0') + ":" + String(d.getMinutes()).padStart(2,'0');
rowDiv.appendChild(ts);
appendMessageTime(rowDiv, timeStr);
chatDom.appendChild(rowDiv);
scrollChatToEnd(Boolean(isUser));
if(!isUser){
@@ -997,7 +1270,7 @@ function addMsg(content, isUser){
function renderError(bubble, text, retry){
bubble.classList.remove("typing");
bubble.classList.add("error-bubble");
bubble.innerHTML = "";
if(!bubble.dataset.raw) bubble.innerHTML = "";
const line = document.createElement("div");
line.className = "err-line";
line.textContent = "⚠️ " + (/API Key|DEEPSEEK/i.test(text) ? t("serviceUnavailable") : text);
@@ -1015,6 +1288,7 @@ function mdToPlain(md){
if(md.indexOf(CARD_PREFIX) === 0){
try{
const card = JSON.parse(md.slice(CARD_PREFIX.length));
if(card.kind === 'order') return card.summary || card.no;
const lines = ["运单号 " + card.no + "(" + (card.status || "") + ")"];
(card.nodes || []).forEach(function(n){
lines.push(n.time + " " + n.text + (n.place ? " " + n.place : ""));
@@ -1129,46 +1403,41 @@ async function doSend(retryText){
const sendWord = (isRetry ? retryText : inputDom.value).trim();
var imgUrl = isRetry ? "" : (window.__pendingImg || "");
var tag = window.__pendingTag || "[图片]";
const draft = inputDom.value, attachmentVersion = uploadVersion;
if(!sendWord && !imgUrl){
inputDom.focus();
return;
}
if(uploadPending){ setComposerStatus(t("uploading")); return; }
if(humanSendingToken || humanClosing) return;
var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
setComposerStatus("");
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; }
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid){
var content = sendWord;
if(imgUrl) content += (content ? " " : "") + tag + imgUrl;
if(!isRetry) addMsg(content, true);
inputDom.value = "";
autoGrow();
window.__pendingImg = ""; window.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
inputDom.focus();
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:window.humanSid,side:"customer",content:content})});
return;
}
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
if(window.humanSid) stopHumanMode();
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
if(cancelActive){ cancelActive(); cancelActive = null; }
sendPreparing = true;
const version = conversationVersion;
let convId;
try{
await ensureConv();
convId = await ensureConv();
}catch(error){
setComposerStatus(t('sessionFail'), true);
if(version === conversationVersion) setComposerStatus(t('sessionFail'), true);
return;
}finally{
sendPreparing = false;
if(version === conversationVersion) sendPreparing = false;
}
if(version !== conversationVersion || humanRequestToken) return;
if(!isRetry){
addMsg(fullContent, true);
inputDom.value = "";
autoGrow();
window.__pendingImg = ""; window.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
if(attachmentVersion === uploadVersion) clearAttachment();
inputDom.focus();
}
@@ -1181,7 +1450,7 @@ async function doSend(retryText){
let firstChunk = true;
let settled = false; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开"
const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 15000);
const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 90000);
const totalTimer = setTimeout(function(){ fail("生成时间过长,已中断,请重试"); }, 120000);
function cleanup(){
@@ -1257,24 +1526,27 @@ async function doSend(retryText){
cancelActive = stop;
function handleFrame(dataText){
if(settled) return;
if(settled || version !== conversationVersion) return;
let obj;
try{
obj = JSON.parse(dataText);
}catch(err){
obj = {t: dataText}; // 兼容旧的纯文本帧
}
if(!obj || typeof obj !== 'object') return;
if(obj.e){ fail(obj.e); return; }
if(obj.card){
clearTimeout(firstTimer);
aiBubble.classList.add("track-card");
fillTrackCard(aiBubble, obj.card);
aiBubble.dataset.raw = "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
firstChunk = false;
scrollChatToEnd();
return;
}
if(obj.replace){
clearTimeout(firstTimer);
aiBubble.dataset.raw = obj.replace;
aiBubble.innerHTML = parseBold(obj.replace);
scrollChatToEnd();
@@ -1309,36 +1581,45 @@ async function doSend(retryText){
fetch("/stream_chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({msg: fullContent, conv_id: currentConvId}),
body: JSON.stringify({msg: fullContent, conv_id: convId}),
signal: controller.signal
}).then(function(res){
if(!res.ok){ throw new Error("HTTP " + res.status); }
}).then(async function(res){
if(!res.ok){
let data;
try{ data = await res.json(); }catch(e){}
fail(data && (data.e || data.error) || t('networkFail'));
return;
}
if(!res.body || typeof res.body.getReader !== 'function'){ fail(t('networkFail')); return; }
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
function drain(final){
function dispatch(frame){
const data = frame.split(/\r?\n/).filter(line => line.indexOf('data:') === 0)
.map(line => line.slice(5).trimStart()).join('\n');
if(data) handleFrame(data);
}
let boundary;
while(!settled && (boundary = /\r?\n\r?\n/.exec(buffer))){
const frame = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary[0].length);
dispatch(frame);
}
if(final && !settled && buffer.trim()){ dispatch(buffer); buffer = ''; }
}
function pump(){
return reader.read().then(function(r){
if(settled) return;
if(r.done){
// Single-frame attachment acknowledgements may end without a d frame.
if(!settled){
if(aiBubble.dataset.raw) finish();
else fail(t("networkFail"));
}
buffer += decoder.decode();
drain(true);
if(!settled) fail(t("networkFail"));
return;
}
buffer += decoder.decode(r.value, {stream: true});
let idx;
// SSE 以空行分隔事件
while((idx = buffer.indexOf("\n\n")) >= 0){
const chunk = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
chunk.split("\n").forEach(function(line){
if(line.indexOf("data:") === 0){
handleFrame(line.slice(5).trimStart());
}
});
}
return pump();
drain(false);
if(!settled) return pump();
});
}
return pump();
+2 -1
View File
@@ -44,11 +44,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>
<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="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>
+452 -16
View File
@@ -5,12 +5,14 @@ const path = require('node:path');
const vm = require('node:vm');
const flush = () => new Promise(setImmediate);
async function customerPage(){
const elements = new Map(), listeners = {}, intervals = [], calls = [];
async function customerPage(pageOptions = {}){
const elements = new Map(), listeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
class Element {
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300;
this.listeners = {};
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
@@ -23,8 +25,10 @@ async function customerPage(){
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
setAttribute(name, value){ this.attributes[name] = value; }
removeAttribute(name){ delete this.attributes[name]; }
getAttribute(name){ return this.attributes[name]; }
addEventListener(){}
addEventListener(name, callback){ this.listeners[name] = callback; }
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(){}
@@ -39,31 +43,52 @@ async function customerPage(){
};
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
}
closest(selector){ return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector); }
closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this;
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); }
}
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
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: () => [],
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
createElement: () => new Element(),
addEventListener: (name, callback) => { listeners[name] = callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, 12])); } }
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'},
localStorage:{getItem:() => null, setItem(){}},
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false})},
setInterval: callback => { intervals.push(callback); return intervals.length; },
clearInterval(){}, setTimeout(){}, clearTimeout(){},
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
requestAnimationFrame:callback => callback(),
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), prompt:() => pageOptions.offlineMessage || null},
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
clearInterval:id => intervals.delete(id),
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
clearTimeout:id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, body:options && options.body && JSON.parse(options.body)});
const data = url === '/api/new_chat' ? {id:1, welcome:'welcome'}
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.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
if(url === '/stream_chat'){
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {value:new TextEncoder().encode('data: {"t":"测试 AI 回复"}\n\ndata: {"d":1}\n\n'), done:false};
}})}};
}
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
: url === '/api/rate' ? {ok:true} : [];
: ['/api/rate', '/api/human/send', '/api/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};
},
});
@@ -72,9 +97,14 @@ async function customerPage(){
}
listeners.DOMContentLoaded();
await flush();
await context.window.showHuman();
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, setState:value => { state=value; }, tick:async () => { intervals[0](); await flush(); }};
return {context, element, calls, hooks, quickButtons,
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(); },
click:async button => { await button.click(); await flush(); },
};
}
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
@@ -92,6 +122,205 @@ test('the final customer-visible reply is processed before closure and a rating
assert.equal(app.context.window.humanSid, 0);
});
function streamResponse(chunks){
let index=0;
return {ok:true, body:{getReader:() => ({read:async () => index < chunks.length
? {done:false, value:typeof chunks[index] === 'string' ? new TextEncoder().encode(chunks[index++]) : chunks[index++]}
: {done:true}})}};
}
test('AI send preserves a newer draft typed while the conversation is being created', async () => {
const app=await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat']=() => new Promise(done => { resolve=done; });
const sending=app.context.window.fillInput('第一条问题');
app.element('input-text').value='正在补充的下一条问题';
resolve(response({id:18,welcome:'welcome'}));
await sending;
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '第一条问题');
assert.equal(app.element('input-text').value, '正在补充的下一条问题');
});
test('switching to a quick topic discards the previous topic attachment', async () => {
const app=await customerPage({human:false});
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
app.context.window.__pendingTag='[文件]';
await app.click(app.quickButtons[1]);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
});
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
const app=await customerPage({human:false});
const bytes=new TextEncoder().encode('data: {"t":"完整中文回复"}\r\n\r\ndata: {"d":1}\r\n\r\n');
const chunks=Array.from({length:Math.ceil(bytes.length/5)}, (_,index) => bytes.slice(index*5,index*5+5));
app.hooks['/stream_chat']=() => streamResponse(chunks);
await app.context.window.fillInput('请回复');
await flush();
assert.match(app.element('chatDom').textContent, /完整中文回复/);
assert.equal(app.element('chatDom').querySelector('.retry-btn'), null);
assert.equal(app.context.window.__busy, false);
});
test('a stream ending without completion retains partial text and offers retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
await app.context.window.fillInput('请继续说明');
await flush();
assert.match(app.element('chatDom').textContent, /已经收到的部分回答/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
assert.equal(app.context.window.__busy, false);
});
test('rate limit responses show the server message and allow retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => response({e:'请求太频繁,请稍后再试'},429);
await app.context.window.fillInput('请查单');
await flush();
assert.match(app.element('chatDom').textContent, /请求太频繁/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
});
test('a stalled conversation creation times out and can be retried without losing the draft', async () => {
const app=await customerPage({human:false});
app.hooks['/api/new_chat']=options => new Promise((resolve,reject) => {
if(options.signal) options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'), {name:'AbortError'})));
});
app.context.window.fillInput('请帮我查询');
await app.runTimers(10000);
assert.match(app.element('composer-status').textContent, /会话创建失败/);
assert.equal(app.element('input-text').value, '请帮我查询');
app.hooks['/api/new_chat']=() => response({id:19,welcome:'welcome'});
await app.context.window.fillInput('请帮我查询');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('history refresh failures preserve the last successfully loaded list', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留的对话',ctime:'2026-10-29 12:00'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await flush();
assert.match(app.element('historyWrap').textContent, /保留的对话/);
});
test('deleting the active conversation aborts its stream and resets the composer', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前对话',ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/stream_chat']=() => ({ok:true,body:{getReader:() => ({read:() => new Promise(() => {})})}});
await app.context.window.fillInput('请等待回复');
await flush();
const remove=app.element('historyWrap').children[0].children[2];
await remove.onclick({stopPropagation(){}});
await flush();
assert.equal(app.context.window.__busy,false);
assert.equal(app.element('send-button').textContent,'发送');
assert.ok(app.calls.find(call => call.url === '/stream_chat'));
});
test('switching topics aborts an upload and does not erase the new conversation status', async () => {
const app=await customerPage({human:false});
let aborted=false;
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => { aborted=true; reject(Object.assign(new Error('aborted'),{name:'AbortError'})); });
});
app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange();
await app.click(app.quickButtons[0]);
await flush();
assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('an upload timeout releases the composer and reports an actionable failure', async () => {
const app=await customerPage({human:false});
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'),{name:'AbortError'})));
});
app.element('upload-input').files=[{name:'slow.png'}];
app.element('upload-input').onchange();
await app.runTimers(15000);
assert.equal(app.element('upload-btn').disabled,false);
assert.match(app.element('composer-status').textContent,/附件上传失败/);
await app.context.window.fillInput('请继续查单');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('malformed stored pin preferences cannot break the conversation list', async () => {
const app=await customerPage({human:false,storage:{pinned_conv:'{broken'},history:[{id:9,title:'正常对话',ctime:'now'}]});
assert.match(app.element('historyWrap').textContent,/正常对话/);
});
test('clearing history blocks new conversations until its timed request settles', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留历史',ctime:'now'}]});
app.hooks['/api/clear_all']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'),{name:'AbortError'})));
});
app.element('.clear-all-btn').click();
await app.click(app.quickButtons[1]);
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('a failed history deletion preserves the active conversation for the next message', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前历史',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/api/del_history?id=9']=() => response({ok:false},503);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
await app.context.window.fillInput('继续这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id,9);
});
test('successful deletion updates the local list even when its refresh is rate limited', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'待删除会话',ctime:'now'},{id:10,title:'保留会话',ctime:'now'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
assert.doesNotMatch(app.element('historyWrap').textContent,/待删除会话/);
assert.match(app.element('historyWrap').textContent,/保留会话/);
});
test('successful clearing removes cached history when the followup refresh fails', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'待清空会话',ctime:'now'}]});
app.hooks['/api/clear_all']=() => response({ok:true});
app.hooks['/api/get_history']=() => Promise.reject(new Error('offline'));
app.element('.clear-all-btn').click();
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/待清空会话/);
assert.ok(app.element('historyWrap').querySelector('.history-empty'));
});
test('a history refresh started before a deletion cannot resurrect the deleted item', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'旧刷新中的会话',ctime:'now'}]});
let resolve, refreshes=0;
app.hooks['/api/get_history']=() => ++refreshes === 1 ? new Promise(done => { resolve=done; }) : response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
resolve(response([{id:9,title:'旧刷新中的会话',ctime:'now'}]));
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/旧刷新中的会话/);
});
test('history text and order cards preserve their original times and card actions', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'历史时间',ctime:'2026-10-28 07:12'}]});
app.hooks.messages=() => response([
{role:'user',content:'FL202610290001',ctime:'2026-10-28 07:12'},
{role:'assistant',content:'__CARD__'+JSON.stringify({kind:'order',no:'FL202610290001',status:'履约中',fields:[],nodes:[],summary:'订单概要'}),ctime:'2026-10-28 07:13'},
{role:'assistant',content:'后续补充回复',ctime:'2026-10-28 07:14'}
]);
await app.click(app.element('historyWrap').children[0]);
const times=app.element('chatDom').querySelectorAll('.msg-time');
assert.deepEqual(times.map(item => item.textContent),['07:12','07:13','07:14']);
assert.equal(times[0].title,'2026-10-28 07:12');
assert.ok(app.element('chatDom').querySelector('.track-card').querySelector('.msg-actions'));
});
test('recalling an earlier reply preserves newer replies in the customer log', async () => {
const app = await customerPage();
app.setState({status:'active', messages:[{id:1, side:'agent', content:'早先的回复'}, {id:2, side:'agent', content:'最新回复'}]});
@@ -103,3 +332,210 @@ test('recalling an earlier reply preserves newer replies in the customer log', a
assert.match(log.textContent, /最新回复/);
assert.doesNotMatch(log.textContent, /早先的回复/);
});
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
const app = await customerPage();
for(const button of app.quickButtons) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat');
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.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
});
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
const app = await customerPage();
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
const button = app.element('question-'+question);
button.dataset.question = question;
app.element('chatDom').listeners.click({target:button});
await flush();
}
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);
});
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
const app = await customerPage({human:false});
const button = app.element('contact-card');
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 === '/stream_chat').length, 0);
assert.equal(app.element('human-session').hidden, false);
});
test('new chat clears human routing and the next quote is sent to AI', async () => {
const app = await customerPage();
await app.click(app.element('.new-chat-btn'));
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.context.window.__busy, false);
});
test('opening AI history clears the human session and retains that conversation id', async () => {
const app = await customerPage({history:[{id:9, title:'之前的对话', ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
await app.context.window.fillInput('请继续回答这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 9);
assert.equal(app.context.window.humanSid, 0);
const authors = app.element('chatDom').querySelectorAll('.message-author');
assert.ok(authors.some(author => author.innerHTML.includes('data-i18n="assistant"')));
});
test('a repeated quick click while a new conversation is being created sends only once', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click();
app.quickButtons[0].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'}));
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('a late conversation creation cannot replace the newest quick question', async () => {
const app = await customerPage({human:false});
let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click();
await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'}));
await flush();
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 1);
assert.equal(requests[0].body.conv_id, 22);
assert.equal(requests[0].body.msg, '包裹异常处理');
assert.equal(app.context.window.__busy, false);
});
test('a late history response cannot overwrite the latest quick conversation', async () => {
const app = await customerPage({history:[{id:9, title:'旧会话', ctime:'2026-10-29 12:00'}]});
let resolve;
app.hooks.messages = () => new Promise(done => { resolve=done; });
await app.click(app.element('historyWrap').children[0]);
await app.click(app.quickButtons[1]);
resolve(response([{role:'user', content:'旧会话的内容不应显示'}]));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧会话的内容不应显示/);
assert.match(app.element('chatDom').textContent, /测试 AI 回复/);
});
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; });
const connecting = app.context.window.showHuman();
await flush();
await app.click(app.quickButtons[1]);
resolve(response({ok:true, session_id:8}));
await connecting;
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
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.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:'请明天跟进报价'});
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0);
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);
});
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);
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 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; });
const first = app.context.window.fillInput('请确认时效');
await app.context.window.fillInput('请确认时效');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
resolve(response({ok:true}));
await first;
assert.equal(app.element('input-text').value, '');
});
test('returning to AI explicitly closes the human session and preserves the conversation', async () => {
const app = await customerPage();
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.find(call => call.url === '/stream_chat').body.conv_id, 1);
assert.equal(app.context.window.humanSid, 0);
});
test('a stale human poll cannot append messages to a newly selected AI topic', async () => {
const app = await customerPage();
let resolve;
app.hooks.poll = () => new Promise(done => { resolve=done; });
await app.tick();
await app.click(app.quickButtons[1]);
resolve(response({status:'active', messages:[{id:1, side:'agent', content:'旧人工会话的回复'}]}));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧人工会话的回复/);
});
test('human poll failures are visible and clear after the connection recovers', async () => {
const app = await customerPage();
app.hooks.poll = () => Promise.reject(new Error('Disconnected'));
await app.tick();
assert.match(app.element('composer-status').textContent, /连接中断/);
delete app.hooks.poll;
await app.tick();
assert.equal(app.element('composer-status').hidden, true);
});
test('order cards show facts as text without import metadata, including older saved cards', async () => {
const app = await customerPage({human:false});
const card = {kind:'order', no:'FL202610290001', status:'履约中',
fields:[{key:'weight',value:'30.000 kg'},{key:'warehouse',value:'<img src=x onerror=bad()>'}],
nodes:[{time:'2026-10-29 10:00',text:'已提交',place:''}],
summary:'订单 FL202610290001,申报毛重30.000 kg', snapshot_at:'2026-10-29 10:30', notice:'旧数据说明'};
app.hooks['/stream_chat'] = () => {
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {done:false,value:new TextEncoder().encode('data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n')};
}})}};
};
await app.context.window.fillInput('FL202610290001');
await flush();
const log = app.element('chatDom');
const fields = log.querySelector('.order-data-fields');
assert.ok(fields);
assert.equal(fields.children[1].textContent, '30.000 kg');
assert.equal(fields.children[3].innerHTML, '');
assert.equal(log.querySelector('.order-data-notice'), null);
assert.doesNotMatch(log.textContent, /旧数据说明|数据导入|2026-10-29 10:30/);
assert.equal(app.context.window.__busy, false);
});
+190 -1
View File
@@ -45,6 +45,14 @@ class ApiTests(unittest.TestCase):
self.root = Path(self.directory.name)
self.app = self.application(self.root)
self.client = self.app.test_client()
from logistics_chat.order_data import import_records
with self.app.app_context():
connection = db.connect()
import_records(connection, [{'tenant_id':'fixture', 'order_no':'15252125',
'status':'PROCESSING', 'country_code':'US', 'gross_weight_kg':'30',
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'}]}])
connection.commit()
connection.close()
def application(self, root):
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
@@ -114,6 +122,167 @@ class ApiTests(unittest.TestCase):
self.assertEqual(events[0]['card']['no'], '15252125')
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment)
def test_image_attachments_do_not_swallow_order_questions_and_are_saved(self):
cid = self.conversation()
image = '[图片]/uploads/20261029100145_6787.png'
events = frames(self.client.post('/stream_chat', json={'msg':'查询15252125 '+image, 'conv_id':cid}))
self.assertEqual(events[0]['card']['no'], '15252125')
events = frames(self.client.post('/stream_chat', json={'msg':image, 'conv_id':cid}))
self.assertIn('收到您的图片', events[0]['t'])
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertEqual(messages[-2]['content'], image)
self.assertIn('收到您的图片', messages[-1]['content'])
def test_bad_chat_payloads_produce_readable_errors_without_calling_model(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
invalid = ([1], 'hello', {'msg':7}, {'msg':['test']}, {'msg':'物流咨询', 'conv_id':{}},
{'msg':'物流咨询', 'conv_id':-1}, {'msg':'物流咨询', 'conv_id':1.5}, {'msg':'物流咨询','conv_id':False},
{'msg':'物流咨询','conv_id':999999999999999999999999},
{'msg':'物流咨询','conv_id':'²'}, {'msg':'物流咨询','conv_id':'9'*5000})
with patch('logistics_chat.ai_client.post_completion') as model:
for index, payload in enumerate(invalid):
with self.subTest(case=index):
events = frames(self.client.post('/stream_chat', json=payload))
self.assertIn('e', events[0])
model.assert_not_called()
def test_deleted_conversations_close_human_sessions_and_reject_new_chat_messages(self):
cid = self.conversation()
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026,10,29,12)
sid = self.client.post('/api/human/request',json={'conv_id':cid}).json['session_id']
self.client.post('/api/human/send',json={'sid':sid,'content':'原会话内容'})
self.client.delete(f'/api/del_history?id={cid}')
self.assertEqual(self.client.get('/api/human/queue').json, [])
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'15252125','conv_id':cid}))
self.assertIn('已删除',events[0]['e'])
model.assert_not_called()
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026,10,29,12)
self.assertEqual(self.client.post('/api/human/request',json={'conv_id':cid}).status_code,404)
def test_uk_quotes_and_followups_keep_the_uk_rates(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
cid = self.conversation()
for message, expected in (('30kg英国空运报价','英国空运专线'), ('那海运呢?','英国海运'),
('那空运呢?','英国空运专线'), ('那50公斤呢?','英国空运专线')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
prompt = model.call_args.args[0]['messages'][0]['content']
self.assertIn(expected,prompt)
self.assertNotIn('欧洲空运专线',prompt)
self.assertNotIn('欧洲海运快线',prompt)
def test_us_quotes_never_reuse_european_price_tables(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'30KG发美国FBA仓,海运报价','conv_id':self.conversation()}))
self.assertIn('目的地',events[0]['t'])
self.assertIn('人工',events[0]['t'])
model.assert_not_called()
def test_mixed_country_codes_select_the_correct_rates(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
for message, channel, amount in (('30kg发UK海运报价','英国海运','384'),
('30kg发uk海运报价','英国海运','384'),
('30kg发GB海运报价','英国海运','384'),
('30kg发DE仓海运报价','欧洲海运','369')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion', return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
events=frames(self.client.post('/stream_chat',json={'msg':message}))
self.assertEqual(events[-1],{'d':1})
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(channel,prompt)
self.assertIn(amount,prompt)
for message in ('30kg发US仓海运报价','30kg发us仓海运报价','30kg发USFBA仓海运报价',
'30kg发阿根廷海运报价','30kg海运报价'):
with self.subTest(message=message), patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':message}))
self.assertIn('目的',events[0]['t'])
model.assert_not_called()
def test_short_channel_followups_and_retries_keep_weight_and_region(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
cid=self.conversation()
sequence=(('30kg英国海运报价','英国海运','384'), ('海运呢','英国海运','384'),
('空运呢','英国空运','1,440'), ('50公斤呢','英国空运','2,400'))
for message, channel, amount in sequence:
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(channel,prompt)
self.assertIn(amount,prompt)
with patch('logistics_chat.ai_client.post_completion',side_effect=requests.exceptions.ConnectionError):
self.assertIn('e',frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))[0])
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model:
frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn('英国海运',prompt)
self.assertIn('640',prompt)
def test_complete_new_questions_do_not_inherit_an_old_quote(self):
from logistics_chat.chat_service import shipping_intent_with_context
cid=self.conversation()
with self.app.app_context():
db.save_message(cid,'user','30kg英国海运报价')
db.save_message(cid,'assistant','报价结果')
for message in ('新的一票50kg德国空运报价','空运理赔应该怎么办?','海运时效一般多久?'):
with self.subTest(message=message):
intent=shipping_intent_with_context(message,cid)
self.assertNotEqual(intent['region'],'uk')
db.save_message(cid,'user','空运理赔应该怎么办?')
self.assertIsNone(shipping_intent_with_context('海运呢',cid)['weight'])
def test_dimension_quotes_and_changes_do_not_become_order_identifiers(self):
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
cid=self.conversation()
for message, amount in (('30kg英国海运,100x100x100cm','1,817'), ('50x40x30cm呢','384'),
('1.2x0.8x0.6m呢','1,229'),
('那另外一票50kg德国海运','615')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
events=frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
self.assertEqual(events[-1],{'d':1})
self.assertTrue(model.called)
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(amount,prompt)
if '另外一票' in message:
self.assertNotIn('体积重',prompt)
def test_destination_only_replies_continue_the_existing_quote(self):
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
cid=self.conversation()
self.assertIn('目的',frames(self.client.post('/stream_chat',json={'msg':'30kg海运报价','conv_id':cid}))[0]['t'])
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':'英国','conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn('英国海运',prompt)
self.assertIn('384',prompt)
with patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':'US仓','conv_id':cid}))
self.assertIn('尚未包含',events[0]['t'])
model.assert_not_called()
def test_uk_volume_quotes_do_not_reuse_continental_warehouse_prices(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'2方发英国海运多少钱'}))
self.assertIn('未提供',events[0]['t'])
self.assertIn('按方费率',events[0]['t'])
model.assert_not_called()
def test_closed_human_sessions_cannot_be_reopened_by_a_late_accept(self):
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026,10,29,12)
sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id']
self.client.post('/api/human/close',json={'sid':sid})
self.assertEqual(self.client.post('/api/human/accept',json={'sid':sid}).status_code,409)
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'],'closed')
def test_human_lifecycle_typing_and_read_receipts(self):
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
with patch('logistics_chat.human_service.datetime') as clock:
@@ -223,13 +392,33 @@ class ApiTests(unittest.TestCase):
streamed = ModelResponse('这是用于覆盖长回答压缩的说明。'*100)
responses = [streamed, ModelResponse('基础运费2050元,时效38-45天。'), ModelResponse('["费用呢","时效呢","能带电吗"]')]
with patch('logistics_chat.ai_client.requests.post', side_effect=responses) as post:
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 海运', 'conv_id': self.conversation()}))
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 德国海运', 'conv_id': self.conversation()}))
self.assertTrue(any('t' in event for event in events))
self.assertTrue(any('replace' in event for event in events))
self.assertTrue(any('suggest' in event for event in events))
self.assertEqual(events[-1], {'d': 1})
self.assertEqual([call.kwargs['timeout'] for call in post.call_args_list], [(5, 90), (5, 30), (5, 15)])
self.assertTrue(streamed.closed)
self.assertTrue(all(response.closed for response in responses))
def test_incomplete_model_streams_are_reported_and_saved_as_partial(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
for reason in (None, 'length'):
with self.subTest(reason=reason):
cid = self.conversation()
response = ModelResponse('这是已经生成的部分内容,需要保留以便用户了解中断前的信息。')
def lines():
yield ('data: '+json.dumps({'choices':[{'delta':{'content':response.content}, 'finish_reason':reason}]})).encode()
if reason:
yield b'data: [DONE]'
response.iter_lines = lines
with patch('logistics_chat.ai_client.post_completion', return_value=response):
events = frames(self.client.post('/stream_chat', json={'msg':'物流咨询', 'conv_id':cid}))
self.assertTrue(any('e' in event for event in events))
self.assertFalse(any(event.get('d') for event in events))
self.assertTrue(response.closed)
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertIn('回答中断', messages[-1]['content'])
def test_stream_network_and_model_errors(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
+7
View File
@@ -91,6 +91,13 @@ class ConfigurationTests(unittest.TestCase):
self.assertIs(response, sentinel.response)
self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer file-key')
def test_order_scope_is_fixed_by_server_file_or_environment(self):
self.write_env('ORDER_DATA_TENANT_ID=tenant-file\n')
os.environ.pop('ORDER_DATA_TENANT_ID', None)
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-file')
os.environ['ORDER_DATA_TENANT_ID'] = 'tenant-environment'
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-environment')
if __name__ == '__main__':
unittest.main()
+11
View File
@@ -67,6 +67,17 @@ class DevIntegrationTests(unittest.TestCase):
self.client.post('/api/human/accept', json={'sid': sid})
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'active')
def test_human_send_rejects_closed_missing_or_mismatched_conversations(self):
sid = self.session()
payload = {'sid': sid, 'side': 'customer', 'content': '不能串到其他会话'}
other_cid = self.client.post('/api/new_chat').json['id']
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'conv_id': other_cid}).status_code, 409)
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'sid': 99999}).status_code, 404)
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'side': 'system'}).status_code, 400)
self.client.post('/api/human/close', json={'sid': sid})
self.assertEqual(self.client.post('/api/human/send', json=payload).status_code, 409)
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
def test_recall_targets_older_agent_message_and_is_idempotent(self):
sid = self.session()
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
+48 -5
View File
@@ -35,7 +35,15 @@ elif name == 'nginx' and '-t' in args and scenario == 'proxy-fail':
if sum(item[0] == 'nginx' and '-t' in item[1] for item in calls) == 1:
sys.exit(19)
elif name == 'curl' and '-w' in args:
calls = [json.loads(line) for line in log.read_text().splitlines()]
if scenario == 'reload-pending' and sum(item[0] == 'curl' and '-w' in item[1] for item in calls) == 1:
sys.exit(60)
if scenario == 'proxy-unsafe':
print('200', end='')
sys.exit(0)
print('401', end='')
elif name == 'sleep':
pass
'''
@@ -47,7 +55,8 @@ class GrayActivationTests(unittest.TestCase):
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
proxy = directory / 'proxy'
for path in (root / 'data', root / 'logs', root / 'releases', proxy):
auth = directory / 'baiyue-logistics-chat-gray-proxy'
for path in (root / 'data', root / 'logs', root / 'releases', proxy, auth):
path.mkdir(parents=True, exist_ok=True)
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
(root / 'data/history.db').write_bytes(b'preserve-existing-data')
@@ -61,18 +70,22 @@ class GrayActivationTests(unittest.TestCase):
(root / 'current-release').write_text(str(previous) + '\n')
old_site = '# Managed for baiyue-logistics-chat-gray\n# previous site\n'
(proxy / 'baiyue-logistics-chat-gray.conf').write_text(old_site)
(proxy / '.baiyue-logistics-chat.htpasswd').write_text('previous-auth-hash\n')
(auth / '.project').write_text('another-service\n' if scenario == 'unowned-auth' else 'baiyue-logistics-chat-gray\n')
(auth / 'access.htpasswd').write_text('previous-auth-hash\n')
unrelated = proxy / 'unrelated.conf'
unrelated.write_text('# unrelated site\n')
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
str(proxy), str(certificate), str(private_key), 'a'*40,
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'b'*64, 'sha256:' + 'c'*64)
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64, str(auth))
release = Path(config.release)
write_release(release, config, 'fixture-model-key')
# Docker 26 and 29 may assign different IDs after an archive is loaded.
loaded_id = ('e' if scenario == 'image-mismatch' else 'b') * 64
(release / 'loaded-image-ids.env').write_text('CHAT_IMAGE_ID=sha256:' + loaded_id + '\n')
(release / 'site.htpasswd').write_text('new-auth-hash\n')
commands = directory / 'commands'
commands.mkdir()
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl'):
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl', 'sleep'):
command = commands / name
command.write_text('#!' + sys.executable + '\n' + FAKE_COMMAND)
command.chmod(0o755)
@@ -92,19 +105,49 @@ class GrayActivationTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
self.assertIn('server_name chat.example.com', (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
self.assertEqual((auth / 'access.htpasswd').read_text(), 'new-auth-hash\n')
self.assertIn(str(auth / 'access.htpasswd'), (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
self.assertEqual(auth.stat().st_mode & 0o777, 0o755)
self.assertEqual((auth / 'access.htpasswd').stat().st_mode & 0o777, 0o644)
def assert_rolled_back(self, scenario):
result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
self.assertEqual((proxy / '.baiyue-logistics-chat.htpasswd').read_text(), 'previous-auth-hash\n')
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)
def test_failed_application_restores_previous_release(self):
self.assert_rolled_back('app-fail')
def test_async_nginx_reload_waits_for_the_new_tls_workers(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('reload-pending')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
self.assertTrue(any(name == 'sleep' for name, args in calls))
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
self.assert_rolled_back('proxy-unsafe')
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)
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
def test_password_directory_owned_by_another_service_is_not_modified(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('unowned-auth')
self.assertNotEqual(result.returncode, 0)
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
self.assertEqual((auth / '.project').read_text(), 'another-service\n')
self.assertEqual((auth / 'access.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
self.assert_rolled_back('proxy-fail')
+79
View File
@@ -0,0 +1,79 @@
"""Verify the real remote image-load guard without contacting Docker or a host."""
import gzip
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Image transfer requires Linux Bash')
class GrayImageTransferTests(unittest.TestCase):
def run_load(self, scenario):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
release = root / 'releases/build'
release.mkdir(parents=True)
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
archive = release / 'images.tar.gz'
archive.write_bytes(gzip.compress(b'fixture-image-archive'))
checksum = hashlib.sha256(archive.read_bytes()).hexdigest()
if scenario == 'corrupt':
archive.write_bytes(archive.read_bytes() + b'changed-in-transit')
commands = directory / 'commands'
commands.mkdir()
docker = commands / 'docker'
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, sys
args = sys.argv[1:]
with pathlib.Path(os.environ['FAKE_LOG']).open('a') as output:
output.write(' '.join(args) + '\\n')
if args == ['load']:
assert sys.stdin.buffer.read() == b'fixture-image-archive'
elif args[:2] == ['image', 'inspect']:
if args[3] == '{{.Id}}':
print('sha256:' + ('b' if '-gray:' in args[-1] else 'c') * 64)
else:
print(('e' if os.environ['FAKE_SCENARIO'] == 'wrong-source' else 'a') * 40)
else:
sys.exit(3)
''')
docker.chmod(0o755)
publish = (Path(__file__).resolve().parents[1] / 'deploy/publish-gray.sh').read_text()
remote = publish.split("<<'LOAD'\n", 1)[1].split('\nLOAD\n', 1)[0]
log = directory / 'commands.log'
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
FAKE_LOG=str(log), FAKE_SCENARIO=scenario)
result = subprocess.run(['bash', '-s', '--', str(root), str(release), checksum,
'baiyue-logistics-chat-gray:' + 'a' * 12 + '-b7',
'baiyue-logistics-chat-certbot:' + 'a' * 12 + '-b7', 'a' * 40],
input=remote, env=environment, capture_output=True, text=True, timeout=15)
return result, release, log
def test_verified_archive_records_target_ids_and_removes_archive(self):
result, release, log = self.run_load('success')
self.assertEqual(result.returncode, 0, result.stderr)
receipt = (release / 'loaded-image-ids.env').read_text()
self.assertIn('CHAT_IMAGE_ID=sha256:' + 'b' * 64, receipt)
self.assertIn('CERTBOT_IMAGE_ID=sha256:' + 'c' * 64, receipt)
self.assertFalse((release / 'images.tar.gz').exists())
def test_modified_archive_is_rejected_before_docker_load(self):
result, release, log = self.run_load('corrupt')
self.assertNotEqual(result.returncode, 0)
self.assertFalse(log.exists())
self.assertFalse((release / 'loaded-image-ids.env').exists())
def test_wrong_source_cannot_create_a_verified_receipt(self):
result, release, log = self.run_load('wrong-source')
self.assertNotEqual(result.returncode, 0)
self.assertFalse((release / 'loaded-image-ids.env').exists())
if __name__ == '__main__':
unittest.main()
+192
View File
@@ -0,0 +1,192 @@
"""Local order snapshots, exact lookup and conversational order queries."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app, db, order_data
from logistics_chat.tracking import CARD_PREFIX, card_to_text
class OrderDataTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
root = Path(folder.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':'', 'ORDER_DATA_TENANT_ID':'', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.context = self.app.app_context()
self.context.push()
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',
'status':'PROCESSING', 'country_code':'US', 'transport_mode':'SEA', 'warehouse_code':'TEST1',
'order_role':'MASTER', 'package_count':3, 'gross_weight_kg':'30.000', 'volume_cbm':'0.2500',
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
self.import_rows([self.record])
def import_rows(self, rows, replace=False):
connection = db.connect()
try:
with connection:
return order_data.import_records(connection, rows, replace=replace)
finally:
connection.close()
def events(self, message, cid=None):
response = self.client.post('/stream_chat', json={'msg':message, 'conv_id':cid})
self.assertEqual(response.status_code, 200)
return [json.loads(frame[6:]) for frame in response.get_data(as_text=True).split('\n\n') if frame.startswith('data: ')]
def test_identifiers_are_exact_case_insensitive_and_preserve_suffixes(self):
for value in ('fl202610290001', 'INB20261029001-01', 'shipment00208'):
self.assertEqual(order_data.lookup_order(value)[0]['order_no'], self.record['order_no'])
self.assertEqual(self.events('查询 '+value)[0]['card']['no'], self.record['order_no'])
self.assertEqual(order_data.lookup_order('FL20261029000'), [])
self.assertEqual(order_data.extract_reference('查询 INB20261029001-01'), ['INB20261029001-01'])
self.import_rows([{**self.record,'order_no':'BOX100x100x100'}])
self.assertEqual(self.events('查询 BOX100x100x100')[0]['card']['no'],'BOX100x100x100')
def test_weight_dates_and_attachment_urls_are_not_order_identifiers(self):
for text in ('2000KG海运报价', '100000 kg海运报价', '30.000000 KG', '2026-10-29',
'2026-10-29T16:53:00', '100x100x100cm', '1200 × 800 × 600 mm',
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
self.assertEqual(order_data.extract_reference(text), [])
events = self.events('帮我查询物流轨迹')
self.assertIn('订单号', events[0]['t'])
self.assertEqual(events[-1], {'d':1})
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
'FL202610290001 运费多少钱?'):
with self.subTest(message=message):
event = self.events(message)[0]
if '运费' in message:
self.assertIn('未记录实际运费', event['t'])
else:
self.assertEqual(event['card']['no'], self.record['order_no'])
def test_unknown_orders_never_produce_fake_tracking_or_call_model(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = self.events('查询 UNKNOWN20260001')
self.assertIn('未找到', events[0]['t'])
self.assertFalse(any('card' in event for event in events))
self.assertEqual(events[-1], {'d':1})
model.assert_not_called()
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = self.events('列出所有订单')
self.assertIn('请提供订单号', events[0]['t'])
model.assert_not_called()
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'])
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
rows = order_data.lookup_order(self.record['order_no'])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]['gross_weight_kg'], '30.000')
self.app.config['ORDER_DATA_TENANT_ID'] = 'unknown-tenant'
self.assertEqual(order_data.lookup_order(self.record['order_no']), [])
def test_manual_changes_are_visible_and_reimport_preserves_them_by_default(self):
connection = db.connect()
connection.execute("UPDATE order_test_data SET gross_weight_kg='42.000' WHERE order_no=?", (self.record['order_no'],))
connection.commit()
connection.close()
self.assertEqual(self.import_rows([self.record]), {'imported':0, 'preserved':1})
fields = self.events(self.record['order_no'])[0]['card']['fields']
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '42.000 kg')
self.import_rows([self.record], replace=True)
self.assertEqual(order_data.lookup_order(self.record['order_no'])[0]['gross_weight_kg'], '30.000')
def test_new_table_records_can_be_added_without_touching_source_database(self):
self.import_rows([{**self.record, 'order_no':'LOCAL20260002', 'shipment_id':'localship002'}])
self.assertEqual(self.events('查询 LOCAL20260002')[0]['card']['no'], 'LOCAL20260002')
def test_business_status_history_does_not_claim_delivery_or_invent_transport_events(self):
self.import_rows([{**self.record, 'status':'COMPLETED'}], replace=True)
card = self.events(self.record['order_no'])[0]['card']
self.assertEqual(card['status'], '头程订单已完成')
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
self.assertEqual([node['time'] for node in card['nodes']], ['2026-10-29 10:00','2026-10-28 10:00'])
self.assertTrue(all(not node['place'] for node in card['nodes']))
def test_children_do_not_reuse_parent_total_weight(self):
self.import_rows([{**self.record, 'order_no':'CHILD20260001-01', 'order_role':'CHILD',
'gross_weight_kg':None, 'package_count':None, 'volume_cbm':None,
'parent_order_no':self.record['order_no'], 'data_note':'子单独立货物数据未提供'}])
card = self.events('CHILD20260001-01')[0]['card']
self.assertFalse(any(field['key']=='weight' for field in card['fields']))
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='parent'), self.record['order_no'])
def test_followups_refresh_local_data_and_remain_within_conversation(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
connection = db.connect()
connection.execute("UPDATE order_test_data SET gross_weight_kg='45.000' WHERE order_no=?", (self.record['order_no'],))
connection.commit()
connection.close()
card = self.events('这票重量是多少?',cid)[0]['card']
self.assertEqual(card['no'], self.record['order_no'])
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='weight'), '45.000 kg')
self.assertIn('未记录实际运费', self.events('这票运费多少钱?',cid)[0]['t'])
self.assertEqual(self.events('那状态呢?',cid)[0]['card']['no'], self.record['order_no'])
self.events('查询 UNKNOWN20260001',cid)
self.assertIn('未找到', self.events('这票重量呢?',cid)[0]['t'])
other_cid = self.client.post('/api/new_chat').json['id']
self.assertIsNone(order_data.answer_query('这票重量是多少?', other_cid))
def test_new_shipping_questions_do_not_reuse_previous_order(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
for message in ('现在有30KG货物想发美国,运费多少?', '美国海运费用怎么算?', '报价30公斤到德国', '物流状态是什么意思?'):
with self.subTest(message=message):
self.assertIsNone(order_data.answer_query(message, cid))
db.save_message(cid, 'user', '美国海运费用怎么算?')
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
self.assertIsNone(order_data.answer_query('那状态呢?', cid))
def test_short_order_followups_read_facts_without_calling_model(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
with patch('logistics_chat.ai_client.post_completion') as model:
for message in ('多少公斤?', '有几箱?', '体积呢?', '那状态呢?'):
with self.subTest(message=message):
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
model.assert_not_called()
def test_card_storage_and_model_context_contain_only_selected_facts(self):
card = self.events(self.record['order_no'])[0]['card']
text = card_to_text(CARD_PREFIX+json.dumps(card))
self.assertIn('30.000', text)
self.assertIn('US', text)
self.assertNotIn('测试', text)
self.assertNotIn('tenant_id', text)
self.assertNotIn('customer_name', json.dumps(card))
def test_invalid_history_is_safe_and_read_failure_is_explicit(self):
connection = db.connect()
connection.execute("UPDATE order_test_data SET history_json='invalid'")
connection.commit()
self.assertEqual(self.events(self.record['order_no'])[0]['card']['nodes'], [])
connection.execute('DROP TABLE order_test_data')
connection.commit()
connection.close()
self.assertIn('暂时无法读取', self.events(self.record['order_no'])[0]['t'])
def test_import_failure_rolls_back_partial_rows(self):
with self.assertRaises(ValueError):
self.import_rows([{**self.record,'order_no':'ROLLBACK20260001'}, {'order_no':''}])
self.assertEqual(order_data.lookup_order('ROLLBACK20260001'), [])
if __name__ == '__main__':
unittest.main()
+19
View File
@@ -40,6 +40,23 @@ class RuleTests(unittest.TestCase):
self.assertEqual(intent['channel'], 'sea')
self.assertEqual(pricing.parse_shipping_intent('1吨铁路')['weight'], 1000)
self.assertEqual(pricing.parse_shipping_intent('100 lbs air')['weight'], 45.36)
self.assertEqual(pricing.parse_shipping_intent('1.2 x 0.8 x 0.6 m 30kg海运')['dims'],[1.2,0.8,0.6])
self.assertEqual(pricing.parse_shipping_intent('长10.5宽20.5高30.5')['dims'],[10.5,20.5,30.5])
def test_shipping_regions_distinguish_country_codes_from_english_pronouns(self):
self.assertEqual(pricing.shipping_region('Give us a quote for 30kg to Germany'),'eu')
self.assertEqual(pricing.shipping_region('30kg air to US'),'unsupported')
self.assertEqual(pricing.shipping_region('30kg air to us'),'unsupported')
self.assertEqual(pricing.shipping_region('30kg to UK'),'uk')
self.assertEqual(pricing.shipping_region('30kg发UK海运'),'uk')
self.assertEqual(pricing.shipping_region('30kg发US仓海运'),'unsupported')
self.assertIsNone(pricing.shipping_region('talk about us and our work'))
self.assertIsNone(pricing.shipping_region('30kg for UKEURO warehouse'))
self.assertIsNone(pricing.shipping_region('2GB附件,30kg海运报价'))
self.assertTrue(pricing.is_shipping_destination_reply('英国的FBA仓'))
self.assertTrue(pricing.is_shipping_destination_reply('目的地是UK仓'))
self.assertFalse(pricing.is_shipping_destination_reply('英国VAT费用是什么'))
self.assertFalse(pricing.is_shipping_destination_reply('Give us a quote for 30kg to Germany'))
def test_warehouse_country_and_surcharge(self):
result = pricing.calc_warehouse_card(2, '海运', country='DE')
@@ -51,6 +68,8 @@ class RuleTests(unittest.TestCase):
self.assertIn('500', pricing.surcharge_reply(product, fee, 100))
self.assertEqual(pricing.check_island('07000')[1], 'IT')
self.assertIsNone(pricing.check_island('not a postcode'))
self.assertIsNone(pricing.calc_warehouse_card(2, '海运', country='GB'))
self.assertIsNone(pricing.calc_warehouse_card(2, '海运', warehouse_key='unknown'))
def test_tracking_recognition_and_card_context(self):
self.assertEqual(tracking.extract_tracking_no('查询15252125'), '15252125')
+12
View File
@@ -117,6 +117,18 @@ class StorageTests(unittest.TestCase):
self.assertEqual(connection.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0], 0)
connection.close()
def test_late_stream_writes_cannot_recreate_messages_for_deleted_conversations(self):
connection = db.connect()
cid = connection.execute("INSERT INTO chat_history(title,ctime) VALUES('temporary','now')").lastrowid
connection.commit()
db.save_message(cid,'user','发送中的问题')
connection.execute('DELETE FROM messages WHERE conv_id=?',(cid,))
connection.execute('DELETE FROM chat_history WHERE id=?',(cid,))
connection.commit()
db.save_message(cid,'assistant','迟到的模型回复')
self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?',(cid,)).fetchone()[0],0)
connection.close()
if __name__ == '__main__':
unittest.main()