From 805675f8db34d014f39b867b8c26f1b20df2bc18 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 29 Oct 2026 10:59:29 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E7=89=A9=E6=B5=81=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E6=A8=A1=E5=9D=97=E5=B9=B6=E5=AE=8C=E5=96=84=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=8E=A5=E5=BE=85=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分应用工厂、业务服务与路由,保留原有 HTTP/SSE 和存储入口 - 分离页面资源,重排客服工作台并完善响应式导航、草稿和附件交互 - 整理维护脚本、请求示例和贡献指南,移除启动脚本中的密钥配置 - 上传文件改为仅本地保留,补充隔离的后端与前端回归验证 验证:46 项 Python 测试通过,含 14 个已有子测试;12 个 Node 前端场景通过。 --- .env.example | 4 + .gitignore | 13 +- AGENTS.md | 61 + README.md | 290 +- am.py | 1573 +---------- c.json | 1 - check.py | 5 - clean.py | 5 - docs/architecture.md | 113 + knowledge.md => docs/knowledge.md | 4 +- examples/requests/README.md | 20 + examples/requests/human_accept.json | 3 + examples/requests/human_request.json | 3 + examples/requests/human_request_invalid.json | 3 + examples/requests/human_send.json | 5 + human.json | 1 - logistics_chat/__init__.py | 45 + logistics_chat/ai_client.py | 61 + logistics_chat/business_service.py | 75 + logistics_chat/chat_service.py | 199 ++ logistics_chat/config.py | 40 + logistics_chat/db.py | 196 ++ logistics_chat/file_service.py | 46 + logistics_chat/history_service.py | 49 + logistics_chat/hooks.py | 28 + logistics_chat/human_service.py | 102 + logistics_chat/knowledge.py | 105 + logistics_chat/pricing.py | 339 +++ logistics_chat/rates_extra.py | 78 + logistics_chat/routes/__init__.py | 7 + logistics_chat/routes/admin.py | 31 + logistics_chat/routes/business.py | 21 + logistics_chat/routes/chat.py | 29 + logistics_chat/routes/files.py | 17 + logistics_chat/routes/history.py | 31 + logistics_chat/routes/human.py | 56 + logistics_chat/routes/pages.py | 10 + logistics_chat/runtime.py | 33 + logistics_chat/tracking.py | 78 + rates_extra.py | 82 +- s.json | 1 - scripts/close_active_sessions.py | 34 + scripts/inspect_human_sessions.py | 38 + start.bat | 63 +- static/css/admin.css | 186 ++ static/css/admin_login.css | 57 + static/css/chat.css | 240 ++ static/css/theme.css | 85 + static/js/admin.js | 393 +++ static/js/admin_login.js | 58 + static/js/chat.js | 1406 ++++++++++ t.json | 1 - templates/admin.html | 342 +-- templates/admin_login.html | 63 +- templates/index.html | 2586 +----------------- tests/frontend/admin.test.cjs | 213 ++ tests/test_api.py | 276 ++ tests/test_frontend.py | 17 + tests/test_maintenance_scripts.py | 128 + tests/test_rules.py | 81 + tests/test_startup.py | 122 + tests/test_storage.py | 103 + uploads/.gitkeep | 0 uploads/20261023140134_5541.txt | 1 - uploads/20261023140134_9878.png | Bin 75 -> 0 bytes uploads/20261023140303_1976.png | Bin 70 -> 0 bytes uploads/20261023141114_8135.png | Bin 599998 -> 0 bytes uploads/20261023141614_6188.png | Bin 599998 -> 0 bytes uploads/20261023144958_7358.png | Bin 599998 -> 0 bytes uploads/20261023155840_2362.png | Bin 599998 -> 0 bytes uploads/20261027114208_2720.jpg | Bin 129422 -> 0 bytes uploads/20261027114412_4954.jpg | Bin 129422 -> 0 bytes uploads/20261027114811_2396.jpg | Bin 129422 -> 0 bytes uploads/20261027114900_1187.jpg | Bin 129422 -> 0 bytes uploads/20261027115011_7220.jpg | Bin 129422 -> 0 bytes uploads/20261027115035_8148.jpg | Bin 129422 -> 0 bytes uploads/20261027115402_3035.jpg | Bin 129422 -> 0 bytes uploads/20261027115623_6679.jpg | Bin 129422 -> 0 bytes 启动.bat | 6 +- 79 files changed, 5737 insertions(+), 4625 deletions(-) create mode 100644 .env.example create mode 100644 AGENTS.md delete mode 100644 c.json delete mode 100644 check.py delete mode 100644 clean.py create mode 100644 docs/architecture.md rename knowledge.md => docs/knowledge.md (96%) create mode 100644 examples/requests/README.md create mode 100644 examples/requests/human_accept.json create mode 100644 examples/requests/human_request.json create mode 100644 examples/requests/human_request_invalid.json create mode 100644 examples/requests/human_send.json delete mode 100644 human.json create mode 100644 logistics_chat/__init__.py create mode 100644 logistics_chat/ai_client.py create mode 100644 logistics_chat/business_service.py create mode 100644 logistics_chat/chat_service.py create mode 100644 logistics_chat/config.py create mode 100644 logistics_chat/db.py create mode 100644 logistics_chat/file_service.py create mode 100644 logistics_chat/history_service.py create mode 100644 logistics_chat/hooks.py create mode 100644 logistics_chat/human_service.py create mode 100644 logistics_chat/knowledge.py create mode 100644 logistics_chat/pricing.py create mode 100644 logistics_chat/rates_extra.py create mode 100644 logistics_chat/routes/__init__.py create mode 100644 logistics_chat/routes/admin.py create mode 100644 logistics_chat/routes/business.py create mode 100644 logistics_chat/routes/chat.py create mode 100644 logistics_chat/routes/files.py create mode 100644 logistics_chat/routes/history.py create mode 100644 logistics_chat/routes/human.py create mode 100644 logistics_chat/routes/pages.py create mode 100644 logistics_chat/runtime.py create mode 100644 logistics_chat/tracking.py delete mode 100644 s.json create mode 100644 scripts/close_active_sessions.py create mode 100644 scripts/inspect_human_sessions.py create mode 100644 static/css/admin.css create mode 100644 static/css/admin_login.css create mode 100644 static/css/chat.css create mode 100644 static/css/theme.css create mode 100644 static/js/admin.js create mode 100644 static/js/admin_login.js create mode 100644 static/js/chat.js delete mode 100644 t.json create mode 100644 tests/frontend/admin.test.cjs create mode 100644 tests/test_api.py create mode 100644 tests/test_frontend.py create mode 100644 tests/test_maintenance_scripts.py create mode 100644 tests/test_rules.py create mode 100644 tests/test_startup.py create mode 100644 tests/test_storage.py create mode 100644 uploads/.gitkeep delete mode 100644 uploads/20261023140134_5541.txt delete mode 100644 uploads/20261023140134_9878.png delete mode 100644 uploads/20261023140303_1976.png delete mode 100644 uploads/20261023141114_8135.png delete mode 100644 uploads/20261023141614_6188.png delete mode 100644 uploads/20261023144958_7358.png delete mode 100644 uploads/20261023155840_2362.png delete mode 100644 uploads/20261027114208_2720.jpg delete mode 100644 uploads/20261027114412_4954.jpg delete mode 100644 uploads/20261027114811_2396.jpg delete mode 100644 uploads/20261027114900_1187.jpg delete mode 100644 uploads/20261027115011_7220.jpg delete mode 100644 uploads/20261027115035_8148.jpg delete mode 100644 uploads/20261027115402_3035.jpg delete mode 100644 uploads/20261027115623_6679.jpg diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..694285e --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Reference only: am.py and start.bat do not automatically load .env files. +# PowerShell: $env:DEEPSEEK_API_KEY = "" +# Linux/macOS: export DEEPSEEK_API_KEY="" +DEEPSEEK_API_KEY= diff --git a/.gitignore b/.gitignore index 2e3ea75..ecc2069 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ __pycache__/ *.pyo .pytest_cache/ +# 虚拟环境 +.venv/ +venv/ + # 数据库(含聊天记录,不要提交) *.db *.db-journal @@ -17,7 +21,14 @@ __pycache__/ # 环境变量与密钥 .env .env.* -!.env.example # 配置示例(不含 Key),需要提交 +# 配置示例(不含 Key),需要提交 +!.env.example + +# 运行时数据,仅保留上传目录占位文件 +/logs/ +/backups/ +/uploads/* +!/uploads/.gitkeep # 自签证书与私钥(本地演示用,不要提交) cert.pem diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4c2b7cc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# Repository Guidelines + +## Project Structure & Module Navigation + +`am.py` is a compatible CLI/WSGI entry. Business code lives in `logistics_chat/`; templates load page assets from `static/css/` and `static/js/`. See [architecture](docs/architecture.md) for ownership, contracts, and extraction rules. + +| Area | Files and search anchors | +|---|---| +| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` | +| 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` | +| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` | +| Human support | `logistics_chat/human_service.py`; `logistics_chat/routes/human.py` | +| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` | +| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView` | +| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`, `hooks.py`, `runtime.py` in that package | +| CLI / tests | `start.bat`: `probe_python`; `scripts/`, `tests/`, `examples/requests/` | + +## Efficient Search & File Boundaries + +Start with mapped files, then follow callers. Prefer symbols over saved line numbers. `rg --files` includes untracked source; respect ignored runtime data. + +```powershell +rg --files +rg -n -F -e 'calc_freight' -e '/api/human/' logistics_chat static/js +``` + +Routes adapt HTTP; services compose rules, persistence, and model calls. Keep pure rules independent of Flask. Preserve URL/SSE/storage contracts and update navigation after moves. + +## Build, Test, and Development Commands + +Run from the repository root: + +```powershell +py -3 -m venv .venv +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +$env:DEEPSEEK_API_KEY = "" +.\.venv\Scripts\python.exe am.py +.\.venv\Scripts\python.exe -m unittest discover -s tests -v +``` + +Commands use project `.venv`. Optional `start.bat` prefers active `VIRTUAL_ENV`; verify selection with `start.bat --check-python`. No frontend build is required. + +## Coding Style & Naming Conventions + +Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, and JavaScript `camelCase`. Preserve Chinese terminology. No formatter/linter is configured. + +## Testing Guidelines + +Use `tests/test_.py` and `test_`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`. + +With Node.js installed, tests also run `tests/frontend/admin.test.cjs`; no npm dependencies are required. + +## Commit & Pull Request Guidelines + +History uses concise Chinese summaries. Keep commits focused. PRs explain behavior, validation, linked issues, and UI screenshots. + +## Security & Configuration Tips + +Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. `.env` is not loaded automatically. Imports are inert; `create_app` initializes runtime storage unless `INITIALIZE_RUNTIME=False`. Default HOST remains `0.0.0.0`. Missing `agents/orders/tickets` initialization and complete admin authentication remain unresolved. diff --git a/README.md b/README.md index 97b5bbe..41ff95a 100644 --- a/README.md +++ b/README.md @@ -8,28 +8,31 @@ - **知识库优先**:优先使用本地知识库回答,减少模型编造 - **追问兜底**:当前问题没命中关键词时,用上一句提问去检索 - **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细 -- **运单号查询**:发送 8~20 位运单号,返回时间轴轨迹卡片 +- **运单号查询**:发送 8~20 位运单号,返回轨迹卡片;先展示最近节点,可展开全部轨迹 - **历史会话**:会话保存到 SQLite,支持打开、搜索、删除 - **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部 - **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L` - **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试 - **深色模式**:右上角切换,偏好本地保存 - **响应式**:窄屏下侧栏自动变为抽屉 +- **附件预览**:发送前查看文件名或缩略图,可移除待发送附件 +- **客服工作台**:默认展示会话列表与全高聊天区,统计收在「数据概览」,服务资料按需展开;支持会话搜索、多行回复和切换会话保留草稿,常用回复先填入再发送 +- **手机客服接待**:会话列表与聊天整屏切换,返回列表保留草稿;回复框固定在聊天底部,可从列表或聊天打开服务资料 ## 报价数据 -运费试算使用 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)** 中的真实数据,覆盖 8 条渠道: +代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位: -| 渠道 | 21KG+ | 101KG+ | 500KG+ | 2000KG+ | 参考时效 | -|---|---|---|---|---|---| -| 欧洲海运快线 | 12.3 | 10.3 | 10.2 | 10.0 | 开船后 38-45 天 | -| 义乌中欧铁路 | 15.5 | 13.5 | — | 13.5(1000+) | 发车后 36-40 天 | -| 中欧卡航 | 19.4 | 17.4 | 17.4 | 17.3 | 发车后 16-20 天 | -| 欧洲空运 | 39 | 36 | — | 37(71+) | 10-13 天 | -| 英国海运 | 12.8 | 10.9 | — | — | 约 40 天 | -| 英国铁路 | 18.0 | 16.2 | — | — | 25-30 天 | -| 中英卡航 | 21.4 | 19.4 | — | — | 20-22 天 | -| 英国空运 | 48(22+) | 42 | — | 44(51+) | 8-10 天 | +| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 | +|---|---|---| +| 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 | +| 义乌中欧铁路 | 21KG+ → 15.5;100KG+ → 13.5;1000KG+ → 13.5 | 发车后 36-40 天 | +| 中欧卡航 | 21KG+ → 19.4;101KG+ → 17.4;500KG+ → 17.4;2000KG+ → 17.3;4000KG+ → 17.3 | 发车后 16-20 天 | +| 欧洲空运 | 21KG+ → 39;45KG+ → 38;71KG+ → 37;101KG+ → 36 | 10-13 天 | +| 英国海运 | 21KG+ → 12.8;101KG+ → 10.9;301KG+ → 10.9 | 约 40 天 | +| 英国铁路 | 21KG+ → 18.0;101KG+ → 16.2;301KG+ → 16.2 | 25-30 天 | +| 中英卡航 | 21KG+ → 21.4;101KG+ → 19.4;301KG+ → 19.4 | 20-22 天 | +| 英国空运 | 22KG+ → 48;51KG+ → 44;101KG+ → 42 | 8-10 天 | **计费规则**(均来自报价表): @@ -39,9 +42,11 @@ - 铁路、卡航单件计费重不足 13KG 加收超轻费 5 元/kg;空运单件不足 12KG 按 12KG 计费 - 偏远地址 +7 元/kg,单票最低 250 元 -数据定义在 `am.py` 的 `RATES` 字典中,修改价格只需改这里,不需要动计算逻辑。 +基础报价维护在 `logistics_chat/pricing.py` 的 `RATES` 字典中;修改价格时同步知识资料,避免调整计算逻辑。 -### 扩展数据(rates_extra.py) +`calc_freight()` 的 `total` 当前仅为基础运费。超轻费、偏远费及产品附加费以说明或单独结果展示,尚未统一计入 `total`;上述业务规则不能理解为已全部自动结算。 + +### 扩展数据(logistics_chat/rates_extra.py) 从同一份报价表提取、目前已支持: @@ -59,162 +64,169 @@ > ⚠️ 报价表含商业价格与联系人信息,公开发布前请脱敏或替换为示例数据。 -## 运行环境 +## 运行环境与启动 -- Python 3.9 及以上 -- Windows / macOS / Linux +项目使用 Python、Flask、SQLite 和 DeepSeek API,无需前端构建。建议使用独立虚拟环境;所有应用启动命令均在项目根目录执行。 -## 快速启动(Windows) - -双击 `start.bat` 即可,脚本会自动检查端口占用、API Key 和依赖。 - -## 安装与启动 - -### 1. 安装依赖 - -```bash -pip install -r requirements.txt -``` - -### 2. 配置 API Key - -Key 不写进代码,通过系统环境变量提供。 - -Windows PowerShell: +### Windows ```powershell -setx DEEPSEEK_API_KEY "sk-你的key" +py -3 -m venv .venv +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +$env:DEEPSEEK_API_KEY = "" +.\.venv\Scripts\python.exe am.py ``` -设置后需要**重新打开终端**才生效,可用下面的命令确认: +上述命令固定使用项目 `.venv`,不受其他已激活环境影响。可选的一键入口 `start.bat` 按已激活的 `VIRTUAL_ENV`、项目 `.venv`、`py -3`、PATH 中的 `python` 顺序逐个验证 Python 3。使用它之前先退出不需要的旧环境,或检查诊断结果是否指向预期解释器。`启动.bat` 调用同一入口: ```powershell -echo $env:DEEPSEEK_API_KEY +.\start.bat ``` -Linux / macOS: +仅检查并打印选中的解释器,不安装依赖或启动服务: + +```powershell +.\start.bat --check-python +``` + +浏览器访问 http://127.0.0.1:5000;客服登录页为 http://127.0.0.1:5000/admin。 + +### Linux / macOS ```bash -export DEEPSEEK_API_KEY="sk-你的key" +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +export DEEPSEEK_API_KEY="" +.venv/bin/python am.py ``` -Key 申请地址:https://platform.deepseek.com/api_keys +### 配置说明 -也可以参考项目根目录的 **.env.example**(不含真实 Key 的配置示例文件),其中列出了需要设置的环境变量及说明。 - -### 3. 启动服务 - -```bash -cd E:\wuliu_chat -python am.py -``` - -### 4. 打开页面 - -浏览器访问 http://127.0.0.1:5000 - -## 测试 - -### 单元测试 - -```bash -pytest -v -``` - -共 **52 个用例**,覆盖运费意图解析、运单号识别、阶梯价计算、卡派按方、附加费识别、岛屿邮编、卡片转换等。 -测试在临时目录中运行,不会改动 `history.db`。 - -### 回答长度控制 - -- 中文回答:120~350 字;英文回答:120~700 字 -- 超过上限时由代码自动压缩一次(保留全部数字),仍超标再压一次 -- 提示词之外的第二道保险,确保字数可控 - -### 场景测试 - -`测试记录.md` 记录了 **29 个业务场景**的输入、预期结果、首次结果、修复动作与复测结果, -其中 26 项通过或已修复,3 项列为未解决/待验证(超长输入、并发提问、无答案兜底)。 +- [`.env.example`](.env.example) 仅列出配置格式,当前程序和启动脚本**不会自动读取 `.env`**;请在环境变量中配置密钥。 +- PowerShell 的 `$env:DEEPSEEK_API_KEY` 只影响当前终端及其子进程。双击启动脚本时,需要提前配置系统环境变量并重新打开窗口。 +- 密钥申请或更换可在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 完成;不要把密钥写入脚本、提交到 Git,或打印到日志。 +- 当前服务监听 `0.0.0.0:5000`,局域网设备可能访问。仅供本机开发时,将 `logistics_chat/config.py` 中默认的 `HOST` 设置为 `"127.0.0.1"`。 +- 历史启动脚本曾提交明文密钥。当前文件已移除该配置,但 Git 历史仍保留旧内容,应在平台作废旧密钥并通过环境变量配置新密钥。 ## 目录结构 -``` -wuliu_chat/ -├── am.py 主程序(路由、知识库、对话逻辑、运单查询、运费试算) -├── rates_extra.py 报价表扩展数据(卡派按方、附加费、岛屿邮编) -├── test_am.py 单元测试(52 个用例) -├── start.bat Windows 一键启动脚本 -├── requirements.txt 依赖清单 -├── README.md 说明文档 -├── knowledge.md 知识资料(由 knowledge_list 导出) -├── 测试记录.md 场景测试记录(29 个场景) -├── .gitignore Git 忽略规则 -├── .env.example 配置示例(不含 Key) -├── history.db SQLite 数据库(会话与消息) -├── static/ 静态资源 -│ └── fa.jpg 网站图标 -├── templates/ -│ └── index.html 前端页面 -└── .vscode/ - └── launch.json VS Code 调试配置 +```text +baiyue-logistics-chat/ +├── am.py CLI/WSGI 兼容入口 +├── rates_extra.py 扩展数据旧导入路径的兼容转发 +├── logistics_chat/ +│ ├── __init__.py create_app 应用工厂 +│ ├── config.py 环境与绝对路径配置 +│ ├── db.py SQLite、历史持久化、备份 +│ ├── knowledge.py 知识与关键词检索 +│ ├── pricing.py 运费解析与计算 +│ ├── rates_extra.py 扩展报价唯一数据源 +│ ├── tracking.py 模拟轨迹与卡片协议 +│ ├── ai_client.py 模型 HTTP、压缩和追问 +│ ├── chat_service.py 问答与 SSE 编排 +│ ├── history_service.py 会话 CRUD +│ ├── human_service.py 人工会话 +│ ├── business_service.py 登录、统计、订单、留言、导出 +│ ├── file_service.py 附件与清理 +│ ├── hooks.py 限流和缓存头 +│ ├── runtime.py 服务启动 +│ └── routes/ HTTP Blueprints +├── templates/ 客户、登录、客服工作台 HTML +├── static/ +│ ├── css/ theme.css 共享主题 + chat/admin/admin_login 页面样式 +│ ├── js/ 对应经典页面脚本 +│ └── fa.jpg +├── scripts/ 人工会话检查/关闭 CLI +├── tests/ 规则、存储、HTTP/SSE、启动与维护回归 +├── docs/ architecture.md、knowledge.md +├── examples/requests/ 人工接口示例 +├── uploads/.gitkeep 本地附件不纳入版本控制 +├── start.bat 可选 Windows 启动检查 +├── 启动.bat 兼容入口,调用 start.bat +├── requirements.txt +├── .env.example +├── .gitignore +├── AGENTS.md +└── README.md ``` -> `kefu.py`、`SQLite.py` 为早期遗留文件,不影响运行,可安全删除。 +运行后还会生成 `history.db`、`logs/` 和 `backups/`,这些运行数据以及虚拟环境均由 Git 忽略。已经上传的本地文件在本次目录整理中保留。 + +## 测试 + +先安装项目依赖,再使用同一个项目虚拟环境运行: + +```powershell +.\.venv\Scripts\python.exe -m pytest -v +``` + +也可执行 `.\.venv\Scripts\python.exe -m unittest discover -s tests -v`。Linux/macOS 将解释器替换为 `.venv/bin/python`。纯规则和维护 CLI 本身仅依赖标准库,整套业务测试还需要 Flask 等项目依赖。 + +安装 Node.js 后,Python 测试还会运行 `tests/frontend/admin.test.cjs`,验证发送失败保留草稿、重复点击、切换会话、输入法及手机抽屉;也可单独执行 `node --test tests/frontend/admin.test.cjs`,无需安装 npm 依赖。未安装 Node.js 时该项明确跳过。 + +覆盖范围包括运费边界与单位换算、知识检索、运单识别、旧 SQLite schema 迁移、事务回滚、附件、人工状态、历史、CSV、SSE 压缩/追问/异常/取消,以及 Windows 解释器选择。Windows 启动测试在其他系统上跳过,三个页面的外置资源也由接口测试检查。 + +模型请求使用模拟响应,数据库和附件使用临时目录;这不代表完成真实 DeepSeek 或真实物流接口联调。旧文档提及的原始 52 个业务测试和 29 个场景记录未随仓库提供,当前结果以实际测试输出为准。 + +## 维护与调试 + +在根目录执行: + +```powershell +py -3 scripts/inspect_human_sessions.py +py -3 scripts/close_active_sessions.py +``` + +两个脚本默认定位到仓库根目录的 `history.db`,不依赖调用时的工作目录,也可用 `--db "D:\data\history.db"` 指定已有数据库。检查脚本只读;关闭脚本将**所有 active 人工会话**标记为 closed,保留 waiting 会话和聊天消息。数据库不存在时会报错退出,不会误建空库。 + +人工客服接口请求示例见 [examples/requests/README.md](examples/requests/README.md)。知识资料见 [docs/knowledge.md](docs/knowledge.md),实际知识内容维护在 `logistics_chat/knowledge.py` 的 `knowledge_list` 中。 + +模块定位、文件归属和后续拆分规范见 [docs/architecture.md](docs/architecture.md)。该文档与当前已拆分的目录保持同步。 ## 主要接口 | 方法 | 路径 | 说明 | |---|---|---| -| GET | `/` | 聊天页面 | -| GET | `/stream_chat?msg=&conv_id=` | 流式对话(SSE) | -| POST | `/api/new_chat` | 新建会话,返回 `{"id": 会话id}` | +| GET | `/` | 客户聊天页面 | +| GET / POST | `/stream_chat` | SSE 流式对话;POST JSON 接收 `msg`、`conv_id` | +| POST | `/api/new_chat` | 新建会话,返回 `id` | | GET | `/api/get_history` | 会话列表 | -| GET | `/api/get_messages?conv_id=` | 单个会话的全部消息 | -| DELETE | `/api/del_history?id=` | 删除会话(级联删消息) | -| DELETE | `/api/clear_all` | 清空全部历史 | +| GET | `/api/get_messages?conv_id=` | 单个会话的消息 | +| DELETE | `/api/del_history?id=` | 删除单个会话和消息 | +| DELETE | `/api/clear_all` | 清空聊天历史 | +| POST | `/api/upload` | 上传图片、PDF、TXT、CSV,最大 10MB | +| POST | `/api/human/request` | 申请人工客服 | +| POST | `/api/human/accept` | 客服接待 | +| POST | `/api/human/send` | 发送人工消息 | +| GET | `/api/human/poll` | 轮询人工消息 | +| POST | `/api/human/close` | 关闭人工会话 | +| GET | `/admin` | 客服登录页面 | +| POST | `/admin/login` | 校验客服账号密码 | +| GET | `/admin/dashboard` | 客服工作台 | +| GET | `/api/export_chat` | 导出聊天记录为 CSV | -### 流式事件格式 +SSE 每帧为一条 JSON,主要事件包括: -`/stream_chat` 返回 Server-Sent Events,每帧是一条 JSON: - -``` -data: {"t": "正文片段"} 正常回答片段 -data: {"e": "错误提示"} 出错时 -data: {"card": {...}} 运单轨迹卡片 -data: {"d": 1} 正常结束 +```text +data: {"t": "正文片段"} +data: {"e": "错误提示"} +data: {"card": {...}} +data: {"replace": "精简后的全文"} +data: {"suggest": ["相关追问"]} +data: {"d": 1} ``` -## 数据库 +## 数据库与运行数据 -SQLite,文件为 `history.db`,首次启动自动创建。 +首次启动会自动创建 `history.db` 中的 `chat_history`、`messages`、`feedback`、`human_sessions` 和 `human_messages` 五张表。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。 -- `chat_history`:会话(id、title、ctime、utime) -- `messages`:消息(id、conv_id、role、content、ctime) +普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。 -运单卡片以 `__CARD__{json}` 形式保存在 `messages.content`,前端识别后重新渲染。 - -## 常见问题 - -**页面报"服务未配置 API Key"** -环境变量没读到。确认变量名是 `DEEPSEEK_API_KEY`,并**重新打开终端**后再启动。 - -**页面报"API Key 无效或已过期"** -Key 已作废,去平台重新生成一把,再用 `setx` 覆盖。 - -**端口 5000 被占用** -```powershell -netstat -ano | findstr ":5000 " -taskkill /PID <进程号> /F -``` -确认只有一行 `LISTENING`,避免两个进程同时抢端口。 - -**改了 am.py 没生效** -Flask 启动时加载代码,改后端必须重启服务;只改 `index.html` 则刷新页面即可。 - -## 说明 - -- 运单轨迹当前为**模拟数据**(按单号固定生成),接真实接口只需修改 `am.py` 中的 `build_track()`。 -- 服务使用 Waitress 多线程启动(8 个线程),适合内网小范围使用;面向公网仍需反向代理与 HTTPS。 -- 默认只监听 `127.0.0.1`,如需局域网访问,把 `am.py` 中的 `HOST` 改为 `"0.0.0.0"`。 -- 运费计费方式为**阶梯单价 × 计费重量**,价格与时效来自炜易达报价表。报价更新时只需修改 `RATES`;知识库文案在 `knowledge_list` 中,两者建议同步更新。 -- 材积按 ÷6000 计算(1CBM = 167KG),与部分渠道 ÷5000 的口径不同,修改时注意核对报价表。 +## 当前限制 +- 运单轨迹为模拟数据,尚未接入真实物流查询接口。 +- `agents`、`orders`、`tickets` 被接口使用,但初始化代码尚未创建这三张表;新环境下客服登录、手机号订单查询、离线留言等功能需要先补齐数据库初始化。 +- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。 +- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。 +- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。 +- 服务优先使用 Waitress 的 8 个线程;缺少 Waitress 时会回退到 Flask 开发服务器。公网部署前还需完善鉴权及部署配置。 diff --git a/am.py b/am.py index 7d6be69..e1021e0 100644 --- a/am.py +++ b/am.py @@ -1,1561 +1,30 @@ -from flask import (Flask, render_template, request, jsonify, Response, - stream_with_context, send_from_directory) -import os -import math -import shutil -import requests -import json -import time -import random -import re -import sqlite3 -import logging -from datetime import datetime, timedelta +"""Compatible command entry point; imports do not initialize runtime data.""" -# 日志写到 logs/ 文件夹 -LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") -os.makedirs(LOG_DIR, exist_ok=True) -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[ - logging.FileHandler(os.path.join(LOG_DIR, "app.log"), encoding="utf-8"), - logging.StreamHandler() - ] +from logistics_chat import create_app +from logistics_chat.knowledge import knowledge_list, search_knowledge +from logistics_chat.pricing import ( + RATES, CBM_CHANNELS, parse_dims, parse_shipping_intent, detect_dim_unit, + dims_to_cm, pick_rate, calc_warehouse_card, detect_surcharge, check_island, + surcharge_reply, calc_freight, freight_reply, ) - -try: - import rates_extra as RX # 报价表扩展数据(卡派按方、海外仓、附加费、岛屿邮编) -except Exception: # 缺失时不影响主流程 - RX = None - -app = Flask(__name__) -# Key 不写进代码,从系统环境变量读取(setx DEEPSEEK_API_KEY "sk-...") -DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip() -URL = "https://api.deepseek.com/v1/chat/completions" -MODEL_NAME = "deepseek-chat" - -# ===== 简单限流:每个 IP 每分钟最多 60 次(真人够用,挡脚本狂刷)===== -from collections import defaultdict -RATE_LIMIT_PER_MIN = 60 -_hits = defaultdict(list) - - -@app.before_request -def rate_limit(): - p = request.path - if p.startswith("/static") or p.startswith("/api/human/"): - return None - ip = request.remote_addr or "unknown" - now = time.time() - arr = _hits[ip] - while arr and now - arr[0] > 60: - arr.pop(0) - if len(arr) >= RATE_LIMIT_PER_MIN: - return jsonify({"e": "请求太频繁,请稍后几秒再试", "d": 1}), 429 - arr.append(now) - return None - - -@app.after_request -def no_cache_html(resp): - """页面不走浏览器缓存,改完代码刷新就能看到;图片等静态资源不受影响。""" - if resp.mimetype in ("text/html", "text/event-stream"): - resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" - resp.headers["Pragma"] = "no-cache" - resp.headers["Expires"] = "0" - return resp - - -knowledge_list = [ - { - "keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家"], - "text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓。" - }, - { - "keys":["海运","船运","海派","海运快线","樱花快线"], - "text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。" - }, - { - "keys":["铁路","中欧班列","班列","义乌铁路","火车"], - "text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。" - }, - { - "keys":["卡航","卡车","卡派"], - "text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。" - }, - { - "keys":["空运","空派","航空","欧洲空运"], - "text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。" - }, - { - "keys":["英国海运","英国铁路","中英卡航","英国专线"], - "text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。" - }, - { - "keys":["英国空运","英国","UK","伦敦"], - "text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。" - }, - { - "keys":["报价","价格","费用","计费","怎么收费","计费重量","多少钱","运费","单价"], - "text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。" - }, - { - "keys":["时效","多久","要几天","多长时间"], - "text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。" - }, - { - "keys":["揽收","上门取件","上门收货","交仓","交货"], - "text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。" - }, - { - "keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","交仓地址","送仓"], - "text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。" - }, - { - "keys":["理赔","货损","丢件","赔付","破损","压坏","索赔"], - "text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。" - }, - { - "keys":["超轻费","计费重量不足","最低计费"], - "text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。" - }, - { - "keys":["偏远","偏远费","偏远地址"], - "text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。" - }, - { - "keys":["超重","超长","超大","尺寸限制"], - "text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。" - }, - { - "keys":["敏感","带电","电池","液体","粉末","禁品","不能寄","违禁品","拒收","拒接"], - "text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。" - }, - { - "keys":["清关","递延","VAT","双清","包税","DDP"], - "text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。" - }, - { - "keys":["报关","单独报关","报关费"], - "text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。" - }, - { - "keys":["亚马逊","FBA","海外仓","仓库配送","卡派"], - "text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。" - }, - { - "keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"], - "text":"人工客服在线时间:周一至周五 9:00—18:00。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。" - }, - { - "keys":["电话","联系方式","怎么联系","联系你们","微信"], - "text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。" - }, - { - "keys":["退件","退回","退货","退运","改地址"], - "text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。" - } -] -DB = "history.db" -MAX_CONTEXT = 4 # 每次带给模型的历史消息条数 - - -def now_str(): - return datetime.now().strftime("%Y-%m-%d %H:%M") - - -def init_db(): - conn = sqlite3.connect(DB) - cur = conn.cursor() - cur.execute(''' - CREATE TABLE IF NOT EXISTS chat_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - ctime TEXT NOT NULL - ) - ''') - # 老库没有 utime 列:补上,用来按"最近活跃"排序 - cols = [r[1] for r in cur.execute("PRAGMA table_info(chat_history)").fetchall()] - if "utime" not in cols: - cur.execute("ALTER TABLE chat_history ADD COLUMN utime TEXT") - cur.execute("UPDATE chat_history SET utime = ctime WHERE utime IS NULL") - - # 消息表:一条会话对应多条消息 - cur.execute(''' - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - conv_id INTEGER NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - ctime TEXT NOT NULL - ) - ''') - cur.execute("CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id)") - - # 👍/👎 反馈表 - cur.execute(''' - CREATE TABLE IF NOT EXISTS feedback ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - question TEXT, - answer TEXT, - vote TEXT, - ctime TEXT - ) - ''') - - # 人工客服会话表 - cur.execute(''' - CREATE TABLE IF NOT EXISTS human_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - conv_id INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'waiting', - ctime TEXT NOT NULL - ) - ''') - cur.execute(''' - CREATE TABLE IF NOT EXISTS human_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - side TEXT NOT NULL, - content TEXT NOT NULL, - ctime TEXT NOT NULL - ) - ''') - # 补 read_at 列 - hm_cols = [r[1] for r in cur.execute("PRAGMA table_info(human_messages)").fetchall()] - if "read_at" not in hm_cols: - cur.execute("ALTER TABLE human_messages ADD COLUMN read_at TEXT") - - # 老数据只有标题,补一条 user 消息,点开还能接着聊 - 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()) - ) - - conn.commit() - conn.close() - - -init_db() - - -def backup_db(): - """启动时把数据库备份到 backups/,文件名带日期,只保留最近 7 天""" - try: - bdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups") - os.makedirs(bdir, exist_ok=True) - today = datetime.now().strftime("%Y%m%d") - dst = os.path.join(bdir, f"history_{today}.db") - if not os.path.exists(dst): - shutil.copy2(DB, dst) - # 清理 7 天前的旧备份 - cutoff = datetime.now() - timedelta(days=7) - for f in os.listdir(bdir): - m = re.match(r"history_(\d{8})\.db$", f) - if not m: - continue - try: - if datetime.strptime(m.group(1), "%Y%m%d") < cutoff: - os.remove(os.path.join(bdir, f)) - except Exception: - pass - except Exception as e: - print(f"[数据库备份失败] {e}") - - -def cleanup_uploads(): - """删除 uploads/ 里 7 天前的图片""" - try: - udir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads") - if not os.path.isdir(udir): - return - cutoff = time.time() - 7 * 86400 - for f in os.listdir(udir): - fp = os.path.join(udir, f) - if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff: - os.remove(fp) - except Exception as e: - print(f"[清理上传文件失败] {e}") - - -def cleanup_old_conversations(): - """自动清理 30 天前的旧对话,防止数据库无限膨胀""" - try: - conn = sqlite3.connect(DB) - c = conn.cursor() - cutoff = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") - rows = c.execute("SELECT id FROM chat_history WHERE utime < ?", (cutoff,)).fetchall() - ids = [r[0] for r in rows] - if not ids: - return - for cid in ids: - c.execute("DELETE FROM messages WHERE conv_id=?", (cid,)) - c.execute(f"DELETE FROM chat_history WHERE id IN ({','.join('?'*len(ids))})", ids) - conn.commit() - conn.close() - print(f"[自动清理] 已删除 {len(ids)} 条 30 天前的旧对话") - except Exception as e: - print(f"[自动清理失败] {e}") - - -backup_db() -cleanup_uploads() -# 自动清理已关闭,不删旧对话 - - -def make_title(content): - """生成会话标题:优先用文字内容,只有附件时显示附件数量""" - # 去掉所有 "[附件] xxx" 行 - lines = [l for l in content.split("\n") if not l.strip().startswith("[附件]")] - text = " ".join(lines).strip() - if text and text != "(发送了附件)" and text != "请帮我看看这个附件": - return text[:24] - - # 只有附件:显示附件数量,不显示文件名 - n = len([l for l in content.split("\n") if l.strip().startswith("[附件]")]) - if n == 1: - return "📎 已上传附件" - if n > 1: - return f"📎 {n}个附件" - return content[:24] - - -def save_message(conv_id, role, content): - """把一条消息写进会话;用户消息做幂等处理,重试不会重复入库。""" - if not conv_id or not content: - return - conn = sqlite3.connect(DB) - cur = conn.cursor() - if role == "user": - last = cur.execute( - "SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 1", - (conv_id,) - ).fetchone() - 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()) - ) - 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] == "新对话"): - cur.execute("UPDATE chat_history SET title=? WHERE id=?", - (make_title(content), conv_id)) - cur.execute("UPDATE chat_history SET utime=? WHERE id=?", (now_str(), conv_id)) - conn.commit() - conn.close() - - -def last_user_question(conv_id): - """上一条用户提问(必须在当前这句入库之前调用)""" - if not conv_id: - return None - conn = sqlite3.connect(DB) - row = conn.execute( - "SELECT content FROM messages WHERE conv_id=? AND role='user' " - "ORDER BY id DESC LIMIT 1", - (conv_id,) - ).fetchone() - conn.close() - return row[0] if row else None - - -def card_to_text(content): - """把存库的卡片标记转成人话,避免原始 JSON 进入模型上下文""" - if not content.startswith(CARD_PREFIX): - return content - try: - card = json.loads(content[len(CARD_PREFIX):]) - return f"(已为运单 {card.get('no', '')} 查询过轨迹,当前状态:{card.get('status', '')})" - except Exception: - return "(已为该运单查询过轨迹)" - - -def load_context(conv_id, msg): - """取最近 MAX_CONTEXT 条消息,拼成模型要的 messages(不含 system)。""" - if not conv_id: - return [{"role": "user", "content": msg}] - conn = sqlite3.connect(DB) - rows = conn.execute( - "SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT ?", - (conv_id, MAX_CONTEXT) - ).fetchall() - conn.close() - - rows.reverse() - history = [{"role": r, "content": card_to_text(c)} for r, c in rows] - # 本次提问已经落库的话,不要再追加一遍 - if not history or history[-1]["role"] != "user" or history[-1]["content"] != msg: - history.append({"role": "user", "content": msg}) - return history - -def search_knowledge(question): - """按关键词命中打分:命中的关键词越长越具体,得分越高""" - q = question.lower() - best, best_score = None, 0 - for item in knowledge_list: - for k in item["keys"]: - if k.lower() in q: - score = len(k) # 长关键词更具体,优先 - if score > best_score: - best, best_score = item["text"], score - return best - -def sse(payload): - """统一的事件格式:data: {"t":正文} / {"e":错误} / {"card":卡片} / {"d":1结束} - JSON 编码可以避免正文里的换行把 SSE 帧撑断。""" - return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n" - - -CARD_PREFIX = "__CARD__" # 存库时标记"这条是轨迹卡片" - - -def is_tracking_no(text): - """判断一句话是不是"整句运单号" - - 规则(逐条收紧,避免误判): - 1. 去掉空格和横杠后,长度 8~20 位,且只能由字母数字组成 - 2. 至少包含 3 个数字(英文句子通常只有 1 个数字) - 3. 字母数量不能多于数字("i want to go 1t how money" 这类会被排除) - 纯数字单号(如 15252125)全部满足上述条件。 - """ - # 日期格式(如 2026-10-23、2026/10/23)不算运单号 - if re.fullmatch(r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}", text.strip()): - return False - t = re.sub(r"[\s\-]", "", text) - if not (8 <= len(t) <= 20): - return False - if not re.fullmatch(r"[A-Za-z0-9]+", t): - return False - digits = sum(c.isdigit() for c in t) - letters = sum(c.isalpha() for c in t) - if digits < 3: - return False - if letters > digits: - return False - return True - - -def extract_tracking_no(text): - """从一句话里提取运单号:先看整句是不是,再从句子里找最长的字母数字串""" - t = text.strip() - if is_tracking_no(t): - return t - # 找句子里最长的 8~20 位字母数字连续串 - candidates = re.findall(r"[A-Za-z0-9]{8,20}", t) - best = None - for c in candidates: - if is_tracking_no(c): - if best is None or len(c) > len(best): - best = c - return best - - -def build_track(no): - """生成固定的模拟轨迹:同一个单号每次查都一样""" - rnd = random.Random(no) - steps = [ - ("已揽收", "义乌仓"), - ("出口清关放行", "义乌/宁波口岸"), - ("已发车", "中欧班列/卡航 · 阿拉山口"), - ("到达欧洲中转仓", "波兰 · 华沙"), - ("欧盟清关放行", "德国 · 杜伊斯堡"), - ("派送中", "收件人所在城市"), - ] - gaps = [rnd.randint(6, 40) for _ in steps] # 相邻节点间隔(小时) - t = datetime.now() - timedelta(hours=sum(gaps)) - nodes = [] - for (text, place), gap in zip(steps, gaps): - t = t + timedelta(hours=gap) - nodes.append({"time": t.strftime("%Y-%m-%d %H:%M"), - "text": text, "place": place}) - nodes.reverse() # 最新的排最上面 - return {"no": no, "status": nodes[0]["text"], "nodes": nodes} - - -# ===== 报价数据(来源:炜易达 VIP 10月15日 9点执行报价表)===== -# tiers: [(最低计费重量, 单价 RMB/kg), ...],按从高到低匹配 -RATES = { - "sea": { - "name": "欧洲海运快线(双清/递延)", - "tiers": [(2000, 10.0), (500, 10.2), (101, 10.3), (21, 12.3)], - "divisor": 6000, - "min_kg": 21, - "time": "开船后 38-45 天提取", - "min_pcs_kg": None, - "remote": 7, - "note": "德国 UPS/DHL/DPD/CWE 统配;欧洲递延 -1.7元/kg;樱花快线 +0.3元/kg;因查验/罢工/节假日顺延", - }, - "rail": { - "name": "义乌-中欧铁路专线", - "tiers": [(1000, 13.5), (100, 13.5), (21, 15.5)], - "divisor": 6000, - "min_kg": 21, - "time": "发车后 36-40 天提取", - "min_pcs_kg": 13, - "light_fee": 5, - "remote": 7, - "note": "义乌装柜;VAT 递延 -1元/kg;单件计费重不足 13kg 按超轻费加收", - }, - "truck": { - "name": "中欧卡航", - "tiers": [(4000, 17.3), (2000, 17.3), (500, 17.4), (101, 17.4), (21, 19.4)], - "divisor": 6000, - "min_kg": 21, - "time": "发车后欧盟 16-20 天提取", - "min_pcs_kg": 13, - "light_fee": 5, - "remote": 7, - "note": "递延 -1.5元/kg;纯纺织品 +5元/kg;普卡时效 18-22 天", - }, - "air": { - "name": "欧洲空运专线(空派)", - "tiers": [(101, 36), (71, 37), (45, 38), (21, 39)], - "divisor": 6000, - "min_kg": 21, - "time": "10-13 天左右提取", - "min_pcs_kg": 12, - "light_fee": None, - "remote": 7, - "note": "一票多件单件不足 12kg 按 12kg 计费;快线 8-13 天;直飞快线 12-15 天", - }, - "sea_uk": { - "name": "英国海运(双清)", - "tiers": [(301, 10.9), (101, 10.9), (21, 12.8)], - "divisor": 6000, - "min_kg": 21, - "time": "参考时效 40 天提取", - "remote": None, - "note": "VAT自税/递延 10.0/8.1 元/kg;按KG卡派 12.2/10.3;限时达 13.5/11.6;北极航线 25-28 天(义乌装)", - }, - "rail_uk": { - "name": "英国铁路(双清)", - "tiers": [(301, 16.2), (101, 16.2), (21, 18.0)], - "divisor": 6000, - "min_kg": 21, - "time": "发车后 25-30 天提取", - "remote": None, - "note": "VAT自税/递延 15/13.2 元/kg;限时达 19.2/17.4(38天);按KG卡派 17.3/15.5", - }, - "truck_uk": { - "name": "中英卡航(双清)", - "tiers": [(301, 19.4), (101, 19.4), (21, 21.4)], - "divisor": 6000, - "min_kg": 21, - "time": "发车后 20-22 天提取", - "remote": None, - "note": "VAT自税/递延 18.4/16.4 元/kg;卡派 20.9/18.9;单件不超30kg,尺寸≤98×68×59cm", - }, - "air_uk": { - "name": "英国空运专线(双清)", - "tiers": [(101, 42), (51, 44), (22, 48)], - "divisor": 6000, - "min_kg": 22, - "time": "8-10 个自然日提取", - "min_pcs_kg": 12, - "light_fee": None, - "remote": None, - "note": "不包税 VAT 自税/递延 44/40/38;带电 47.5/42.5/41.5;拒收北爱尔兰/苏格兰高地偏远地址", - }, -} - - -def parse_dims(t): - """解析 长x宽x高,例如 120*100*100 / 100x80x60 / 120×100×100 - - 用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连, - 这样 "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 - - -def parse_shipping_intent(text): - """从话里抠出:重量(kg)、渠道、长宽高(cm)""" - t = text.replace(" ", "") - intent = {"weight": None, "channel": None, "dims": None} - - # 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量) - intent["dims"] = parse_dims(text) - if not intent["dims"]: - # 支持“长100宽80高60” - dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t) - if dl: - intent["dims"] = [float(x) for x in dl.groups()] - - # 重量:带单位的数字。去掉空格后在原始文本上匹配, - # 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量 - m = re.search(r"(\d+(?:\.\d+)?)\s*(吨|kg|KG|公斤|千克|tons?|kilo(?:grams?)?|lbs?)", text, re.I) - if m: - before = text[:m.start()].rstrip() - adjacent_sep = bool(before) and before[-1] in "*xX×" - if not adjacent_sep: - n = float(m.group(1)) - unit_w = m.group(2).lower() - if unit_w in ("吨", "ton", "tons"): - intent["weight"] = n * 1000 - elif unit_w.startswith("lb"): - intent["weight"] = round(n * 0.4536, 2) # 磅转公斤 - else: - intent["weight"] = n - intent["weight_raw"] = m.group(0).replace(" ", "") - - # 渠道:空运 / 铁路 / 卡航 / 海运(按优先级匹配) - if re.search(r"空运|空派|航空", t) or re.search(r"\bair\b|air ?freight|air ?cargo", text, re.I): - intent["channel"] = "air_uk" if re.search(r"英国|UK|伦敦|英格兰", text, re.I) else "air" - elif re.search(r"铁路|班列|火车", t) or re.search(r"\brail\b|train", text, re.I): - intent["channel"] = "rail" - elif re.search(r"卡航|卡车|卡派", t) or re.search(r"\btruck\b|road", text, re.I): - intent["channel"] = "truck" - elif re.search(r"海运|海派|船运|快船|海运快线|樱花快线", t) or re.search(r"\bsea\b|ocean|shipping", text, re.I): - intent["channel"] = "sea" - return intent - - -def detect_dim_unit(text): - """识别尺寸单位:cm(默认)/ m / mm""" - low = text.lower() - if re.search(r"毫米|\d\s*mm", low): - return "mm" - if re.search(r"米", low) and not re.search(r"厘米", low): - return "m" - if re.search(r"\d\s*cm|\d\s*厘米", low): - return "cm" - if re.search(r"\d\s*m", low): # 注意:cm 里也含 m,所以放在 cm 之后判断 - return "m" - return "cm" - - -def dims_to_cm(dims, unit): - """统一换算成厘米,体积重公式按 cm 计算""" - if unit == "m": - return [d * 100 for d in dims] - if unit == "mm": - return [d / 10 for d in dims] - return list(dims) - - -def pick_rate(weight_kg, tiers): - """从阶梯价里选档:tiers 为 [(最低重量, 单价), ...],从高到低匹配""" - for min_w, price in tiers: - if weight_kg >= min_w: - return price, min_w - return tiers[-1][1], tiers[-1][0] - - -CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运") - - -def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None): - """海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档 - - 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 not key and country: - for k in keys: - if ("-" + country) in k.upper(): - key = k - break - if not key: - key = keys[0] - row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]] - p = row.get(channel) or row["海运"] - price = p["cbm1"] if cbm >= 1 else p["cbm05"] - if price is None: - return 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 - else: - stores = stores[:1] - note = "报价表中的仓点分组:" + "、".join(stores) - return { - "warehouse": "\n".join(stores), "stores": stores, - "channel": channel, "cbm": cbm, "note": note, - "price_cbm": price, "tier": tier, - "total": round(cbm * price, 2), - "formula": f"{cbm:g} CBM × {price:,.0f} 元/CBM = {cbm * price:,.0f} 元", - } - - -def detect_surcharge(text): - """识别产品类型,返回报价表中的附加费条目""" - if not RX: - return None - best = None - for product, fee in RX.PRODUCT_SURCHARGES: - # 用产品名里的前几个字做匹配(如“手机”“电池”“玩具”) - for seg in re.split(r"[,,、\s()()]", product): - seg = seg.strip() - if len(seg) >= 2 and seg in text: - if best is None or len(seg) > len(best[2]): - best = (product, fee, seg) - if best: - return best[0], best[1] - return None - - -def check_island(postcode): - """按邮编判断是否属于 DPD 岛屿偏远地区(附加费 35 欧/件)""" - if not RX: - return None - pc = re.sub(r"[^0-9]", "", str(postcode)) - if not pc: - return None - for country, info in RX.DPD_ISLAND_POSTCODES.items(): - seg = info["text"] - # 直接匹配的 4~5 位邮编 - for m in re.finditer(r"\d{4,5}", seg): - if pc.startswith(m.group(0)): - return country, info["code"], m.group(0) - # 区间形式 07000–07999 - for m in re.finditer(r"(\d{4,5})\s*[–\-]\s*(\d{4,5})", seg): - lo, hi = int(m.group(1)), int(m.group(2)) - if len(m.group(1)) == len(pc) and lo <= int(pc) <= hi: - return country, info["code"], m.group(0) - return None - - -def surcharge_reply(product, fee, weight_kg=None): - """附加费说明(能算就给出金额)""" - txt = f"【产品附加费】{product}:{fee}" - if weight_kg and "/KG" in fee.upper(): - m = re.search(r"\+?(\d+(?:\.\d+)?)", fee) - if m: - extra = float(m.group(1)) * weight_kg - txt += f"\n 按 {weight_kg:g} kg 计:约 {extra:,.0f} 元(以最终计费重为准)" - return txt - - -def calc_freight(weight_kg, channel, dims=None, unit="cm"): - """按报价表规则计算运费(数字全部由代码算) - - 报价表规则: - - 阶梯单价:按总重量落入的档位取价 - - 材积:长×宽×高(cm) ÷ 6000,与实重取大值作为计费重量 - - 单件最低计费重 / 超轻费:见 RATES 里的 min_pcs_kg、light_fee - - 偏远地址:+7元/kg(不含油),最低 250 元/票 - """ - r = RATES[channel] - cm = dims_to_cm(dims, unit) if dims else None - divisor = r.get("divisor", 6000) - vol = round(cm[0] * cm[1] * cm[2] / divisor, 2) if cm else None - real = weight_kg or 0 - charge = round(max(real, vol) if vol else real, 2) # 实重与体积重取大 - min_kg = r.get("min_kg") or 0 - charge = max(charge, min_kg) # 报价表起收档 - price, tier = pick_rate(charge, r["tiers"]) - total = round(charge * price, 2) - - unit_note = "" - if dims and unit != "cm": - unit_note = "(原尺寸 %g×%g×%g %s,已换算为 %g×%g×%g cm)" % (*dims, unit, *cm) - return { - "channel": channel, "name": r["name"], - "real": real, "vol": vol, "charge": charge, - "dims": dims, "dims_cm": cm, "unit": unit, "unit_note": unit_note, - "dim_rule": ("长×宽×高÷%d = %g×%g×%g÷%d = %g kg" % (divisor, *cm, divisor, vol)) if cm else None, - "price": price, "tier": tier, - "formula": f"{charge:g} kg × {price} 元/kg = {total:,.0f} 元", - "total": total, - "time": r["time"], - "min_pcs_kg": r.get("min_pcs_kg"), - "light_fee": r.get("light_fee"), - "remote": r.get("remote"), - "note": r.get("note"), - } - - -def freight_reply(intent): - """根据已知信息拼出可读的计算说明(给模型当参考,也直接展示)""" - w = intent["weight"] or 0 - dims = intent["dims"] - unit = intent.get("dim_unit") or "cm" - lines = [] - channels = [intent["channel"]] if intent["channel"] else ["sea", "air"] - for ch in channels: - c = calc_freight(w, ch, dims, unit) - parts = [f"实重 {w:g} kg" if w else "实重:未提供"] - if c["vol"]: - parts.append(f"体积重 {c['vol']:g} kg") - body = ( - f"【{c['name']}】按时效 {c['time']},计费方式:阶梯单价 × 计费重量\n" - + (f" 尺寸:{c['unit_note']}\n" if c["unit_note"] else "") - + f" 计费重量 {c['charge']:g} kg(" + ",".join(parts) + ",取较大值)\n" - + (f" 材积算法:{c['dim_rule']}\n" if c["dim_rule"] else "") - + f" 适用档位:{c['tier']}KG+ 档,单价 {c['price']} 元/kg\n" - + f" 运费:{c['formula']}\n" - ) - if c["min_pcs_kg"] and c["charge"] < c["min_pcs_kg"]: - body += f" 注意:单件计费重不足 {c['min_pcs_kg']}kg,需按 {c['min_pcs_kg']}kg 起算或加收超轻费 {c['light_fee']} 元/kg\n" - if c["remote"]: - body += f" 偏远地址:额外 +{c['remote']} 元/kg,单票最低 250 元\n" - if c["note"]: - body += f" 其他:{c['note']}\n" - lines.append(body.rstrip("\n")) - return "\n\n".join(lines) - - -MAX_ANSWER_CHARS = 800 # 中文回答字数上限 -MAX_ANSWER_CHARS_EN = 1400 # 英文回答字数上限(英文信息密度低,字符数约为中文的 2 倍) - -# 压缩时若原文没有中文,要求压缩结果也用英文,避免把英文回答压成中文 -EN_COMPRESS_RULE = ( - "原文是英文,压缩后的结果必须仍然使用英文(English),不得翻译成中文。" +from logistics_chat.tracking import ( + CARD_PREFIX, card_to_text, is_tracking_no, extract_tracking_no, build_track, ) -def has_chinese(t): - return any("\u4e00" <= ch <= "\u9fa5" for ch in t) +def __getattr__(name): + # Preserve WSGI loaders that explicitly request am.app; plain imports stay inert. + if name == 'app': + application = create_app() + globals()['app'] = application + return application + raise AttributeError(name) -def compress_answer(text, limit=MAX_ANSWER_CHARS): - """回答超长时,让模型压缩到规定字数内;失败则原样返回""" - if len(text) <= limit: - return text - try: - body = { - "model": 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 = requests.post(URL, - headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}", - "Content-Type": "application/json"}, - json=body, timeout=(5, 30)) - if r.status_code == 200: - short = r.json()["choices"][0]["message"]["content"].strip() - print(f"[压缩] {len(text)} 字 -> {len(short)} 字") - return short - print(f"[压缩失败] 状态码 {r.status_code}") - except Exception as e: - print(f"[压缩异常] {e}") - return text +def main(): + from logistics_chat.runtime import serve_app + serve_app(create_app()) -def gen_followups(user_msg, answer_text): - """根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)""" - if not DEEPSEEK_API_KEY: - return [] - try: - body = { - "model": 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 = requests.post(URL, - headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}"}, - json=body, timeout=(5, 15)) - if r.status_code != 200: - return [] - text = r.json()["choices"][0]["message"]["content"].strip() - m = re.search(r"\[.*\]", text, re.S) - if m: - arr = json.loads(m.group(0)) - return [str(x).strip() for x in arr if str(x).strip()][:3] - except Exception: - pass - return [] - - -@app.route("/api/feedback", methods=["POST"]) -def feedback(): - """收集 👍/👎 反馈,存到 feedback 表""" - data = request.get_json(silent=True) or {} - conn = sqlite3.connect(DB) - conn.execute( - "INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)", - (str(data.get("question", ""))[:500], str(data.get("answer", ""))[:2000], - str(data.get("vote", "")), now_str())) - conn.commit() - conn.close() - return jsonify({"ok": True}) - - -@app.route("/stream_chat", methods=["GET", "POST"]) -def stream_chat(): - # POST:问题放在请求体里,不受 URL 长度限制(推荐,长文本用这个) - # GET:保留兼容旧链接与 EventSource 方式 - if request.method == "POST": - data = request.get_json(silent=True) or {} - msg = (data.get("msg") or "").strip() - 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) - if not msg: - return Response(sse({"e": "问题不能为空"}), mimetype="text/event-stream") - # 纯图片消息:不调AI - if "[图片]" in msg and len(msg) < 300: - return Response(sse({"t": "收到您的图片,请描述一下您的问题。"}), mimetype="text/event-stream") - - # 运单号:直接给轨迹卡片,不让模型编 - track_no = extract_tracking_no(msg) - if track_no: - card = build_track(track_no) - - 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)) - yield sse({"d": 1}) - - return Response(stream_with_context(track_stream()), - mimetype="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) - - if not DEEPSEEK_API_KEY: - return Response(sse({"e": "服务未配置 API Key,请设置环境变量 DEEPSEEK_API_KEY 后重启服务"}), - mimetype="text/event-stream") - - 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(r"英国|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 或方数时 - cbm_m = re.search(r"(\d+(?:\.\d+)?)\s*(?:cbm|方|立方)", msg, re.I) - if cbm_m: - cbm = float(cbm_m.group(1)) - ch = "海运" - if re.search(r"卡航|卡车", msg): - ch = "卡航" - elif re.search(r"铁路|班列", msg): - ch = "铁路" - elif re.search(r"普卡", msg): - ch = "普卡" - # 按提问里的国家挑选对应仓点分组 - want = None - for kw, ctry in (("德国", "DE"), ("法国", "FR"), ("波兰", "PL"), - ("捷克", "CZ"), ("西班牙", "ES"), ("马德里", "ES"), ("中国", "")): - if kw in msg: - want = ctry - break - card = calc_warehouse_card(cbm, ch, country=want) - if card: - store = card['warehouse'].splitlines()[0] - extra_parts.append( - "【海外仓卡派 · 按方计费】\n" - f" 仓点:{store}\n" - f" 计费:{card['note']}\n" - f" 渠道:{card['channel']},适用档位 {card['tier']}\n" - f" 运费:{card['formula']}\n" - " 说明:单箱实重超 35KG 加收 30 元/箱(封顶 3600 元);不接受仓储,特殊情况 85 元/箱/周" - ) - # ② 产品附加费 - sur = detect_surcharge(msg) - if sur: - extra_parts.append(surcharge_reply(sur[0], sur[1], ship.get("weight"))) - # ③ 岛屿偏远邮编 - pc_m = re.search(r"(? limit: - short = compress_answer(full_text, limit) - # 压缩后仍超标就再压一次(最多两轮) - if len(short) > limit: - short = compress_answer(short, limit - 60) - # 压缩后仍超上限:说明内容含大量数字(如多渠道对比), - # 此时保留完整内容,仅在末尾标注,不硬截断 - if short != full_text: - full_text = short - yield sse({"replace": short}) - save_message(conv_id, "assistant", full_text) - # 只在新对话第一轮生成 3 个相关追问(省额度,失败静默) - if is_first_turn: - try: - fu = gen_followups(msg, full_text) - if fu: - yield sse({"suggest": fu}) - except Exception: - pass - yield sse({"d": 1}) - except GeneratorExit: - # 浏览器刷新 / 关页面 / 切换会话:把已经生成的部分存下来 - interrupted = True - raise - except Exception: - interrupted = True - yield sse({"e": "传输中断,请点击重试"}) - finally: - text = "".join(parts) - # 太短的半截回答没有价值(比如刚输出"您好"就断了),直接丢弃, - # 会话会停在"只有提问"的状态,前端会给出"重新回答"入口 - if interrupted and len(text) >= 20: - save_message(conv_id, "assistant", - text + "\n\n(回答中断,以上内容未生成完整)") - - return Response( - stream_with_context(generate()), - mimetype="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} - ) - -@app.route("/api/new_chat", methods=["POST"]) -def new_chat(): - now = now_str() - conn = sqlite3.connect(DB) - cur = conn.cursor() - cur.execute("INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)", - ("新对话", now, now)) - conv_id = cur.lastrowid - conn.commit() - conn.close() - return jsonify({"id": conv_id}) - - -@app.route("/api/get_history") -def get_history(): - conn = sqlite3.connect(DB) - rows = conn.execute( - "SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history " - "ORDER BY COALESCE(utime, ctime) DESC, id DESC" - ).fetchall() - conn.close() - return jsonify([{"id": r[0], "title": r[1], "ctime": r[2], "utime": r[3]} - for r in rows]) - - -# ===== 管理员后台 ===== -@app.route("/api/admin/stats") -def admin_stats(): - conn = sqlite3.connect(DB) - total_conv = conn.execute("SELECT COUNT(*) FROM chat_history").fetchone()[0] - total_msg = conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0] - good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote=1").fetchone()[0] - bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote=-1").fetchone()[0] - today = datetime.now().strftime("%Y-%m-%d") - today_conv = conn.execute("SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)", (today,)).fetchone()[0] - conn.close() - return jsonify({"total_conv": total_conv, "total_msg": total_msg, - "good": good, "bad": bad, "today_conv": today_conv}) - - -# ===== 人工客服 ===== -@app.route("/api/human/request", methods=["POST"]) -def human_request(): - conv_id = request.json.get("conv_id") - if not conv_id: - return jsonify({"ok": False, "error": "no conv"}), 400 - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn = sqlite3.connect(DB) - 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] - else: - cur = conn.execute("INSERT INTO human_sessions(conv_id, status, ctime) VALUES(?,?,?)", (conv_id, "waiting", now)) - sid = cur.lastrowid - conn.commit() - conn.close() - return jsonify({"ok": True, "session_id": sid}) - -@app.route("/api/human/poll") -def human_poll(): - sid = request.args.get("sid", type=int) - after = request.args.get("after", 0, type=int) - conn = sqlite3.connect(DB) - msgs = conn.execute("SELECT id, side, content, ctime, read_at FROM human_messages WHERE session_id=? AND id>? ORDER BY id", (sid, after)).fetchall() - st = conn.execute("SELECT status FROM human_sessions WHERE id=?", (sid,)).fetchone() - conn.close() - is_typing = False - ts = typing_map.get(str(sid)) - if ts and time.time() - ts < 5: - is_typing = True - return jsonify({"status": st[0] if st else "closed", - "typing": is_typing, - "messages": [{"id": m[0], "side": m[1], "content": m[2], "ctime": m[3], "read": bool(m[4])} for m in msgs]}) - -@app.route("/api/human/send", methods=["POST"]) -def human_send(): - sid = request.json.get("sid") - side = request.json.get("side", "customer") - content = (request.json.get("content") or "").strip() - if not sid or not content: - return jsonify({"ok": False}), 400 - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn = sqlite3.connect(DB) - conn.execute("INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)", (sid, side, content, now)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - -@app.route("/api/human/queue") -def human_queue(): - conn = sqlite3.connect(DB) - rows = conn.execute("SELECT id, conv_id, status, ctime FROM human_sessions WHERE status='waiting' ORDER BY id").fetchall() - conn.close() - return jsonify([{"id": r[0], "conv_id": r[1], "ctime": r[3]} for r in rows]) - -@app.route("/api/human/active") -def human_active(): - conn = sqlite3.connect(DB) - rows = conn.execute("SELECT id, conv_id FROM human_sessions WHERE status='active' ORDER BY id").fetchall() - conn.close() - return jsonify([{"id": r[0], "conv_id": r[1]} for r in rows]) - -@app.route("/api/human/accept", methods=["POST"]) -def human_accept(): - sid = request.json.get("sid") - conn = sqlite3.connect(DB) - conn.execute("UPDATE human_sessions SET status='active' WHERE id=?", (sid,)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - -@app.route("/api/human/close", methods=["POST"]) -def human_close(): - sid = request.json.get("sid") - conn = sqlite3.connect(DB) - conn.execute("UPDATE human_sessions SET status='closed' WHERE id=?", (sid,)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - -# 客服正在输入(简单内存标记) -typing_map = {} -@app.route("/api/human/typing", methods=["POST"]) -def human_typing(): - sid = request.json.get("sid") - typing_map[str(sid)] = time.time() - return jsonify({"ok": True}) - -@app.route("/api/human/stop_typing", methods=["POST"]) -def human_stop_typing(): - sid = request.json.get("sid") - typing_map.pop(str(sid), None) - return jsonify({"ok": True}) - -@app.route("/api/human/read", methods=["POST"]) -def human_read(): - sid = request.json.get("sid") - conn = sqlite3.connect(DB) - conn.execute("UPDATE human_messages SET read_at=? WHERE session_id=? AND side='customer' AND read_at IS NULL", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), sid)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - -@app.route("/api/get_messages") -def get_messages(): - conv_id = request.args.get("conv_id", type=int) - conn = sqlite3.connect(DB) - # 只取最近 100 条,避免超长对话一次渲染几百个气泡卡死 - rows = conn.execute( - "SELECT role, content, ctime FROM messages WHERE conv_id=? " - "ORDER BY id DESC LIMIT 100", - (conv_id,) - ).fetchall() - conn.close() - rows = rows[::-1] # 反序回来变成正序 - return jsonify([{"role": r[0], "content": r[1], "ctime": r[2]} for r in rows]) - - -@app.route("/api/del_history", methods=["DELETE"]) -def del_history(): - hid = request.args.get("id", type=int) - conn = sqlite3.connect(DB) - cur = conn.cursor() - cur.execute("DELETE FROM messages WHERE conv_id=?", (hid,)) - cur.execute("DELETE FROM chat_history WHERE id=?", (hid,)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - - -@app.route("/api/clear_all", methods=["DELETE"]) -def clear_all(): - conn = sqlite3.connect(DB) - cur = conn.cursor() - cur.execute("DELETE FROM messages") - cur.execute("DELETE FROM chat_history") - conn.commit() - conn.close() - return jsonify({"ok": True}) - -UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads") -ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv"} -MAX_UPLOAD_MB = 10 - - -@app.route("/api/upload", methods=["POST"]) -def upload_file(): - """上传附件:保存到 uploads 目录,返回文件名与访问地址""" - if "file" not in request.files: - return jsonify({"ok": False, "error": "没有文件"}), 400 - f = request.files["file"] - if not f or not f.filename: - return jsonify({"ok": False, "error": "文件名为空"}), 400 - - ext = os.path.splitext(f.filename)[1].lower() - if ext not in ALLOWED_EXT: - return jsonify({"ok": False, "error": f"不支持的格式 {ext},仅支持图片/PDF/TXT/CSV"}), 400 - - f.seek(0, os.SEEK_END) - size = f.tell() - f.seek(0) - if size > MAX_UPLOAD_MB * 1024 * 1024: - return jsonify({"ok": False, "error": f"文件超过 {MAX_UPLOAD_MB}MB"}), 400 - - os.makedirs(UPLOAD_DIR, exist_ok=True) - safe_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{random.randint(1000, 9999)}{ext}" - f.save(os.path.join(UPLOAD_DIR, safe_name)) - - is_image = ext in (".png", ".jpg", ".jpeg", ".gif", ".webp") - return jsonify({ - "ok": True, - "name": f.filename, - "url": f"/uploads/{safe_name}", - "is_image": is_image, - }) - - -@app.route("/uploads/") -def uploaded_file(name): - return send_from_directory(UPLOAD_DIR, name) - - -@app.route("/") -def index(): - return render_template("index.html") - -@app.route("/api/my_orders") -def my_orders(): - phone = request.args.get("phone", "").strip() - if not phone: - return jsonify({"orders": []}) - conn = sqlite3.connect(DB) - rows = conn.execute("SELECT track_no, status, created FROM orders WHERE phone=? ORDER BY id DESC", (phone,)).fetchall() - conn.close() - return jsonify({"orders": [{"track_no": r[0], "status": r[1], "created": r[2]} for r in rows]}) - -@app.route("/api/ticket", methods=["POST"]) -def add_ticket(): - data = request.get_json() or {} - conn = sqlite3.connect(DB) - conn.execute("INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)", - (data.get("content",""), data.get("phone",""), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "pending")) - conn.commit(); conn.close() - return jsonify({"ok": True}) - -@app.route("/api/tickets") -def list_tickets(): - conn = sqlite3.connect(DB) - rows = conn.execute("SELECT id, content, phone, ctime FROM tickets WHERE status='pending' ORDER BY id DESC").fetchall() - conn.close() - return jsonify([{"id":r[0],"content":r[1],"phone":r[2],"ctime":r[3]} for r in rows]) - -@app.route("/admin/login", methods=["POST"]) -def admin_login(): - data = request.json or {} - u = data.get("username", "") - p = data.get("password", "") - conn = sqlite3.connect(DB) - row = conn.execute("SELECT name FROM agents WHERE username=? AND password=?", (u, p)).fetchone() - conn.close() - if row: - return jsonify({"ok": True, "name": row[0]}) - return jsonify({"ok": False}), 401 - -@app.route("/api/export_chat") -def export_chat(): - conn = sqlite3.connect(DB) - rows = conn.execute("SELECT h.title, m.role, m.content, m.ctime FROM messages m JOIN chat_history h ON m.conv_id=h.id ORDER BY m.id").fetchall() - conn.close() - lines = ["标题,角色,内容,时间"] - for r in rows: - safe = (r[2] or "").replace(",", ",").replace("\n", " ") - lines.append(f"{r[0]},{r[1]},{safe},{r[3]}") - import csv, io - buf = io.StringIO() - w = csv.writer(buf) - w.writerow(["标题","角色","内容","时间"]) - for r in rows: - w.writerow([r[0], r[1], (r[2] or ""), r[3]]) - return Response(buf.getvalue(), mimetype="text/csv", headers={"Content-Disposition":"attachment;filename=chat_export.csv"}) - -@app.route("/admin") -def admin(): - return render_template("admin_login.html") - -@app.route("/admin/dashboard") -def admin_dash(): - return render_template("admin.html") - - -if __name__ == "__main__": - # 0.0.0.0 = 接受局域网内所有设备访问(手机、其他电脑); - # 只在演示时开启,演示完建议改回 127.0.0.1,避免别人误用消耗 API 额度 - HOST = "0.0.0.0" - PORT = 5000 # 若报 WinError 10013,改成 8000 等端口 - - # 启动时打印本机局域网 IP,方便直接发给别人访问 - def local_ips(): - import socket - ips = set() - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - ips.add(s.getsockname()[0]) - s.close() - except Exception: - pass - try: - for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): - ip = info[4][0] - if not ip.startswith("127."): - ips.add(ip) - except Exception: - pass - return sorted(ips) - - try: - from waitress import serve - print(" * 柏悦物流智能客服已启动(多线程)") - print(f" 本机访问: http://127.0.0.1:{PORT}") - for ip in local_ips(): - print(f" 局域网访问:http://{ip}:{PORT}") - serve(app, host=HOST, port=PORT, threads=8) - except ImportError: - # 没装 waitress 时回退到 Flask 自带服务器,保证仍能运行 - print(f"本机访问: http://127.0.0.1:{PORT}") - for ip in local_ips(): - print(f"局域网访问:http://{ip}:{PORT}") - app.run(host=HOST, port=PORT, debug=True, use_reloader=False) \ No newline at end of file +if __name__ == '__main__': + main() diff --git a/c.json b/c.json deleted file mode 100644 index d4229bc..0000000 --- a/c.json +++ /dev/null @@ -1 +0,0 @@ -{"sid":24} diff --git a/check.py b/check.py deleted file mode 100644 index d4106e5..0000000 --- a/check.py +++ /dev/null @@ -1,5 +0,0 @@ -import sqlite3 -c = sqlite3.connect('history.db') -print(c.execute('SELECT * FROM human_messages ORDER BY id DESC LIMIT 5').fetchall()) -print('---') -print(c.execute("SELECT id, status FROM human_sessions WHERE status!='closed'").fetchall()) diff --git a/clean.py b/clean.py deleted file mode 100644 index 29d69a8..0000000 --- a/clean.py +++ /dev/null @@ -1,5 +0,0 @@ -import sqlite3 -c = sqlite3.connect('history.db') -c.execute("UPDATE human_sessions SET status='closed' WHERE status='active'") -c.commit() -print('ok') diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5445e63 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,113 @@ +# 模块导航与拆分规范 + +四阶段拆分已落地:`am.py` 保留命令及 WSGI 兼容入口,应用工厂和业务代码位于 `logistics_chat/`,页面样式与脚本位于 `static/`。本文描述当前结构及后续结构调整约定。 + +## 先定位,再阅读 + +从 [AGENTS.md](../AGENTS.md) 选择文件,以函数、常量、接口为锚点,搜索后只读取相关实现和调用方。模块移动时更新索引,不保存易失效的行号。默认遵守 Git 忽略规则,排除附件、数据库、日志、备份、虚拟环境和工作草稿。 + +```powershell +rg --files +rg -n -F 'calc_freight' logistics_chat/pricing.py +rg -n -F '/api/human/' logistics_chat/routes static/js examples +rg -n '^(def |class | def )' scripts tests -g '*.py' +``` + +PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整行很长,搜索时加 `--max-columns 200 --max-columns-preview`,避免输出整份数据。 + +## 当前模块索引 + +| 业务范围 | 实际文件与搜索锚点 | 联动位置 | +|---|---|---| +| 应用入口与配置 | `am.py`:`main`、延迟访问的 `app`;`logistics_chat/__init__.py`:`create_app`;`config.py`:`default_config`、`get_setting`;`runtime.py`:`serve_app` | `start.bat`、`.env.example` | +| 请求钩子 | `logistics_chat/hooks.py`:`rate_limit`、`no_cache_html` | 工厂注册钩子;限流状态在 `app.extensions['rate_hits']` | +| SQLite 与维护 | `logistics_chat/db.py`:`connect`、`close_connections`、`init_db`、`backup_db`、`save_message`、`last_user_question`、`load_context` | 维护 CLI 在 `scripts/`;旧会话自动清理仍未启用 | +| 历史接口 | `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`;模拟节点时间取当前时刻,回归对照需固定时钟 | +| 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` | +| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` | +| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` | +| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`,再加载对应 `chat`、`admin`、`admin_login` 的 CSS/JS;保留全局事件入口 | +| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | 数据、HTTP 与 SSE 使用隔离配置和模拟模型;Node 内置测试验证草稿、异步发送和服务资料抽屉;浏览器使用临时测试库 | + +除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。 + +## 当前文件布局 + +```text +am.py # CLI;显式请求 am.app 时创建默认 WSGI 应用 +rates_extra.py # 旧导入路径的兼容转发 +logistics_chat/ +├── __init__.py # create_app 与显式运行时初始化 +├── config.py # 环境变量、稳定根路径、服务/资源配置 +├── db.py # 连接、schema、历史持久化、备份 +├── knowledge.py # 知识与关键词检索 +├── pricing.py # 运费规则、解析、换算、附加费说明 +├── rates_extra.py # 扩展报价数据 +├── tracking.py # 单号识别、模拟轨迹、卡片协议 +├── ai_client.py # 模型 HTTP、压缩与追问 +├── chat_service.py # 问答编排与 SSE 帧 +├── history_service.py # 历史会话 CRUD +├── human_service.py # 人工状态与消息 +├── business_service.py # 反馈、统计、登录、订单、留言、CSV +├── file_service.py # 附件校验、保存、清理 +├── hooks.py # 限流及 HTML/SSE 缓存头 +├── runtime.py # Waitress / Flask 启动 +└── routes/ + ├── __init__.py # Blueprint 注册 + ├── chat.py + ├── history.py + ├── human.py + ├── admin.py + ├── business.py + ├── files.py + └── pages.py +templates/ # HTML/Jinja 及原有事件属性 +static/css/ # theme.css 共享令牌;chat/admin/admin_login 页面样式 +static/js/ # chat.js、admin.js、admin_login.js,经典脚本 +scripts/ # 可独立运行的维护 CLI +tests/ # 按业务模块组织的回归 +``` + +## 模块边界与依赖方向 + +- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。 +- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。 +- 所有模型 HTTP 经 `ai_client.post_completion`。保留流式问答 `(5, 90)`、压缩 `(5, 30)`、追问 `(5, 15)` 三组连接/读取超时;不能因共用客户端而合并期限。流结束或取消时释放 HTTP 响应。 +- 模块导入不建库、不备份、不清理附件、不访问模型。`create_app` 默认显式初始化运行目录、数据库、备份与旧附件清理;`INITIALIZE_RUNTIME=False` 可禁止这些动作,此时调用方负责提供已有 schema。访问兼容入口 `am.app` 视为显式创建应用。 +- `config.ROOT_DIR` 指向仓库根目录,模板/静态目录显式提供给 Flask。数据库、上传、备份、日志使用配置中的绝对路径,避免包文件移动改变数据位置。 +- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。 +- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。 +- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。 +- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。 +- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。 + +## 后续结构调整顺序 + +| 阶段 | 范围 | 验收要求 | +|---|---|---| +| 1. 纯规则 | 知识、报价、运费、运单识别 | 边界结果与迁移前一致;固定时钟比较模拟轨迹;导入无运行数据副作用 | +| 2. 数据访问 | 配置、SQLite、备份、附件 | 临时目录验证 schema 兼容、事务、保留/清理时机和跨目录 CLI | +| 3. 服务/路由 | 模型、聊天、人工、应用工厂、Blueprint | URL/方法/状态/JSON/SSE 不变;保留限流、HTML/SSE 缓存头及根入口 | +| 4. 前端 | 模板与 CSS/JS | 仅迁移时对照原资源内容;主动改版时验证语法、资源 URL、加载顺序、响应式和客户/客服页交互 | + +每阶段完成相关验证再推进依赖阶段。鉴权、缺表和计费的业务修复单独说明,避免在结构调整中顺带改行为。 + +## 必须保留的接口与数据约定 + +- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。 +- `chat_service.create_chat_stream` 在识别运单号时忽略 `[图片]/uploads/...` 和 `[文件]/uploads/...` 路径,避免附件文件名中的时间戳误触发查询;存储与请求仍保留原始附件标记和 URL。 +- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。 +- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent`。 +- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。 +- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。 +- `agents`、`orders`、`tickets` 缺少初始化、后台鉴权不完整、轨迹为模拟数据,仍是已知限制。临时 UI/接口测试中补表只属于测试夹具,不代表修复生产初始化。 + +## 验证与维护 + +先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。 + +路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。 diff --git a/knowledge.md b/docs/knowledge.md similarity index 96% rename from knowledge.md rename to docs/knowledge.md index 08ea3cc..8a09efc 100644 --- a/knowledge.md +++ b/docs/knowledge.md @@ -1,8 +1,8 @@ # 柏悦智能物流客服系统 · 知识资料 -> 本文件由 `am.py` 的 `knowledge_list` 自动导出,供评审与知识维护使用。 +> 本文件整理了 [logistics_chat/knowledge.py](../logistics_chat/knowledge.py) 中的 `knowledge_list`,供评审与知识维护使用。 > 数据来源:炜易达 VIP 10月15日9点执行报价表。 -> 修改知识内容请直接编辑 `am.py` 中的 `knowledge_list`,改完后重新导出本文件。 +> 修改知识内容请编辑 `logistics_chat/knowledge.py` 中的 `knowledge_list`,并同步更新本文件;程序不会直接读取此文档。 --- diff --git a/examples/requests/README.md b/examples/requests/README.md new file mode 100644 index 0000000..283ff40 --- /dev/null +++ b/examples/requests/README.md @@ -0,0 +1,20 @@ +# 人工客服接口请求示例 + +这些 JSON 文件用于本地手动调试。示例中的 `conv_id`、`sid` 均须替换成接口实际返回的 ID。 + +| 文件 | 方法与接口 | 用途 | +|---|---|---| +| `human_request.json` | POST `/api/human/request` | 为已有聊天会话申请人工客服 | +| `human_accept.json` | POST `/api/human/accept` | 客服接待排队中的人工会话 | +| `human_send.json` | POST `/api/human/send` | 客服向已接待会话发送消息 | +| `human_request_invalid.json` | POST `/api/human/request` | `conv_id=0` 的无效请求示例,预期 HTTP 400 | + +在项目根目录执行以下 PowerShell 命令,仅申请人工会话: + +```powershell +Invoke-RestMethod -Uri 'http://127.0.0.1:5000/api/human/request' ` + -Method Post -ContentType 'application/json; charset=utf-8' ` + -InFile 'examples/requests/human_request.json' +``` + +先通过 POST `/api/new_chat` 获取 `conv_id`,再申请人工客服取得 `session_id`,后续请求中的 `sid` 使用该值。接口当前没有完整登录鉴权,请仅在受控的开发环境调试。 diff --git a/examples/requests/human_accept.json b/examples/requests/human_accept.json new file mode 100644 index 0000000..abfb3ee --- /dev/null +++ b/examples/requests/human_accept.json @@ -0,0 +1,3 @@ +{ + "sid": 1 +} diff --git a/examples/requests/human_request.json b/examples/requests/human_request.json new file mode 100644 index 0000000..7f1f9dd --- /dev/null +++ b/examples/requests/human_request.json @@ -0,0 +1,3 @@ +{ + "conv_id": 1 +} diff --git a/examples/requests/human_request_invalid.json b/examples/requests/human_request_invalid.json new file mode 100644 index 0000000..8df046b --- /dev/null +++ b/examples/requests/human_request_invalid.json @@ -0,0 +1,3 @@ +{ + "conv_id": 0 +} diff --git a/examples/requests/human_send.json b/examples/requests/human_send.json new file mode 100644 index 0000000..aac48b0 --- /dev/null +++ b/examples/requests/human_send.json @@ -0,0 +1,5 @@ +{ + "sid": 1, + "side": "agent", + "content": "您好,请问需要咨询什么?" +} diff --git a/human.json b/human.json deleted file mode 100644 index 3c5059d..0000000 --- a/human.json +++ /dev/null @@ -1 +0,0 @@ -{"conv_id":295} diff --git a/logistics_chat/__init__.py b/logistics_chat/__init__.py new file mode 100644 index 0000000..1491d68 --- /dev/null +++ b/logistics_chat/__init__.py @@ -0,0 +1,45 @@ +"""Application factory; package imports do not initialize storage or networking.""" + + +def create_app(config=None): + from collections import defaultdict + import logging + from pathlib import Path + + from flask import Flask + + from .config import ROOT_DIR, default_config + from . import db, file_service, hooks + from .routes import register_routes + + app = Flask(__name__, template_folder=str(ROOT_DIR/'templates'), + static_folder=str(ROOT_DIR/'static')) + app.config.update(default_config()) + if config: + app.config.update(config) + app.extensions['human_typing'] = {} + app.extensions['rate_hits'] = defaultdict(list) + app.before_request(hooks.rate_limit) + app.after_request(hooks.no_cache_html) + app.teardown_appcontext(db.close_connections) + register_routes(app) + + if app.config['INITIALIZE_RUNTIME']: + if not app.testing: + from flask.logging import default_handler + log_directory = Path(app.config['LOG_DIR']) + log_directory.mkdir(parents=True, exist_ok=True) + log_path = str((log_directory/'app.log').resolve()) + app.logger = logging.getLogger('logistics_chat.' + log_path) + app.logger.setLevel(logging.INFO) + if default_handler not in app.logger.handlers: + app.logger.addHandler(default_handler) + if not any(getattr(handler, 'baseFilename', None) == log_path for handler in app.logger.handlers): + handler = logging.FileHandler(log_path, encoding='utf-8') + handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) + app.logger.addHandler(handler) + with app.app_context(): + db.init_db() + db.backup_db() + file_service.cleanup_uploads() + return app diff --git a/logistics_chat/ai_client.py b/logistics_chat/ai_client.py new file mode 100644 index 0000000..1c89fd3 --- /dev/null +++ b/logistics_chat/ai_client.py @@ -0,0 +1,61 @@ +"""DeepSeek HTTP calls; callers retain their streaming/compression/followup deadlines.""" + +import json +import re +import requests +from requests.exceptions import RequestException, Timeout + +from .config import get_setting + + +def post_completion(body, *, timeout, stream=False): + return requests.post( + get_setting('MODEL_URL'), + headers={'Authorization': f"Bearer {get_setting('DEEPSEEK_API_KEY')}", + 'Content-Type': 'application/json'}, + json=body, stream=stream, timeout=timeout, + ) + + + +EN_COMPRESS_RULE = '原文是英文,压缩后的结果必须仍然使用英文(English),不得翻译成中文。' + + +def has_chinese(t): + return any(('一' <= ch <= '龥' for ch in t)) + + +def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')): + """回答超长时,让模型压缩到规定字数内;失败则原样返回""" + if len(text) <= limit: + return text + 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)) + if r.status_code == 200: + short = r.json()['choices'][0]['message']['content'].strip() + print(f'[压缩] {len(text)} 字 -> {len(short)} 字') + return short + print(f'[压缩失败] 状态码 {r.status_code}') + except Exception as e: + print(f'[压缩异常] {e}') + return text + + +def gen_followups(user_msg, answer_text): + """根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)""" + if not get_setting('DEEPSEEK_API_KEY'): + return [] + 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)) + if r.status_code != 200: + return [] + text = r.json()['choices'][0]['message']['content'].strip() + m = re.search('\\[.*\\]', text, re.S) + if m: + arr = json.loads(m.group(0)) + return [str(x).strip() for x in arr if str(x).strip()][:3] + except Exception: + pass + return [] diff --git a/logistics_chat/business_service.py b/logistics_chat/business_service.py new file mode 100644 index 0000000..317ed63 --- /dev/null +++ b/logistics_chat/business_service.py @@ -0,0 +1,75 @@ +"""Feedback, reporting, login, orders and offline tickets.""" + +from datetime import datetime + +from .db import connect, now_str + + +def feedback(data): + """收集 👍/👎 反馈,存到 feedback 表""" + conn = connect() + conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str())) + conn.commit() + conn.close() + return {'ok': True} + + +def admin_stats(): + conn = connect() + total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0] + total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0] + good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0] + bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0] + today = datetime.now().strftime('%Y-%m-%d') + today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0] + conn.close() + return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv} + + +def my_orders(phone): + if not phone: + return {'orders': []} + conn = connect() + rows = conn.execute('SELECT track_no, status, created FROM orders WHERE phone=? ORDER BY id DESC', (phone,)).fetchall() + conn.close() + return {'orders': [{'track_no': r[0], 'status': r[1], 'created': r[2]} for r in rows]} + + +def add_ticket(data): + conn = connect() + conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending')) + conn.commit() + conn.close() + return {'ok': True} + + +def list_tickets(): + conn = connect() + rows = conn.execute("SELECT id, content, phone, ctime FROM tickets WHERE status='pending' ORDER BY id DESC").fetchall() + conn.close() + return [{'id': r[0], 'content': r[1], 'phone': r[2], 'ctime': r[3]} for r in rows] + + +def admin_login(data): + u = data.get('username', '') + p = data.get('password', '') + conn = connect() + row = conn.execute('SELECT name FROM agents WHERE username=? AND password=?', (u, p)).fetchone() + conn.close() + if row: + return {'ok': True, 'name': row[0]} + return ({'ok': False}, 401) + + +def export_chat(): + conn = connect() + rows = conn.execute('SELECT h.title, m.role, m.content, m.ctime FROM messages m JOIN chat_history h ON m.conv_id=h.id ORDER BY m.id').fetchall() + conn.close() + import csv + import io + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(['标题', '角色', '内容', '时间']) + for r in rows: + w.writerow([r[0], r[1], r[2] or '', r[3]]) + return buf.getvalue() diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py new file mode 100644 index 0000000..600ed7e --- /dev/null +++ b/logistics_chat/chat_service.py @@ -0,0 +1,199 @@ +"""Compose knowledge, freight and model output into the existing SSE protocol.""" + +import json +import re + +from . import ai_client +from .ai_client import compress_answer, gen_followups, has_chinese +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 + + + +def sse(payload): + """统一的事件格式:data: {"t":正文} / {"e":错误} / {"card":卡片} / {"d":1结束} + JSON 编码可以避免正文里的换行把 SSE 帧撑断。""" + return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n' + + +def create_chat_stream(msg, conv_id): + if not msg: + return (sse({'e': '问题不能为空'}), {}) + if '[图片]' in msg and len(msg) < 300: + return (sse({'t': '收到您的图片,请描述一下您的问题。'}), {}) + track_no = extract_tracking_no(re.sub(r'\[(?:图片|文件)\]/uploads/\S+', '', msg)) + if track_no: + card = build_track(track_no) + + 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)) + 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,请设置环境变量 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: + cbm = float(cbm_m.group(1)) + ch = '海运' + if re.search('卡航|卡车', msg): + ch = '卡航' + elif re.search('铁路|班列', msg): + ch = '铁路' + elif re.search('普卡', msg): + ch = '普卡' + want = None + for kw, ctry in (('德国', 'DE'), ('法国', 'FR'), ('波兰', 'PL'), ('捷克', 'CZ'), ('西班牙', 'ES'), ('马德里', 'ES'), ('中国', '')): + if kw in msg: + want = ctry + break + card = calc_warehouse_card(cbm, ch, country=want) + if card: + store = card['warehouse'].splitlines()[0] + extra_parts.append(f"【海外仓卡派 · 按方计费】\n 仓点:{store}\n 计费:{card['note']}\n 渠道:{card['channel']},适用档位 {card['tier']}\n 运费:{card['formula']}\n 说明:单箱实重超 35KG 加收 30 元/箱(封顶 3600 元);不接受仓储,特殊情况 85 元/箱/周") + sur = detect_surcharge(msg) + if sur: + extra_parts.append(surcharge_reply(sur[0], sur[1], ship.get('weight'))) + pc_m = re.search('(? limit: + short = compress_answer(full_text, limit) + if len(short) > limit: + short = compress_answer(short, limit - 60) + if short != full_text: + full_text = short + yield sse({'replace': short}) + save_message(conv_id, 'assistant', full_text) + if is_first_turn: + try: + fu = gen_followups(msg, full_text) + if fu: + yield sse({'suggest': fu}) + except Exception: + pass + yield sse({'d': 1}) + except GeneratorExit: + interrupted = True + raise + except Exception: + interrupted = True + yield sse({'e': '传输中断,请点击重试'}) + finally: + text = ''.join(parts) + if interrupted and len(text) >= 20: + save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)') + resp.close() + return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) diff --git a/logistics_chat/config.py b/logistics_chat/config.py new file mode 100644 index 0000000..c28c873 --- /dev/null +++ b/logistics_chat/config.py @@ -0,0 +1,40 @@ +"""Configuration defaults; importing this module does not initialize storage.""" + +import os +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +ALLOWED_EXT = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf', '.txt', '.csv'} +MAX_UPLOAD_MB = 10 + + +def default_config(): + return { + 'DATABASE': ROOT_DIR / 'history.db', + 'UPLOAD_DIR': ROOT_DIR / 'uploads', + 'BACKUP_DIR': ROOT_DIR / 'backups', + 'LOG_DIR': ROOT_DIR / 'logs', + 'DEEPSEEK_API_KEY': os.environ.get('DEEPSEEK_API_KEY', '').strip(), + 'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions', + 'MODEL_NAME': 'deepseek-chat', + 'MAX_CONTEXT': 4, + 'MAX_ANSWER_CHARS': 800, + 'MAX_ANSWER_CHARS_EN': 1400, + 'RATE_LIMIT_PER_MIN': 60, + 'ALLOWED_EXT': set(ALLOWED_EXT), + 'MAX_UPLOAD_MB': MAX_UPLOAD_MB, + 'HOST': '0.0.0.0', + 'PORT': 5000, + 'INITIALIZE_RUNTIME': True, + } + + +def get_setting(name): + try: + from flask import current_app, has_app_context + except ImportError: + return default_config()[name] + if has_app_context(): + return current_app.config[name] + return default_config()[name] diff --git a/logistics_chat/db.py b/logistics_chat/db.py new file mode 100644 index 0000000..e2090ee --- /dev/null +++ b/logistics_chat/db.py @@ -0,0 +1,196 @@ +"""SQLite schema, history persistence and backups; initialized explicitly by create_app.""" + +import os +import re +import shutil +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path + +from .config import get_setting +from .tracking import card_to_text + + +def connect(): + connection = sqlite3.connect(get_setting('DATABASE')) + try: + from flask import g, has_app_context + except ImportError: + return connection + if has_app_context(): + g.setdefault('_database_connections', []).append(connection) + return connection + + +def close_connections(error=None): + from flask import g + for connection in g.pop('_database_connections', []): + connection.close() + + + +def now_str(): + return datetime.now().strftime('%Y-%m-%d %H:%M') + + +def init_db(): + Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True) + conn = connect() + cur = conn.cursor() + cur.execute(''' + CREATE TABLE IF NOT EXISTS chat_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + ctime TEXT NOT NULL + ) + ''') + cols = [r[1] for r in cur.execute('PRAGMA table_info(chat_history)').fetchall()] + if 'utime' not in cols: + cur.execute('ALTER TABLE chat_history ADD COLUMN utime TEXT') + cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL') + cur.execute(''' + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conv_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + ctime TEXT NOT NULL + ) + ''') + cur.execute('CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id)') + cur.execute(''' + CREATE TABLE IF NOT EXISTS feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + question TEXT, + answer TEXT, + vote TEXT, + ctime TEXT + ) + ''') + cur.execute(''' + CREATE TABLE IF NOT EXISTS human_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conv_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'waiting', + ctime TEXT NOT NULL + ) + ''') + cur.execute(''' + CREATE TABLE IF NOT EXISTS human_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + side TEXT NOT NULL, + content TEXT NOT NULL, + ctime TEXT NOT NULL + ) + ''') + hm_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_messages)').fetchall()] + if 'read_at' not in hm_cols: + cur.execute('ALTER TABLE human_messages ADD COLUMN read_at TEXT') + 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())) + conn.commit() + conn.close() + + +def backup_db(): + """启动时把数据库备份到 backups/,文件名带日期,只保留最近 7 天""" + try: + bdir = get_setting('BACKUP_DIR') + os.makedirs(bdir, exist_ok=True) + today = datetime.now().strftime('%Y%m%d') + dst = os.path.join(bdir, f'history_{today}.db') + if not os.path.exists(dst): + shutil.copy2(get_setting('DATABASE'), dst) + cutoff = datetime.now() - timedelta(days=7) + for f in os.listdir(bdir): + m = re.match('history_(\\d{8})\\.db$', f) + if not m: + continue + try: + if datetime.strptime(m.group(1), '%Y%m%d') < cutoff: + os.remove(os.path.join(bdir, f)) + except Exception: + pass + except Exception as e: + print(f'[数据库备份失败] {e}') + + +def cleanup_old_conversations(): + """自动清理 30 天前的旧对话,防止数据库无限膨胀""" + try: + conn = connect() + c = conn.cursor() + cutoff = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S') + rows = c.execute('SELECT id FROM chat_history WHERE utime < ?', (cutoff,)).fetchall() + ids = [r[0] for r in rows] + if not ids: + return + for cid in ids: + c.execute('DELETE FROM messages WHERE conv_id=?', (cid,)) + c.execute(f"DELETE FROM chat_history WHERE id IN ({','.join('?' * len(ids))})", ids) + conn.commit() + conn.close() + print(f'[自动清理] 已删除 {len(ids)} 条 30 天前的旧对话') + except Exception as e: + print(f'[自动清理失败] {e}') + + +def make_title(content): + """生成会话标题:优先用文字内容,只有附件时显示附件数量""" + lines = [l for l in content.split('\n') if not l.strip().startswith('[附件]')] + text = ' '.join(lines).strip() + if text and text != '(发送了附件)' and (text != '请帮我看看这个附件'): + return text[:24] + n = len([l for l in content.split('\n') if l.strip().startswith('[附件]')]) + if n == 1: + return '📎 已上传附件' + if n > 1: + return f'📎 {n}个附件' + return content[:24] + + +def save_message(conv_id, role, content): + """把一条消息写进会话;用户消息做幂等处理,重试不会重复入库。""" + if not conv_id or not content: + return + conn = connect() + cur = conn.cursor() + if role == 'user': + last = cur.execute('SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 1', (conv_id,)).fetchone() + 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())) + 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] == '新对话'): + cur.execute('UPDATE chat_history SET title=? WHERE id=?', (make_title(content), conv_id)) + cur.execute('UPDATE chat_history SET utime=? WHERE id=?', (now_str(), conv_id)) + conn.commit() + conn.close() + + +def last_user_question(conv_id): + """上一条用户提问(必须在当前这句入库之前调用)""" + if not conv_id: + return None + conn = connect() + row = conn.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone() + conn.close() + return row[0] if row else None + + +def load_context(conv_id, msg): + """取最近 MAX_CONTEXT 条消息,拼成模型要的 messages(不含 system)。""" + if not conv_id: + return [{'role': 'user', 'content': msg}] + conn = connect() + rows = conn.execute('SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT ?', (conv_id, get_setting('MAX_CONTEXT'))).fetchall() + conn.close() + rows.reverse() + history = [{'role': r, 'content': card_to_text(c)} for r, c in rows] + if not history or history[-1]['role'] != 'user' or history[-1]['content'] != msg: + history.append({'role': 'user', 'content': msg}) + return history diff --git a/logistics_chat/file_service.py b/logistics_chat/file_service.py new file mode 100644 index 0000000..02e23c1 --- /dev/null +++ b/logistics_chat/file_service.py @@ -0,0 +1,46 @@ +"""Attachment validation, storage and retention without request parsing.""" + +import os +import random +import time +from datetime import datetime + +from .config import get_setting + + + +def cleanup_uploads(): + """删除 uploads/ 里 7 天前的图片""" + try: + udir = get_setting('UPLOAD_DIR') + if not os.path.isdir(udir): + return + cutoff = time.time() - 7 * 86400 + for f in os.listdir(udir): + fp = os.path.join(udir, f) + if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff: + os.remove(fp) + except Exception as e: + print(f'[清理上传文件失败] {e}') + + +def upload_file(files): + """上传附件:保存到 uploads 目录,返回文件名与访问地址""" + if 'file' not in files: + return ({'ok': False, 'error': '没有文件'}, 400) + f = files['file'] + if not f or not f.filename: + return ({'ok': False, 'error': '文件名为空'}, 400) + ext = os.path.splitext(f.filename)[1].lower() + if ext not in get_setting('ALLOWED_EXT'): + return ({'ok': False, 'error': f'不支持的格式 {ext},仅支持图片/PDF/TXT/CSV'}, 400) + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(0) + if size > get_setting('MAX_UPLOAD_MB') * 1024 * 1024: + return ({'ok': False, 'error': f"文件超过 {get_setting('MAX_UPLOAD_MB')}MB"}, 400) + os.makedirs(get_setting('UPLOAD_DIR'), exist_ok=True) + safe_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{random.randint(1000, 9999)}{ext}" + f.save(os.path.join(get_setting('UPLOAD_DIR'), safe_name)) + is_image = ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp') + return {'ok': True, 'name': f.filename, 'url': f'/uploads/{safe_name}', 'is_image': is_image} diff --git a/logistics_chat/history_service.py b/logistics_chat/history_service.py new file mode 100644 index 0000000..ab27ca2 --- /dev/null +++ b/logistics_chat/history_service.py @@ -0,0 +1,49 @@ +"""Conversation CRUD with unchanged response data.""" + +from .db import connect, now_str + + +def new_chat(): + now = now_str() + conn = connect() + cur = conn.cursor() + cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now)) + conv_id = cur.lastrowid + conn.commit() + conn.close() + return {'id': conv_id} + + +def get_history(): + conn = connect() + rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history ORDER BY COALESCE(utime, ctime) DESC, id DESC').fetchall() + conn.close() + return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows] + + +def get_messages(conv_id): + conn = connect() + rows = conn.execute('SELECT role, content, ctime FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 100', (conv_id,)).fetchall() + conn.close() + rows = rows[::-1] + return [{'role': r[0], 'content': r[1], 'ctime': r[2]} for r in rows] + + +def del_history(hid): + conn = connect() + cur = conn.cursor() + cur.execute('DELETE FROM messages WHERE conv_id=?', (hid,)) + cur.execute('DELETE FROM chat_history WHERE id=?', (hid,)) + conn.commit() + conn.close() + return {'ok': True} + + +def clear_all(): + conn = connect() + cur = conn.cursor() + cur.execute('DELETE FROM messages') + cur.execute('DELETE FROM chat_history') + conn.commit() + conn.close() + return {'ok': True} diff --git a/logistics_chat/hooks.py b/logistics_chat/hooks.py new file mode 100644 index 0000000..c52d023 --- /dev/null +++ b/logistics_chat/hooks.py @@ -0,0 +1,28 @@ +"""Request limiting and HTML/SSE cache headers with app-scoped state.""" + +import time + +from flask import current_app, jsonify, request + + +def rate_limit(): + path = request.path + if path.startswith('/static') or path.startswith('/api/human/'): + return None + ip = request.remote_addr or 'unknown' + now = time.time() + hits = current_app.extensions['rate_hits'][ip] + while hits and now - hits[0] > 60: + hits.pop(0) + if len(hits) >= current_app.config['RATE_LIMIT_PER_MIN']: + return jsonify({'e': '请求太频繁,请稍后几秒再试', 'd': 1}), 429 + hits.append(now) + return None + + +def no_cache_html(response): + if response.mimetype in ('text/html', 'text/event-stream'): + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response diff --git a/logistics_chat/human_service.py b/logistics_chat/human_service.py new file mode 100644 index 0000000..4221ecf --- /dev/null +++ b/logistics_chat/human_service.py @@ -0,0 +1,102 @@ +"""Human conversation lifecycle; typing state is supplied by the app.""" + +import time +from datetime import datetime + +from .db import connect + + +def human_request(data): + conv_id = data.get('conv_id') + if not conv_id: + return ({'ok': False, 'error': 'no conv'}, 400) + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + conn = connect() + 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] + else: + cur = conn.execute('INSERT INTO human_sessions(conv_id, status, ctime) VALUES(?,?,?)', (conv_id, 'waiting', now)) + sid = cur.lastrowid + conn.commit() + conn.close() + return {'ok': True, 'session_id': sid} + + +def human_poll(sid, after, typing_map): + conn = connect() + msgs = conn.execute('SELECT id, side, content, ctime, read_at FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall() + st = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone() + conn.close() + is_typing = False + ts = typing_map.get(str(sid)) + if ts and time.time() - ts < 5: + is_typing = True + return {'status': st[0] if st else 'closed', 'typing': is_typing, 'messages': [{'id': m[0], 'side': m[1], 'content': m[2], 'ctime': m[3], 'read': bool(m[4])} for m in msgs]} + + +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: + return ({'ok': False}, 400) + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + conn = connect() + conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now)) + conn.commit() + conn.close() + return {'ok': True} + + +def human_queue(): + conn = connect() + rows = conn.execute("SELECT id, conv_id, status, ctime FROM human_sessions WHERE status='waiting' ORDER BY id").fetchall() + conn.close() + return [{'id': r[0], 'conv_id': r[1], 'ctime': r[3]} for r in rows] + + +def human_active(): + conn = connect() + rows = conn.execute("SELECT id, conv_id FROM human_sessions WHERE status='active' ORDER BY id").fetchall() + conn.close() + return [{'id': r[0], 'conv_id': r[1]} for r in rows] + + +def human_accept(data): + sid = data.get('sid') + conn = connect() + conn.execute("UPDATE human_sessions SET status='active' WHERE id=?", (sid,)) + conn.commit() + conn.close() + return {'ok': True} + + +def human_close(data): + sid = data.get('sid') + conn = connect() + conn.execute("UPDATE human_sessions SET status='closed' WHERE id=?", (sid,)) + conn.commit() + conn.close() + return {'ok': True} + + +def human_typing(data, typing_map): + sid = data.get('sid') + typing_map[str(sid)] = time.time() + return {'ok': True} + + +def human_stop_typing(data, typing_map): + sid = data.get('sid') + typing_map.pop(str(sid), None) + return {'ok': True} + + +def human_read(data): + sid = data.get('sid') + conn = connect() + conn.execute("UPDATE human_messages SET read_at=? WHERE session_id=? AND side='customer' AND read_at IS NULL", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), sid)) + conn.commit() + conn.close() + return {'ok': True} diff --git a/logistics_chat/knowledge.py b/logistics_chat/knowledge.py new file mode 100644 index 0000000..60b8e8f --- /dev/null +++ b/logistics_chat/knowledge.py @@ -0,0 +1,105 @@ +"""Extracted knowledge rules with unchanged business behavior.""" + + +knowledge_list = [ + { + "keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家"], + "text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓。" + }, + { + "keys":["海运","船运","海派","海运快线","樱花快线"], + "text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。" + }, + { + "keys":["铁路","中欧班列","班列","义乌铁路","火车"], + "text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。" + }, + { + "keys":["卡航","卡车","卡派"], + "text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。" + }, + { + "keys":["空运","空派","航空","欧洲空运"], + "text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。" + }, + { + "keys":["英国海运","英国铁路","中英卡航","英国专线"], + "text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。" + }, + { + "keys":["英国空运","英国","UK","伦敦"], + "text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。" + }, + { + "keys":["报价","价格","费用","计费","怎么收费","计费重量","多少钱","运费","单价"], + "text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。" + }, + { + "keys":["时效","多久","要几天","多长时间"], + "text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。" + }, + { + "keys":["揽收","上门取件","上门收货","交仓","交货"], + "text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。" + }, + { + "keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","交仓地址","送仓"], + "text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。" + }, + { + "keys":["理赔","货损","丢件","赔付","破损","压坏","索赔"], + "text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。" + }, + { + "keys":["超轻费","计费重量不足","最低计费"], + "text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。" + }, + { + "keys":["偏远","偏远费","偏远地址"], + "text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。" + }, + { + "keys":["超重","超长","超大","尺寸限制"], + "text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。" + }, + { + "keys":["敏感","带电","电池","液体","粉末","禁品","不能寄","违禁品","拒收","拒接"], + "text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。" + }, + { + "keys":["清关","递延","VAT","双清","包税","DDP"], + "text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。" + }, + { + "keys":["报关","单独报关","报关费"], + "text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。" + }, + { + "keys":["亚马逊","FBA","海外仓","仓库配送","卡派"], + "text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。" + }, + { + "keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"], + "text":"人工客服在线时间:周一至周五 9:00—18:00。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。" + }, + { + "keys":["电话","联系方式","怎么联系","联系你们","微信"], + "text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。" + }, + { + "keys":["退件","退回","退货","退运","改地址"], + "text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。" + } +] + +def search_knowledge(question): + """按关键词命中打分:命中的关键词越长越具体,得分越高""" + q = question.lower() + best, best_score = None, 0 + for item in knowledge_list: + for k in item["keys"]: + if k.lower() in q: + score = len(k) # 长关键词更具体,优先 + if score > best_score: + best, best_score = item["text"], score + return best diff --git a/logistics_chat/pricing.py b/logistics_chat/pricing.py new file mode 100644 index 0000000..05b84fc --- /dev/null +++ b/logistics_chat/pricing.py @@ -0,0 +1,339 @@ +"""Extracted pricing rules with unchanged business behavior.""" + +import math +import re + +try: + from . import rates_extra as RX +except Exception: + RX = None + +RATES = { + "sea": { + "name": "欧洲海运快线(双清/递延)", + "tiers": [(2000, 10.0), (500, 10.2), (101, 10.3), (21, 12.3)], + "divisor": 6000, + "min_kg": 21, + "time": "开船后 38-45 天提取", + "min_pcs_kg": None, + "remote": 7, + "note": "德国 UPS/DHL/DPD/CWE 统配;欧洲递延 -1.7元/kg;樱花快线 +0.3元/kg;因查验/罢工/节假日顺延", + }, + "rail": { + "name": "义乌-中欧铁路专线", + "tiers": [(1000, 13.5), (100, 13.5), (21, 15.5)], + "divisor": 6000, + "min_kg": 21, + "time": "发车后 36-40 天提取", + "min_pcs_kg": 13, + "light_fee": 5, + "remote": 7, + "note": "义乌装柜;VAT 递延 -1元/kg;单件计费重不足 13kg 按超轻费加收", + }, + "truck": { + "name": "中欧卡航", + "tiers": [(4000, 17.3), (2000, 17.3), (500, 17.4), (101, 17.4), (21, 19.4)], + "divisor": 6000, + "min_kg": 21, + "time": "发车后欧盟 16-20 天提取", + "min_pcs_kg": 13, + "light_fee": 5, + "remote": 7, + "note": "递延 -1.5元/kg;纯纺织品 +5元/kg;普卡时效 18-22 天", + }, + "air": { + "name": "欧洲空运专线(空派)", + "tiers": [(101, 36), (71, 37), (45, 38), (21, 39)], + "divisor": 6000, + "min_kg": 21, + "time": "10-13 天左右提取", + "min_pcs_kg": 12, + "light_fee": None, + "remote": 7, + "note": "一票多件单件不足 12kg 按 12kg 计费;快线 8-13 天;直飞快线 12-15 天", + }, + "sea_uk": { + "name": "英国海运(双清)", + "tiers": [(301, 10.9), (101, 10.9), (21, 12.8)], + "divisor": 6000, + "min_kg": 21, + "time": "参考时效 40 天提取", + "remote": None, + "note": "VAT自税/递延 10.0/8.1 元/kg;按KG卡派 12.2/10.3;限时达 13.5/11.6;北极航线 25-28 天(义乌装)", + }, + "rail_uk": { + "name": "英国铁路(双清)", + "tiers": [(301, 16.2), (101, 16.2), (21, 18.0)], + "divisor": 6000, + "min_kg": 21, + "time": "发车后 25-30 天提取", + "remote": None, + "note": "VAT自税/递延 15/13.2 元/kg;限时达 19.2/17.4(38天);按KG卡派 17.3/15.5", + }, + "truck_uk": { + "name": "中英卡航(双清)", + "tiers": [(301, 19.4), (101, 19.4), (21, 21.4)], + "divisor": 6000, + "min_kg": 21, + "time": "发车后 20-22 天提取", + "remote": None, + "note": "VAT自税/递延 18.4/16.4 元/kg;卡派 20.9/18.9;单件不超30kg,尺寸≤98×68×59cm", + }, + "air_uk": { + "name": "英国空运专线(双清)", + "tiers": [(101, 42), (51, 44), (22, 48)], + "divisor": 6000, + "min_kg": 22, + "time": "8-10 个自然日提取", + "min_pcs_kg": 12, + "light_fee": None, + "remote": None, + "note": "不包税 VAT 自税/递延 44/40/38;带电 47.5/42.5/41.5;拒收北爱尔兰/苏格兰高地偏远地址", + }, +} + +CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运") + +def parse_dims(t): + """解析 长x宽x高,例如 120*100*100 / 100x80x60 / 120×100×100 + + 用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连, + 这样 "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 + +def parse_shipping_intent(text): + """从话里抠出:重量(kg)、渠道、长宽高(cm)""" + t = text.replace(" ", "") + intent = {"weight": None, "channel": None, "dims": None} + + # 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量) + intent["dims"] = parse_dims(text) + if not intent["dims"]: + # 支持“长100宽80高60” + dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t) + if dl: + intent["dims"] = [float(x) for x in dl.groups()] + + # 重量:带单位的数字。去掉空格后在原始文本上匹配, + # 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量 + m = re.search(r"(\d+(?:\.\d+)?)\s*(吨|kg|KG|公斤|千克|tons?|kilo(?:grams?)?|lbs?)", text, re.I) + if m: + before = text[:m.start()].rstrip() + adjacent_sep = bool(before) and before[-1] in "*xX×" + if not adjacent_sep: + n = float(m.group(1)) + unit_w = m.group(2).lower() + if unit_w in ("吨", "ton", "tons"): + intent["weight"] = n * 1000 + elif unit_w.startswith("lb"): + intent["weight"] = round(n * 0.4536, 2) # 磅转公斤 + else: + intent["weight"] = n + intent["weight_raw"] = m.group(0).replace(" ", "") + + # 渠道:空运 / 铁路 / 卡航 / 海运(按优先级匹配) + if re.search(r"空运|空派|航空", t) or re.search(r"\bair\b|air ?freight|air ?cargo", text, re.I): + intent["channel"] = "air_uk" if re.search(r"英国|UK|伦敦|英格兰", text, re.I) else "air" + elif re.search(r"铁路|班列|火车", t) or re.search(r"\brail\b|train", text, re.I): + intent["channel"] = "rail" + elif re.search(r"卡航|卡车|卡派", t) or re.search(r"\btruck\b|road", text, re.I): + intent["channel"] = "truck" + elif re.search(r"海运|海派|船运|快船|海运快线|樱花快线", t) or re.search(r"\bsea\b|ocean|shipping", text, re.I): + intent["channel"] = "sea" + return intent + +def detect_dim_unit(text): + """识别尺寸单位:cm(默认)/ m / mm""" + low = text.lower() + if re.search(r"毫米|\d\s*mm", low): + return "mm" + if re.search(r"米", low) and not re.search(r"厘米", low): + return "m" + if re.search(r"\d\s*cm|\d\s*厘米", low): + return "cm" + if re.search(r"\d\s*m", low): # 注意:cm 里也含 m,所以放在 cm 之后判断 + return "m" + return "cm" + +def dims_to_cm(dims, unit): + """统一换算成厘米,体积重公式按 cm 计算""" + if unit == "m": + return [d * 100 for d in dims] + if unit == "mm": + return [d / 10 for d in dims] + return list(dims) + +def pick_rate(weight_kg, tiers): + """从阶梯价里选档:tiers 为 [(最低重量, 单价), ...],从高到低匹配""" + for min_w, price in tiers: + if weight_kg >= min_w: + return price, min_w + return tiers[-1][1], tiers[-1][0] + +def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None): + """海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档 + + 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 not key and country: + for k in keys: + if ("-" + country) in k.upper(): + key = k + break + if not key: + key = keys[0] + row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]] + p = row.get(channel) or row["海运"] + price = p["cbm1"] if cbm >= 1 else p["cbm05"] + if price is None: + return 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 + else: + stores = stores[:1] + note = "报价表中的仓点分组:" + "、".join(stores) + return { + "warehouse": "\n".join(stores), "stores": stores, + "channel": channel, "cbm": cbm, "note": note, + "price_cbm": price, "tier": tier, + "total": round(cbm * price, 2), + "formula": f"{cbm:g} CBM × {price:,.0f} 元/CBM = {cbm * price:,.0f} 元", + } + +def detect_surcharge(text): + """识别产品类型,返回报价表中的附加费条目""" + if not RX: + return None + best = None + for product, fee in RX.PRODUCT_SURCHARGES: + # 用产品名里的前几个字做匹配(如“手机”“电池”“玩具”) + for seg in re.split(r"[,,、\s()()]", product): + seg = seg.strip() + if len(seg) >= 2 and seg in text: + if best is None or len(seg) > len(best[2]): + best = (product, fee, seg) + if best: + return best[0], best[1] + return None + +def check_island(postcode): + """按邮编判断是否属于 DPD 岛屿偏远地区(附加费 35 欧/件)""" + if not RX: + return None + pc = re.sub(r"[^0-9]", "", str(postcode)) + if not pc: + return None + for country, info in RX.DPD_ISLAND_POSTCODES.items(): + seg = info["text"] + # 直接匹配的 4~5 位邮编 + for m in re.finditer(r"\d{4,5}", seg): + if pc.startswith(m.group(0)): + return country, info["code"], m.group(0) + # 区间形式 07000–07999 + for m in re.finditer(r"(\d{4,5})\s*[–\-]\s*(\d{4,5})", seg): + lo, hi = int(m.group(1)), int(m.group(2)) + if len(m.group(1)) == len(pc) and lo <= int(pc) <= hi: + return country, info["code"], m.group(0) + return None + +def surcharge_reply(product, fee, weight_kg=None): + """附加费说明(能算就给出金额)""" + txt = f"【产品附加费】{product}:{fee}" + if weight_kg and "/KG" in fee.upper(): + m = re.search(r"\+?(\d+(?:\.\d+)?)", fee) + if m: + extra = float(m.group(1)) * weight_kg + txt += f"\n 按 {weight_kg:g} kg 计:约 {extra:,.0f} 元(以最终计费重为准)" + return txt + +def calc_freight(weight_kg, channel, dims=None, unit="cm"): + """按报价表规则计算运费(数字全部由代码算) + + 报价表规则: + - 阶梯单价:按总重量落入的档位取价 + - 材积:长×宽×高(cm) ÷ 6000,与实重取大值作为计费重量 + - 单件最低计费重 / 超轻费:见 RATES 里的 min_pcs_kg、light_fee + - 偏远地址:+7元/kg(不含油),最低 250 元/票 + """ + r = RATES[channel] + cm = dims_to_cm(dims, unit) if dims else None + divisor = r.get("divisor", 6000) + vol = round(cm[0] * cm[1] * cm[2] / divisor, 2) if cm else None + real = weight_kg or 0 + charge = round(max(real, vol) if vol else real, 2) # 实重与体积重取大 + min_kg = r.get("min_kg") or 0 + charge = max(charge, min_kg) # 报价表起收档 + price, tier = pick_rate(charge, r["tiers"]) + total = round(charge * price, 2) + + unit_note = "" + if dims and unit != "cm": + unit_note = "(原尺寸 %g×%g×%g %s,已换算为 %g×%g×%g cm)" % (*dims, unit, *cm) + return { + "channel": channel, "name": r["name"], + "real": real, "vol": vol, "charge": charge, + "dims": dims, "dims_cm": cm, "unit": unit, "unit_note": unit_note, + "dim_rule": ("长×宽×高÷%d = %g×%g×%g÷%d = %g kg" % (divisor, *cm, divisor, vol)) if cm else None, + "price": price, "tier": tier, + "formula": f"{charge:g} kg × {price} 元/kg = {total:,.0f} 元", + "total": total, + "time": r["time"], + "min_pcs_kg": r.get("min_pcs_kg"), + "light_fee": r.get("light_fee"), + "remote": r.get("remote"), + "note": r.get("note"), + } + +def freight_reply(intent): + """根据已知信息拼出可读的计算说明(给模型当参考,也直接展示)""" + w = intent["weight"] or 0 + dims = intent["dims"] + unit = intent.get("dim_unit") or "cm" + lines = [] + channels = [intent["channel"]] if intent["channel"] else ["sea", "air"] + for ch in channels: + c = calc_freight(w, ch, dims, unit) + parts = [f"实重 {w:g} kg" if w else "实重:未提供"] + if c["vol"]: + parts.append(f"体积重 {c['vol']:g} kg") + body = ( + f"【{c['name']}】按时效 {c['time']},计费方式:阶梯单价 × 计费重量\n" + + (f" 尺寸:{c['unit_note']}\n" if c["unit_note"] else "") + + f" 计费重量 {c['charge']:g} kg(" + ",".join(parts) + ",取较大值)\n" + + (f" 材积算法:{c['dim_rule']}\n" if c["dim_rule"] else "") + + f" 适用档位:{c['tier']}KG+ 档,单价 {c['price']} 元/kg\n" + + f" 运费:{c['formula']}\n" + ) + if c["min_pcs_kg"] and c["charge"] < c["min_pcs_kg"]: + body += f" 注意:单件计费重不足 {c['min_pcs_kg']}kg,需按 {c['min_pcs_kg']}kg 起算或加收超轻费 {c['light_fee']} 元/kg\n" + if c["remote"]: + body += f" 偏远地址:额外 +{c['remote']} 元/kg,单票最低 250 元\n" + if c["note"]: + body += f" 其他:{c['note']}\n" + lines.append(body.rstrip("\n")) + return "\n\n".join(lines) diff --git a/logistics_chat/rates_extra.py b/logistics_chat/rates_extra.py new file mode 100644 index 0000000..f201f4b --- /dev/null +++ b/logistics_chat/rates_extra.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""报价表扩展数据(自动生成,来源:炜易达 VIP 10月15日9点执行报价表) + +包含:海外仓卡派、二级补仓、德国仓亚马逊退货、产品附加费、DPD 岛屿偏远邮编。 +""" + +# 海外仓卡派:按 kg(21KG+/51KG+)或按方(0.5CBM+/1CBM+)计费 +# 价格单位:按 kg 为 RMB/kg,按方为 RMB/CBM +WAREHOUSE_CARD = {'谷仓法国巴黎1仓-95500-FR\n谷仓法国巴黎2仓-60440-FR\n谷仓法国巴黎4仓-77230-FR': {'卡航': {'kg21': 19.5, 'kg51': 18.5, 'cbm05': 3200.0, 'cbm1': 3020.0}, '普卡': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2920.0}, '铁路': {'kg21': 16.8, 'kg51': 15.8, 'cbm05': 2722.0, 'cbm1': 2542.0}, '海运': {'kg21': 12.5, 'kg51': 11.5, 'cbm05': 1952.0, 'cbm1': 1772.0}}, '4PX-69110-PL\n谷仓-69110-PL\nSLU2-69110-PL': {'卡航': {'kg21': 18.2, 'kg51': 17.2, 'cbm05': 2966.0, 'cbm1': 2786.0}, '普卡': {'kg21': 17.7, 'kg51': 16.7, 'cbm05': 2876.0, 'cbm1': 2686.0}, '铁路': {'kg21': 15.5, 'kg51': 14.5, 'cbm05': 2488.0, 'cbm1': 2308.0}, '海运': {'kg21': 11.1, 'kg51': 10.1, 'cbm05': 1700.0, 'cbm1': 1520.0}}, 'EDAPL-69100-PL': {'卡航': {'kg21': 18.6, 'kg51': 17.6, 'cbm05': 3038.0, 'cbm1': 2858.0}, '普卡': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2948.0, 'cbm1': 2758.0}, '铁路': {'kg21': 15.9, 'kg51': 14.9, 'cbm05': 2560.0, 'cbm1': 2380.0}, '海运': {'kg21': 11.5, 'kg51': 10.5, 'cbm05': 1772.0, 'cbm1': 1592.0}}, '4PX-66340-PL\n菜鸟仓-66340-PL': {'卡航': {'kg21': 18.9, 'kg51': 17.9, 'cbm05': 3092.0, 'cbm1': 2912.0}, '普卡': {'kg21': 18.4, 'kg51': 17.4, 'cbm05': 3002.0, 'cbm1': 2812.0}, '铁路': {'kg21': 16.2, 'kg51': 15.2, 'cbm05': 2614.0, 'cbm1': 2434.0}, '海运': {'kg21': 11.9, 'kg51': 10.9, 'cbm05': 1844.0, 'cbm1': 1664.0}}, '希音-44141-PL\n希音-55040-PL': {'卡航': {'kg21': 19.5, 'kg51': 18.5, 'cbm05': 3200.0, 'cbm1': 3020.0}, '普卡': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2920.0}, '铁路': {'kg21': 16.8, 'kg51': 15.8, 'cbm05': 2722.0, 'cbm1': 2542.0}, '海运': {'kg21': 12.5, 'kg51': 11.5, 'cbm05': 1952.0, 'cbm1': 1772.0}}, '谷仓捷克1仓-25261-CZ\n谷仓捷克2仓-25261-CZ\n谷仓捷克3仓-41002-CZ\n谷仓捷克4仓-25261-CZ\n谷仓捷克5仓-25219-CZ\n谷仓捷克7仓-25301-CZ\n谷仓捷克8仓-41002-CZ': {'卡航': {'kg21': 18.6, 'kg51': 17.6, 'cbm05': 3038.0, 'cbm1': 2858.0}, '普卡': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2948.0, 'cbm1': 2758.0}, '铁路': {'kg21': 15.9, 'kg51': 14.9, 'cbm05': 2560.0, 'cbm1': 2380.0}, '海运': {'kg21': 11.5, 'kg51': 10.5, 'cbm05': 1772.0, 'cbm1': 1592.0}}, '4PX布拉格A仓-25261-CZ': {'卡航': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2930.0}, '普卡': {'kg21': 18.5, 'kg51': 17.5, 'cbm05': 3020.0, 'cbm1': 2830.0}, '铁路': {'kg21': 16.3, 'kg51': 15.3, 'cbm05': 2632.0, 'cbm1': 2452.0}, '海运': {'kg21': 12.0, 'kg51': 11.0, 'cbm05': 1862.0, 'cbm1': 1682.0}}, '4PX马德里A仓-19171-ES': {'卡航': {'kg21': 20.8, 'kg51': 19.8, 'cbm05': 3434.0, 'cbm1': 3254.0}, '普卡': {'kg21': 20.3, 'kg51': 19.3, 'cbm05': 3344.0, 'cbm1': 3154.0}, '铁路': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2956.0, 'cbm1': 2776.0}, '海运': {'kg21': 13.7, 'kg51': 12.7, 'cbm05': 2168.0, 'cbm1': 1988.0}}, '盘古-63150-DE\n新欧达-31628-DE': {'卡航': {'kg21': 19.4, 'kg51': 18.4, 'cbm05': 3182.0, 'cbm1': 3002.0}, '普卡': {'kg21': 18.9, 'kg51': 17.9, 'cbm05': 3092.0, 'cbm1': 2902.0}, '铁路': {'kg21': 16.7, 'kg51': 15.7, 'cbm05': 2704.0, 'cbm1': 2524.0}, '海运': {'kg21': 12.3, 'kg51': 11.3, 'cbm05': 1916.0, 'cbm1': 1736.0}}} + +# 二级补仓(海外仓中转)主要服务费 +SECONDARY_WAREHOUSE = {'服务': '1.贴标:2张FBA标签,一张快递面单)单件计费重量范围在(0KG<X≤30KG) 2.打板:实际打板数为准(1个板大概1.2方-1.5方) 3.卸货费', '头程单价指示': '欧洲铁路头程单价:欧洲铁路渠道 中欧卡航头程单价:中欧卡航渠道 欧洲海运头程单价:海运快线/海运热门仓卡派直送 英国头程单价:英国专线(DPD)', '二次派送': '1. 欧洲/英国二级补仓无需二次派送费用,可自提单价-1/KG 2. 海外仓在德国,如需发往其他国家补运费差价', '仓储费': '德国:免费存储25天,超过5RMB/方/天,61-180天 7RMB/方/每天,181天以上 15RMB/方/每天 英国:免费存储25天,超过8RMB/方/天,90-120/方/天 9.5RMB/方/天,超120天 15RMB/方/天', '订单处理费': '1:针对一个SKU发往一个地址作为一个订单收取15RMB订单操作费; 2:针对不同票数合并发往一个地址作为一个订单收取15RMB订单操作费; 出货之前都需要填写出货指令', '销毁费': '德国:单询 英国:50英镑/件', '换产品SKU费': '欧洲:针对一个箱里面是一种SKU要换标:开箱关箱费13RMB/箱换SKU:4RMB/个,清点:2RMB/个针对一箱多个SKU换标(费用单询) 英国:针对一个箱里面是一种SKU要换标:开箱关箱费20RMB/箱换SKU:6RMB/个,清点:3RMB/个针对一箱多个SKU换标(费用单询)'} + +# 德国仓亚马逊退货费用(单位:欧元) +DE_RETURN_FEES = [ + {'cat': '2. Outbound 出库', 'item': '2.1.1 Picking 整箱出库', 'sub': 'Single Carton 每箱', 'price': 2}, + {'cat': '4. In-House Service 库内服务', 'item': '4.4 Packing 打包费', 'sub': 'Per Carton 每箱', 'price': 1.5}, + {'cat': '4. In-House Service 库内服务', 'item': '4.5 Unpacking and Inspection 开箱检验', 'sub': 'Per Carton 每箱', 'price': 1}, + {'cat': '4. In-House Service 库内服务', 'item': '4.6 Destroy Product without Battery 不带电池产品销毁', 'sub': 'Per 1KG 每公斤', 'price': 1}, + {'cat': '4. In-House Service 库内服务', 'item': '4.7 Destroy battery 销毁电池', 'sub': 'Per 1KG 每公斤', 'price': 1.5}, + {'cat': '4. In-House Service 库内服务', 'item': '4.8 Cancel Order 截单费', 'sub': 'Per order 每单', 'price': 2}, + {'cat': '4. In-House Service 库内服务', 'item': '4.9 Claim Fee 认领费', 'sub': 'Every order 每个订单', 'price': 2}, + {'cat': '4. In-House Service 库内服务', 'item': '4.10 Lost Shipping Claim 丢件索赔服务费', 'sub': 'Per order 每单', 'price': 5}, +] + +# 产品附加费清单 +PRODUCT_SURCHARGES = [ + ('电池 标准箱100瓦以下非危电池/内电不超100瓦产品。头程参考海运快线快递单价标准', '资料齐全+1/KG 资料不齐全+2/KG'), + ('手机,平板电脑', '5/KG'), + ('铝制品', '2元/KG'), + ('瓷砖', '1元/KG'), + ('玻璃纤维网格织物、玻璃纤维长丝、聚酯高强力纱', '1元/KG'), + ('胶合板', '1元/KG'), + ('铜版纸', '1元/KG'), + ('钢丝绳和钢缆', '1元/KG'), + ('包包、U型枕、吊床,普通纺织品', '1元/KG(中欧卡航+2)'), + ('与接触食品类产品(厨具、榨汁机、擀面杖、杯子等)', '1元/KG'), + ('LED灯、白炽灯、钨丝灯类、电子温度计', '1元/KG'), + ('皮革制品', '1元/KG'), + ('球类', '1元/KG'), + ('木制品', '1元/KG'), + ('带磁产品', '1元/KG'), + ('蓝牙产品 无线产品 数据线', '1元/KG'), + ('金属及其制品:不锈钢无缝钢管耐腐蚀钢、铸铁产品、可锻铸铁螺纹管和接头、钢铁管配件、钢铁制紧固件、预应力非合金钢丝和钢绞线、活页夹的贱金属附件、铸铁井盖', '1元/KG'), + ('光伏、晶体硅光伏组件、太阳能板、眼镜', '2元/KG'), + ('玩具、沐浴盐球、碳包 蜡烛', '2元/KG'), + ('厨房刀具 (全长不超30CM)', '2元/KG'), + ('空气净化器', '2元/KG'), + ('工具箱', '2元/KG'), + ('眼镜类 笔类', '2元/KG'), + ('机顶盒 计时器(不包括大的钟表) 电子手环', '2元/KG'), + ('带陶瓷产品', '2元/KG'), + ('汽车/摩托车/自行车配件/平衡车/汽车配件', '2元/KG'), + ('卡客车轮胎、铝合金轮毂 、笔类', '2元/KG'), + ('投影仪', '2元/KG'), + ('成人用品、性用品、', '3元/KG'), + ('服装类(大衣,棉服,风衣,外套,斗篷,运动服饰,毛衫,T恤,衬衫,连体服饰,套装,裙子,骑行服),毛巾类,床上用品,和内衣内裤(海关编码61-65开头)', '3/KG(中欧卡航+5)'), + ('鞋类(包括塑料拖鞋、鞋垫)不包括鞋带', '3/KG(中欧卡航+5)'), + ('针织类的帽子、围巾、手套、披肩,窗帘', '3/KG(中欧卡航+5)'), + ('U盘/内存卡', '3元/个'), + ('各种码表 手表', '3.5元/个'), + ('车载DVD/车载显示屏/车载收音机/行车记录仪/行车导航仪', '80元/台'), + ('投影仪', '35元/台'), + ('平衡车/平板车(单个15KG以下)(超100瓦安时拒接)', '单询'), + ('平衡车/平板车(超100瓦安时拒接)', '单询'), + ('电动滑板车(单个15KG以下)(超100瓦安时拒接)', '单询'), + ('电动滑板车(超100瓦安时拒接)', '单询'), + ('平衡车/平板车/滑板车(单个20KG以上)', '单询'), + ('自行车、电动自行车、', '400/台(计费重超60KG拒收)'), + ('药物、化学产品及原料', '拒接'), + ('玩具车、积木类', '不侵权+2/KG'), + ('管制刀具,弓箭,枪支弹药及配件', '拒接'), + ('电子打火机、电子烟', '拒接'), + ('以上表格未列明的产品', '另外询价'), +] + +# DPD 欧洲岛屿偏远邮编(附加费 35 欧/件) +DPD_ISLAND_POSTCODES = {'丹麦': {'code': 'DK', 'text': '4592, 5960, 5970, 5985, 6720, 7884,8305,8592, 9940, 9950, 9960, Bornholm: 3700, 3720, 3730,3740,3751, 3760, 3770, 3782, 3790'}, '德国': {'code': 'DE', 'text': 'Hiddensee: 18565, NordfriesischeInseln:25849, 25859, 25863, 25869, 25938, 25946, 25980,25992,25996–25999, Ostfries. Inseln: 26465, 26474, 26486,26548,26571, 26579, 26757, Helgoland: 27498,27499,Chiemsee: 83256 Neuwerk:'}, '爱沙尼亚': {'code': 'EE', 'text': '62601, 88001–88005, 91217,91301–91320,92001–92420, 93001–94799, 96027, 96098'}, '芬兰': {'code': 'FI', 'text': 'Aland–Inseln: 22000–22999'}, '法国': {'code': 'FR', 'text': '17111, 17123, 17190, 17310, 17370,17410, 17480,17550, 17580, 17590, 17630, 17670, 17740, 17880,17940,22870, 29242, 29253, 29259, 29990, 56360, 56590,56780,56840, 85330, 85350, Korsika: 20000–20999'}, '希腊': {'code': 'GR', 'text': 'Saronische Inseln: 180xx, 189xx,Ionische Inseln: 28xxx–29xxx, 31xxx, 49xxx, Berg Athos:63086–63087,Kreta: 70xxx–74xxx und Inseln der Ägäis: 80xxx–85xxx'}, '意大利': {'code': 'IT', 'text': 'Pontinische Inseln: 04020, 04027, Sardinien:07000–09999, Lagune von Venedig: 30010, 30012, 30100,30121–30126, 30131–30133, 30135, 30141, Elba mitTyrrhenischeInseln: 57030–57039, 58012–58013, 58018, Tremiti Inseln:71040, Inseln im Golf von Neapel: 80070–80071, 80073–80077, 80079, Inselgruppen um Sizilien: 90010, 91017, 91023,92010, 98050, 98052, 98055Einige PLZ können Gebiete auf Inseln und dem Festlandbetreffen.'}, '克罗地亚': {'code': 'HR', 'text': '20221–20226, 20260–20263, 20264,20270– 20275, 20290, 21220, 21223–21225, 21400,21403–21405,21410, 21412, 21413, 21420, 21423–21426, 21430, 21432,21450, 21454, 21460, 21462–21463, 21465–21467, 21469,21480, 21483, 21485, 22231–22236, 22240–22244, 23212,23234, 23249–23251, 23262–23264, 23271–23275, 23281–23287, 23291–23296, 51280–51281, 51500, 51511–51517,51521–51523, 51550–51552, 51554–51557, 51559, 51561–51562, 51564, 53291, 53294, 53296'}, '荷兰': {'code': 'NL', 'text': '1156AA–1156ZZ, 1791AA–1797ZZ,8881AA– 8884ZZ, 8891AA–8897ZZ, 8899AA–8899ZZ,9161AA–9164ZZ, 9166AA–9166ZZ'}, '葡萄牙': {'code': 'PT', 'text': 'Madeira: 9000–9390, Porto Santo: 9400,Azoren: 9500–9980'}, '瑞典': {'code': 'SE', 'text': '10005, 13025, 13033, 13034, 13036,13038, 13039, 13042, 13043, 13055, 13056, 37022, 43080–43099,45204–45205, 47112–47115, 47500–47599, 62000–62999,74296–74297, 76017–76019, 91803'}, '西班牙': {'code': 'ES', 'text': 'Balearen: 07000–07999'}} diff --git a/logistics_chat/routes/__init__.py b/logistics_chat/routes/__init__.py new file mode 100644 index 0000000..4ebd026 --- /dev/null +++ b/logistics_chat/routes/__init__.py @@ -0,0 +1,7 @@ +"""Blueprint registration, called explicitly by the application factory.""" + + +def register_routes(app): + from . import admin, business, chat, files, history, human, pages + for module in (admin, business, chat, files, history, human, pages): + app.register_blueprint(module.bp) diff --git a/logistics_chat/routes/admin.py b/logistics_chat/routes/admin.py new file mode 100644 index 0000000..2c34de0 --- /dev/null +++ b/logistics_chat/routes/admin.py @@ -0,0 +1,31 @@ +"""HTTP adapters for admin; business logic lives in services.""" + +from flask import Blueprint, Response, render_template, request + +from .. import business_service + +bp = Blueprint('admin', __name__) + +@bp.route('/api/admin/stats') +def admin_stats(): + return business_service.admin_stats() + + +@bp.route('/admin/login', methods=['POST']) +def admin_login(): + return business_service.admin_login(request.json or {}) + + +@bp.route('/api/export_chat') +def export_chat(): + return Response(business_service.export_chat(), mimetype='text/csv', headers={'Content-Disposition': 'attachment;filename=chat_export.csv'}) + + +@bp.route('/admin') +def admin(): + return render_template('admin_login.html') + + +@bp.route('/admin/dashboard') +def admin_dash(): + return render_template('admin.html') diff --git a/logistics_chat/routes/business.py b/logistics_chat/routes/business.py new file mode 100644 index 0000000..3da2ff8 --- /dev/null +++ b/logistics_chat/routes/business.py @@ -0,0 +1,21 @@ +"""HTTP adapters for business; business logic lives in services.""" + +from flask import Blueprint, request + +from .. import business_service + +bp = Blueprint('business', __name__) + +@bp.route('/api/my_orders') +def my_orders(): + return business_service.my_orders(request.args.get('phone', '').strip()) + + +@bp.route('/api/ticket', methods=['POST']) +def add_ticket(): + return business_service.add_ticket(request.get_json() or {}) + + +@bp.route('/api/tickets') +def list_tickets(): + return business_service.list_tickets() diff --git a/logistics_chat/routes/chat.py b/logistics_chat/routes/chat.py new file mode 100644 index 0000000..31884b3 --- /dev/null +++ b/logistics_chat/routes/chat.py @@ -0,0 +1,29 @@ +"""HTTP adapters for chat; business logic lives in services.""" + +from flask import Blueprint, Response, request, stream_with_context + +from .. import business_service, chat_service + +bp = Blueprint('chat', __name__) + +@bp.route('/stream_chat', methods=['GET', 'POST']) +def stream_chat(): + if request.method == 'POST': + data = request.get_json(silent=True) or {} + msg = (data.get('msg') or '').strip() + 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) + 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) + + +@bp.route('/api/feedback', methods=['POST']) +def feedback(): + return business_service.feedback(request.get_json(silent=True) or {}) diff --git a/logistics_chat/routes/files.py b/logistics_chat/routes/files.py new file mode 100644 index 0000000..b743009 --- /dev/null +++ b/logistics_chat/routes/files.py @@ -0,0 +1,17 @@ +"""HTTP adapters for files; business logic lives in services.""" + +from flask import Blueprint, request, send_from_directory + +from .. import file_service +from ..config import get_setting + +bp = Blueprint('files', __name__) + +@bp.route('/api/upload', methods=['POST']) +def upload_file(): + return file_service.upload_file(request.files) + + +@bp.route('/uploads/') +def uploaded_file(name): + return send_from_directory(get_setting('UPLOAD_DIR'), name) diff --git a/logistics_chat/routes/history.py b/logistics_chat/routes/history.py new file mode 100644 index 0000000..e390be7 --- /dev/null +++ b/logistics_chat/routes/history.py @@ -0,0 +1,31 @@ +"""HTTP adapters for history; business logic lives in services.""" + +from flask import Blueprint, request + +from .. import history_service + +bp = Blueprint('history', __name__) + +@bp.route('/api/new_chat', methods=['POST']) +def new_chat(): + return history_service.new_chat() + + +@bp.route('/api/get_history') +def get_history(): + return history_service.get_history() + + +@bp.route('/api/get_messages') +def get_messages(): + return history_service.get_messages(request.args.get('conv_id', type=int)) + + +@bp.route('/api/del_history', methods=['DELETE']) +def del_history(): + return history_service.del_history(request.args.get('id', type=int)) + + +@bp.route('/api/clear_all', methods=['DELETE']) +def clear_all(): + return history_service.clear_all() diff --git a/logistics_chat/routes/human.py b/logistics_chat/routes/human.py new file mode 100644 index 0000000..69f8f8e --- /dev/null +++ b/logistics_chat/routes/human.py @@ -0,0 +1,56 @@ +"""HTTP adapters for human; business logic lives in services.""" + +from flask import Blueprint, current_app, request + +from .. import human_service + +bp = Blueprint('human', __name__) + +@bp.route('/api/human/request', methods=['POST']) +def human_request(): + return human_service.human_request(request.json) + + +@bp.route('/api/human/poll') +def human_poll(): + return human_service.human_poll(request.args.get('sid', type=int), request.args.get('after', 0, type=int), current_app.extensions['human_typing']) + + +@bp.route('/api/human/send', methods=['POST']) +def human_send(): + return human_service.human_send(request.json) + + +@bp.route('/api/human/queue') +def human_queue(): + return human_service.human_queue() + + +@bp.route('/api/human/active') +def human_active(): + return human_service.human_active() + + +@bp.route('/api/human/accept', methods=['POST']) +def human_accept(): + return human_service.human_accept(request.json) + + +@bp.route('/api/human/close', methods=['POST']) +def human_close(): + return human_service.human_close(request.json) + + +@bp.route('/api/human/typing', methods=['POST']) +def human_typing(): + return human_service.human_typing(request.json, current_app.extensions['human_typing']) + + +@bp.route('/api/human/stop_typing', methods=['POST']) +def human_stop_typing(): + return human_service.human_stop_typing(request.json, current_app.extensions['human_typing']) + + +@bp.route('/api/human/read', methods=['POST']) +def human_read(): + return human_service.human_read(request.json) diff --git a/logistics_chat/routes/pages.py b/logistics_chat/routes/pages.py new file mode 100644 index 0000000..77c7bb5 --- /dev/null +++ b/logistics_chat/routes/pages.py @@ -0,0 +1,10 @@ +"""HTTP adapters for pages; business logic lives in services.""" + +from flask import Blueprint, render_template + + +bp = Blueprint('pages', __name__) + +@bp.route('/') +def index(): + return render_template('index.html') diff --git a/logistics_chat/runtime.py b/logistics_chat/runtime.py new file mode 100644 index 0000000..deb07b2 --- /dev/null +++ b/logistics_chat/runtime.py @@ -0,0 +1,33 @@ +"""Explicit local serving entry point; importing it starts no server.""" + + +def local_ips(): + import socket + ips = set() + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as connection: + connection.connect(('8.8.8.8', 80)) + ips.add(connection.getsockname()[0]) + except Exception: + pass + try: + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): + ip = info[4][0] + if not ip.startswith('127.'): + ips.add(ip) + except Exception: + pass + return sorted(ips) + + +def serve_app(app): + host, port = app.config['HOST'], app.config['PORT'] + print(f'Local: http://127.0.0.1:{port}') + for ip in local_ips(): + print(f'LAN: http://{ip}:{port}') + try: + from waitress import serve + except ImportError: + app.run(host=host, port=port, debug=True, use_reloader=False) + else: + serve(app, host=host, port=port, threads=8) diff --git a/logistics_chat/tracking.py b/logistics_chat/tracking.py new file mode 100644 index 0000000..0687166 --- /dev/null +++ b/logistics_chat/tracking.py @@ -0,0 +1,78 @@ +"""Extracted tracking rules with unchanged business behavior.""" + +import json +import random +import re +from datetime import datetime, timedelta + +CARD_PREFIX = "__CARD__" + +def card_to_text(content): + """把存库的卡片标记转成人话,避免原始 JSON 进入模型上下文""" + if not content.startswith(CARD_PREFIX): + return content + try: + card = json.loads(content[len(CARD_PREFIX):]) + return f"(已为运单 {card.get('no', '')} 查询过轨迹,当前状态:{card.get('status', '')})" + except Exception: + return "(已为该运单查询过轨迹)" + +def is_tracking_no(text): + """判断一句话是不是"整句运单号" + + 规则(逐条收紧,避免误判): + 1. 去掉空格和横杠后,长度 8~20 位,且只能由字母数字组成 + 2. 至少包含 3 个数字(英文句子通常只有 1 个数字) + 3. 字母数量不能多于数字("i want to go 1t how money" 这类会被排除) + 纯数字单号(如 15252125)全部满足上述条件。 + """ + # 日期格式(如 2026-10-23、2026/10/23)不算运单号 + if re.fullmatch(r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}", text.strip()): + return False + t = re.sub(r"[\s\-]", "", text) + if not (8 <= len(t) <= 20): + return False + if not re.fullmatch(r"[A-Za-z0-9]+", t): + return False + digits = sum(c.isdigit() for c in t) + letters = sum(c.isalpha() for c in t) + if digits < 3: + return False + if letters > digits: + return False + return True + +def extract_tracking_no(text): + """从一句话里提取运单号:先看整句是不是,再从句子里找最长的字母数字串""" + t = text.strip() + if is_tracking_no(t): + return t + # 找句子里最长的 8~20 位字母数字连续串 + candidates = re.findall(r"[A-Za-z0-9]{8,20}", t) + best = None + for c in candidates: + if is_tracking_no(c): + if best is None or len(c) > len(best): + best = c + return best + +def build_track(no): + """生成固定的模拟轨迹:同一个单号每次查都一样""" + rnd = random.Random(no) + steps = [ + ("已揽收", "义乌仓"), + ("出口清关放行", "义乌/宁波口岸"), + ("已发车", "中欧班列/卡航 · 阿拉山口"), + ("到达欧洲中转仓", "波兰 · 华沙"), + ("欧盟清关放行", "德国 · 杜伊斯堡"), + ("派送中", "收件人所在城市"), + ] + gaps = [rnd.randint(6, 40) for _ in steps] # 相邻节点间隔(小时) + t = datetime.now() - timedelta(hours=sum(gaps)) + nodes = [] + for (text, place), gap in zip(steps, gaps): + t = t + timedelta(hours=gap) + nodes.append({"time": t.strftime("%Y-%m-%d %H:%M"), + "text": text, "place": place}) + nodes.reverse() # 最新的排最上面 + return {"no": no, "status": nodes[0]["text"], "nodes": nodes} diff --git a/rates_extra.py b/rates_extra.py index f201f4b..8a1a835 100644 --- a/rates_extra.py +++ b/rates_extra.py @@ -1,78 +1,6 @@ -# -*- coding: utf-8 -*- -"""报价表扩展数据(自动生成,来源:炜易达 VIP 10月15日9点执行报价表) +"""Compatibility exports for the relocated quotation data.""" -包含:海外仓卡派、二级补仓、德国仓亚马逊退货、产品附加费、DPD 岛屿偏远邮编。 -""" - -# 海外仓卡派:按 kg(21KG+/51KG+)或按方(0.5CBM+/1CBM+)计费 -# 价格单位:按 kg 为 RMB/kg,按方为 RMB/CBM -WAREHOUSE_CARD = {'谷仓法国巴黎1仓-95500-FR\n谷仓法国巴黎2仓-60440-FR\n谷仓法国巴黎4仓-77230-FR': {'卡航': {'kg21': 19.5, 'kg51': 18.5, 'cbm05': 3200.0, 'cbm1': 3020.0}, '普卡': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2920.0}, '铁路': {'kg21': 16.8, 'kg51': 15.8, 'cbm05': 2722.0, 'cbm1': 2542.0}, '海运': {'kg21': 12.5, 'kg51': 11.5, 'cbm05': 1952.0, 'cbm1': 1772.0}}, '4PX-69110-PL\n谷仓-69110-PL\nSLU2-69110-PL': {'卡航': {'kg21': 18.2, 'kg51': 17.2, 'cbm05': 2966.0, 'cbm1': 2786.0}, '普卡': {'kg21': 17.7, 'kg51': 16.7, 'cbm05': 2876.0, 'cbm1': 2686.0}, '铁路': {'kg21': 15.5, 'kg51': 14.5, 'cbm05': 2488.0, 'cbm1': 2308.0}, '海运': {'kg21': 11.1, 'kg51': 10.1, 'cbm05': 1700.0, 'cbm1': 1520.0}}, 'EDAPL-69100-PL': {'卡航': {'kg21': 18.6, 'kg51': 17.6, 'cbm05': 3038.0, 'cbm1': 2858.0}, '普卡': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2948.0, 'cbm1': 2758.0}, '铁路': {'kg21': 15.9, 'kg51': 14.9, 'cbm05': 2560.0, 'cbm1': 2380.0}, '海运': {'kg21': 11.5, 'kg51': 10.5, 'cbm05': 1772.0, 'cbm1': 1592.0}}, '4PX-66340-PL\n菜鸟仓-66340-PL': {'卡航': {'kg21': 18.9, 'kg51': 17.9, 'cbm05': 3092.0, 'cbm1': 2912.0}, '普卡': {'kg21': 18.4, 'kg51': 17.4, 'cbm05': 3002.0, 'cbm1': 2812.0}, '铁路': {'kg21': 16.2, 'kg51': 15.2, 'cbm05': 2614.0, 'cbm1': 2434.0}, '海运': {'kg21': 11.9, 'kg51': 10.9, 'cbm05': 1844.0, 'cbm1': 1664.0}}, '希音-44141-PL\n希音-55040-PL': {'卡航': {'kg21': 19.5, 'kg51': 18.5, 'cbm05': 3200.0, 'cbm1': 3020.0}, '普卡': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2920.0}, '铁路': {'kg21': 16.8, 'kg51': 15.8, 'cbm05': 2722.0, 'cbm1': 2542.0}, '海运': {'kg21': 12.5, 'kg51': 11.5, 'cbm05': 1952.0, 'cbm1': 1772.0}}, '谷仓捷克1仓-25261-CZ\n谷仓捷克2仓-25261-CZ\n谷仓捷克3仓-41002-CZ\n谷仓捷克4仓-25261-CZ\n谷仓捷克5仓-25219-CZ\n谷仓捷克7仓-25301-CZ\n谷仓捷克8仓-41002-CZ': {'卡航': {'kg21': 18.6, 'kg51': 17.6, 'cbm05': 3038.0, 'cbm1': 2858.0}, '普卡': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2948.0, 'cbm1': 2758.0}, '铁路': {'kg21': 15.9, 'kg51': 14.9, 'cbm05': 2560.0, 'cbm1': 2380.0}, '海运': {'kg21': 11.5, 'kg51': 10.5, 'cbm05': 1772.0, 'cbm1': 1592.0}}, '4PX布拉格A仓-25261-CZ': {'卡航': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2930.0}, '普卡': {'kg21': 18.5, 'kg51': 17.5, 'cbm05': 3020.0, 'cbm1': 2830.0}, '铁路': {'kg21': 16.3, 'kg51': 15.3, 'cbm05': 2632.0, 'cbm1': 2452.0}, '海运': {'kg21': 12.0, 'kg51': 11.0, 'cbm05': 1862.0, 'cbm1': 1682.0}}, '4PX马德里A仓-19171-ES': {'卡航': {'kg21': 20.8, 'kg51': 19.8, 'cbm05': 3434.0, 'cbm1': 3254.0}, '普卡': {'kg21': 20.3, 'kg51': 19.3, 'cbm05': 3344.0, 'cbm1': 3154.0}, '铁路': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2956.0, 'cbm1': 2776.0}, '海运': {'kg21': 13.7, 'kg51': 12.7, 'cbm05': 2168.0, 'cbm1': 1988.0}}, '盘古-63150-DE\n新欧达-31628-DE': {'卡航': {'kg21': 19.4, 'kg51': 18.4, 'cbm05': 3182.0, 'cbm1': 3002.0}, '普卡': {'kg21': 18.9, 'kg51': 17.9, 'cbm05': 3092.0, 'cbm1': 2902.0}, '铁路': {'kg21': 16.7, 'kg51': 15.7, 'cbm05': 2704.0, 'cbm1': 2524.0}, '海运': {'kg21': 12.3, 'kg51': 11.3, 'cbm05': 1916.0, 'cbm1': 1736.0}}} - -# 二级补仓(海外仓中转)主要服务费 -SECONDARY_WAREHOUSE = {'服务': '1.贴标:2张FBA标签,一张快递面单)单件计费重量范围在(0KG<X≤30KG) 2.打板:实际打板数为准(1个板大概1.2方-1.5方) 3.卸货费', '头程单价指示': '欧洲铁路头程单价:欧洲铁路渠道 中欧卡航头程单价:中欧卡航渠道 欧洲海运头程单价:海运快线/海运热门仓卡派直送 英国头程单价:英国专线(DPD)', '二次派送': '1. 欧洲/英国二级补仓无需二次派送费用,可自提单价-1/KG 2. 海外仓在德国,如需发往其他国家补运费差价', '仓储费': '德国:免费存储25天,超过5RMB/方/天,61-180天 7RMB/方/每天,181天以上 15RMB/方/每天 英国:免费存储25天,超过8RMB/方/天,90-120/方/天 9.5RMB/方/天,超120天 15RMB/方/天', '订单处理费': '1:针对一个SKU发往一个地址作为一个订单收取15RMB订单操作费; 2:针对不同票数合并发往一个地址作为一个订单收取15RMB订单操作费; 出货之前都需要填写出货指令', '销毁费': '德国:单询 英国:50英镑/件', '换产品SKU费': '欧洲:针对一个箱里面是一种SKU要换标:开箱关箱费13RMB/箱换SKU:4RMB/个,清点:2RMB/个针对一箱多个SKU换标(费用单询) 英国:针对一个箱里面是一种SKU要换标:开箱关箱费20RMB/箱换SKU:6RMB/个,清点:3RMB/个针对一箱多个SKU换标(费用单询)'} - -# 德国仓亚马逊退货费用(单位:欧元) -DE_RETURN_FEES = [ - {'cat': '2. Outbound 出库', 'item': '2.1.1 Picking 整箱出库', 'sub': 'Single Carton 每箱', 'price': 2}, - {'cat': '4. In-House Service 库内服务', 'item': '4.4 Packing 打包费', 'sub': 'Per Carton 每箱', 'price': 1.5}, - {'cat': '4. In-House Service 库内服务', 'item': '4.5 Unpacking and Inspection 开箱检验', 'sub': 'Per Carton 每箱', 'price': 1}, - {'cat': '4. In-House Service 库内服务', 'item': '4.6 Destroy Product without Battery 不带电池产品销毁', 'sub': 'Per 1KG 每公斤', 'price': 1}, - {'cat': '4. In-House Service 库内服务', 'item': '4.7 Destroy battery 销毁电池', 'sub': 'Per 1KG 每公斤', 'price': 1.5}, - {'cat': '4. In-House Service 库内服务', 'item': '4.8 Cancel Order 截单费', 'sub': 'Per order 每单', 'price': 2}, - {'cat': '4. In-House Service 库内服务', 'item': '4.9 Claim Fee 认领费', 'sub': 'Every order 每个订单', 'price': 2}, - {'cat': '4. In-House Service 库内服务', 'item': '4.10 Lost Shipping Claim 丢件索赔服务费', 'sub': 'Per order 每单', 'price': 5}, -] - -# 产品附加费清单 -PRODUCT_SURCHARGES = [ - ('电池 标准箱100瓦以下非危电池/内电不超100瓦产品。头程参考海运快线快递单价标准', '资料齐全+1/KG 资料不齐全+2/KG'), - ('手机,平板电脑', '5/KG'), - ('铝制品', '2元/KG'), - ('瓷砖', '1元/KG'), - ('玻璃纤维网格织物、玻璃纤维长丝、聚酯高强力纱', '1元/KG'), - ('胶合板', '1元/KG'), - ('铜版纸', '1元/KG'), - ('钢丝绳和钢缆', '1元/KG'), - ('包包、U型枕、吊床,普通纺织品', '1元/KG(中欧卡航+2)'), - ('与接触食品类产品(厨具、榨汁机、擀面杖、杯子等)', '1元/KG'), - ('LED灯、白炽灯、钨丝灯类、电子温度计', '1元/KG'), - ('皮革制品', '1元/KG'), - ('球类', '1元/KG'), - ('木制品', '1元/KG'), - ('带磁产品', '1元/KG'), - ('蓝牙产品 无线产品 数据线', '1元/KG'), - ('金属及其制品:不锈钢无缝钢管耐腐蚀钢、铸铁产品、可锻铸铁螺纹管和接头、钢铁管配件、钢铁制紧固件、预应力非合金钢丝和钢绞线、活页夹的贱金属附件、铸铁井盖', '1元/KG'), - ('光伏、晶体硅光伏组件、太阳能板、眼镜', '2元/KG'), - ('玩具、沐浴盐球、碳包 蜡烛', '2元/KG'), - ('厨房刀具 (全长不超30CM)', '2元/KG'), - ('空气净化器', '2元/KG'), - ('工具箱', '2元/KG'), - ('眼镜类 笔类', '2元/KG'), - ('机顶盒 计时器(不包括大的钟表) 电子手环', '2元/KG'), - ('带陶瓷产品', '2元/KG'), - ('汽车/摩托车/自行车配件/平衡车/汽车配件', '2元/KG'), - ('卡客车轮胎、铝合金轮毂 、笔类', '2元/KG'), - ('投影仪', '2元/KG'), - ('成人用品、性用品、', '3元/KG'), - ('服装类(大衣,棉服,风衣,外套,斗篷,运动服饰,毛衫,T恤,衬衫,连体服饰,套装,裙子,骑行服),毛巾类,床上用品,和内衣内裤(海关编码61-65开头)', '3/KG(中欧卡航+5)'), - ('鞋类(包括塑料拖鞋、鞋垫)不包括鞋带', '3/KG(中欧卡航+5)'), - ('针织类的帽子、围巾、手套、披肩,窗帘', '3/KG(中欧卡航+5)'), - ('U盘/内存卡', '3元/个'), - ('各种码表 手表', '3.5元/个'), - ('车载DVD/车载显示屏/车载收音机/行车记录仪/行车导航仪', '80元/台'), - ('投影仪', '35元/台'), - ('平衡车/平板车(单个15KG以下)(超100瓦安时拒接)', '单询'), - ('平衡车/平板车(超100瓦安时拒接)', '单询'), - ('电动滑板车(单个15KG以下)(超100瓦安时拒接)', '单询'), - ('电动滑板车(超100瓦安时拒接)', '单询'), - ('平衡车/平板车/滑板车(单个20KG以上)', '单询'), - ('自行车、电动自行车、', '400/台(计费重超60KG拒收)'), - ('药物、化学产品及原料', '拒接'), - ('玩具车、积木类', '不侵权+2/KG'), - ('管制刀具,弓箭,枪支弹药及配件', '拒接'), - ('电子打火机、电子烟', '拒接'), - ('以上表格未列明的产品', '另外询价'), -] - -# DPD 欧洲岛屿偏远邮编(附加费 35 欧/件) -DPD_ISLAND_POSTCODES = {'丹麦': {'code': 'DK', 'text': '4592, 5960, 5970, 5985, 6720, 7884,8305,8592, 9940, 9950, 9960, Bornholm: 3700, 3720, 3730,3740,3751, 3760, 3770, 3782, 3790'}, '德国': {'code': 'DE', 'text': 'Hiddensee: 18565, NordfriesischeInseln:25849, 25859, 25863, 25869, 25938, 25946, 25980,25992,25996–25999, Ostfries. Inseln: 26465, 26474, 26486,26548,26571, 26579, 26757, Helgoland: 27498,27499,Chiemsee: 83256 Neuwerk:'}, '爱沙尼亚': {'code': 'EE', 'text': '62601, 88001–88005, 91217,91301–91320,92001–92420, 93001–94799, 96027, 96098'}, '芬兰': {'code': 'FI', 'text': 'Aland–Inseln: 22000–22999'}, '法国': {'code': 'FR', 'text': '17111, 17123, 17190, 17310, 17370,17410, 17480,17550, 17580, 17590, 17630, 17670, 17740, 17880,17940,22870, 29242, 29253, 29259, 29990, 56360, 56590,56780,56840, 85330, 85350, Korsika: 20000–20999'}, '希腊': {'code': 'GR', 'text': 'Saronische Inseln: 180xx, 189xx,Ionische Inseln: 28xxx–29xxx, 31xxx, 49xxx, Berg Athos:63086–63087,Kreta: 70xxx–74xxx und Inseln der Ägäis: 80xxx–85xxx'}, '意大利': {'code': 'IT', 'text': 'Pontinische Inseln: 04020, 04027, Sardinien:07000–09999, Lagune von Venedig: 30010, 30012, 30100,30121–30126, 30131–30133, 30135, 30141, Elba mitTyrrhenischeInseln: 57030–57039, 58012–58013, 58018, Tremiti Inseln:71040, Inseln im Golf von Neapel: 80070–80071, 80073–80077, 80079, Inselgruppen um Sizilien: 90010, 91017, 91023,92010, 98050, 98052, 98055Einige PLZ können Gebiete auf Inseln und dem Festlandbetreffen.'}, '克罗地亚': {'code': 'HR', 'text': '20221–20226, 20260–20263, 20264,20270– 20275, 20290, 21220, 21223–21225, 21400,21403–21405,21410, 21412, 21413, 21420, 21423–21426, 21430, 21432,21450, 21454, 21460, 21462–21463, 21465–21467, 21469,21480, 21483, 21485, 22231–22236, 22240–22244, 23212,23234, 23249–23251, 23262–23264, 23271–23275, 23281–23287, 23291–23296, 51280–51281, 51500, 51511–51517,51521–51523, 51550–51552, 51554–51557, 51559, 51561–51562, 51564, 53291, 53294, 53296'}, '荷兰': {'code': 'NL', 'text': '1156AA–1156ZZ, 1791AA–1797ZZ,8881AA– 8884ZZ, 8891AA–8897ZZ, 8899AA–8899ZZ,9161AA–9164ZZ, 9166AA–9166ZZ'}, '葡萄牙': {'code': 'PT', 'text': 'Madeira: 9000–9390, Porto Santo: 9400,Azoren: 9500–9980'}, '瑞典': {'code': 'SE', 'text': '10005, 13025, 13033, 13034, 13036,13038, 13039, 13042, 13043, 13055, 13056, 37022, 43080–43099,45204–45205, 47112–47115, 47500–47599, 62000–62999,74296–74297, 76017–76019, 91803'}, '西班牙': {'code': 'ES', 'text': 'Balearen: 07000–07999'}} +from logistics_chat.rates_extra import ( + WAREHOUSE_CARD, SECONDARY_WAREHOUSE, DE_RETURN_FEES, + PRODUCT_SURCHARGES, DPD_ISLAND_POSTCODES, +) diff --git a/s.json b/s.json deleted file mode 100644 index 22f3245..0000000 --- a/s.json +++ /dev/null @@ -1 +0,0 @@ -{"sid":48,"side":"agent","content":"test123"} diff --git a/scripts/close_active_sessions.py b/scripts/close_active_sessions.py new file mode 100644 index 0000000..9018a60 --- /dev/null +++ b/scripts/close_active_sessions.py @@ -0,0 +1,34 @@ +"""Close all active human sessions; waiting sessions and messages are preserved.""" + +import argparse +import sqlite3 +from contextlib import closing +from pathlib import Path + + +DEFAULT_DB = Path(__file__).resolve().parents[1] / "history.db" + + +def close_active_sessions(db_path=None): + database = Path(db_path if db_path is not None else DEFAULT_DB).expanduser().resolve() + with closing(sqlite3.connect(database.as_uri() + "?mode=rw", uri=True)) as connection: + with connection: + count = connection.execute( + "UPDATE human_sessions SET status='closed' WHERE status='active'" + ).rowcount + return count + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="Existing SQLite database") + args = parser.parse_args() + try: + count = close_active_sessions(args.db) + except sqlite3.Error as error: + parser.exit(1, f"Database error: {error}\n") + print(f"Closed {count} active session(s).") + + +if __name__ == "__main__": + main() diff --git a/scripts/inspect_human_sessions.py b/scripts/inspect_human_sessions.py new file mode 100644 index 0000000..2b1bb35 --- /dev/null +++ b/scripts/inspect_human_sessions.py @@ -0,0 +1,38 @@ +"""Inspect recent human messages and open sessions without changing the database.""" + +import argparse +import sqlite3 +from contextlib import closing +from pathlib import Path + + +DEFAULT_DB = Path(__file__).resolve().parents[1] / "history.db" + + +def inspect_sessions(db_path=None): + database = Path(db_path if db_path is not None else DEFAULT_DB).expanduser().resolve() + with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection: + messages = connection.execute( + "SELECT * FROM human_messages ORDER BY id DESC LIMIT 5" + ).fetchall() + sessions = connection.execute( + "SELECT id, status FROM human_sessions WHERE status!='closed' ORDER BY id" + ).fetchall() + return messages, sessions + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="Existing SQLite database") + args = parser.parse_args() + try: + messages, sessions = inspect_sessions(args.db) + except sqlite3.Error as error: + parser.exit(1, f"Database error: {error}\n") + print(messages) + print("---") + print(sessions) + + +if __name__ == "__main__": + main() diff --git a/start.bat b/start.bat index 161700e..ad2a3ed 100644 --- a/start.bat +++ b/start.bat @@ -1,21 +1,46 @@ @echo off -chcp 936 >nul -title Baiyue Logistics AI Customer Service +setlocal DisableDelayedExpansion cd /d "%~dp0" +set "PYTHON_ARGS=" +if defined VIRTUAL_ENV ( + set "PYTHON_EXE=%VIRTUAL_ENV%\Scripts\python.exe" + call :probe_python + if not errorlevel 1 goto python_ready +) + +set "PYTHON_EXE=%~dp0.venv\Scripts\python.exe" +call :probe_python +if not errorlevel 1 goto python_ready + +set "PYTHON_EXE=py.exe" +set "PYTHON_ARGS=-3" +call :probe_python +if not errorlevel 1 goto python_ready + +set "PYTHON_EXE=python.exe" +set "PYTHON_ARGS=" +call :probe_python +if not errorlevel 1 goto python_ready + +echo [ERROR] No working Python 3 interpreter found. Install Python or recreate .venv. +if /i "%~1"=="--check-python" exit /b 1 +pause +exit /b 1 + +:python_ready +if /i "%~1"=="--check-python" ( + "%PYTHON_EXE%" %PYTHON_ARGS% -c "import sys; print(sys.executable)" + exit /b +) + +chcp 936 >nul +title Baiyue Logistics AI Customer Service echo ============================================ echo Baiyue Logistics AI Customer Service echo ============================================ echo. -where python >nul 2>nul -if errorlevel 1 ( - echo [ERROR] python not found. Install Python and tick "Add to PATH". - echo. - pause - exit /b 1 -) - echo [1/3] Checking port 5000 ... netstat -ano | findstr "LISTENING" | findstr /C:":5000 " >nul if not errorlevel 1 ( @@ -35,9 +60,10 @@ if not errorlevel 1 ( echo OK - port is free. echo [2/3] Checking DEEPSEEK_API_KEY ... -if "%DEEPSEEK_API_KEY%"=="" ( +if not defined DEEPSEEK_API_KEY ( echo. echo [WARN] DEEPSEEK_API_KEY is empty in this window. + echo .env files are not loaded automatically. echo If you just ran setx, reopen this window or restart VS Code. echo setx DEEPSEEK_API_KEY "sk-xxxxxxxx" echo. @@ -47,10 +73,10 @@ if "%DEEPSEEK_API_KEY%"=="" ( ) echo [3/3] Checking dependencies ... -python -c "import flask, requests" >nul 2>nul +"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress" >nul 2>nul if errorlevel 1 ( echo Missing packages, installing ... - python -m pip install -r requirements.txt + "%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt if errorlevel 1 ( echo [ERROR] pip install failed, check network and retry. pause @@ -67,8 +93,15 @@ echo ============================================ echo. start "" http://127.0.0.1:5000 -python am.py +"%PYTHON_EXE%" %PYTHON_ARGS% am.py +set "APP_EXIT_CODE=%ERRORLEVEL%" echo. echo Server stopped. -pause \ No newline at end of file +pause +exit /b %APP_EXIT_CODE% + +:probe_python +"%PYTHON_EXE%" %PYTHON_ARGS% -c "import sys; sys.exit(0 if sys.version_info.major == 3 else 1)" >nul 2>nul +if "%ERRORLEVEL%"=="0" exit /b 0 +exit /b 1 diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..a4ec9d6 --- /dev/null +++ b/static/css/admin.css @@ -0,0 +1,186 @@ +/* A conversation-first console: compact header, session inbox and full-height chat. */ +body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--surface); } +.topbar { height: 56px; min-height: 56px; display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 22px; background: var(--surface); border-bottom: 1px solid var(--border); z-index: 30; } +.app-identity, .topbar-actions { display: flex; align-items: center; gap: 20px; min-width: 0; } +.brand { gap: 10px; } +.topbar .brand-mark { width: 30px; height: 30px; border-radius: 8px; } +.topbar .brand-mark svg { width: 19px; height: 19px; } +.topbar .brand-name { font-size: 15px; white-space: nowrap; } +.nav-divider { height: 18px; width: 1px; background: var(--border); } +.app-identity h1 { font-size: 14px; font-weight: 500; color: var(--secondary); white-space: nowrap; } +.workspace-link { font-size: 12px; color: var(--secondary); text-decoration: none; white-space: nowrap; } +.workspace-link:hover { color: var(--brand); } +.data-overview { position: relative; } +.data-overview summary { display: flex; align-items: center; gap: 7px; padding: 6px 10px; font-size: 12px; color: var(--secondary); cursor: pointer; border-radius: 7px; list-style: none; border: 1px solid var(--border); } +.data-overview summary::-webkit-details-marker { display: none; } +.data-overview summary svg { width: 15px; height: 15px; } +.data-overview[open] summary, .data-overview summary:hover { color: var(--brand); border-color: #bacbf2; background: var(--brand-soft); } +.stats { position: absolute; top: calc(100% + 11px); right: 0; width: 310px; padding: 18px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); box-shadow: 0 12px 40px #19263d1a; } +.stats h2 { grid-column: 1 / -1; font-size: 13px; font-weight: 600; margin-bottom: 3px; } +.stat { display: flex; flex-direction: column; padding: 10px 12px; background: var(--surface-soft); border-radius: 7px; } +.stat > span { font-size: 11px; color: var(--secondary); } +.stat b { font-size: 23px; font-weight: 600; line-height: 1.5; font-variant-numeric: tabular-nums; } +.export-link { display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; font-size: 12px; color: var(--brand); text-decoration: none; } +.export-link:hover { background: var(--brand-soft); } +.sync-status { flex-shrink: 0; background: var(--danger-soft); color: var(--danger); border-bottom: 1px solid #eed0d3; padding: 7px 22px; font-size: 12px; } +.layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 300px minmax(0, 1fr); overflow: hidden; } +.conv-list { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); } +.list-heading { padding: 23px 20px 18px; } +.list-heading h2 { font-size: 17px; font-weight: 650; } +.list-heading p { font-size: 12px; color: var(--muted); margin-top: 4px; } +.session-search-wrap { position: relative; margin-top: 17px; } +.session-search-wrap svg { position: absolute; top: 12px; left: 11px; width: 15px; height: 15px; color: var(--muted); pointer-events: none; } +#session-search { width: 100%; height: 38px; padding: 8px 10px 8px 34px; border: 1px solid transparent; border-radius: 7px; background: var(--surface-soft); color: var(--text); font-size: 13px; } +#session-search::placeholder { color: var(--muted); } +#session-search:focus { outline: none; border-color: #9ab4ed; background: var(--surface); box-shadow: 0 0 0 3px var(--brand-soft); } +.session-tabs { display: flex; gap: 22px; padding: 0 20px; border-bottom: 1px solid var(--border); } +.session-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 43px; padding: 7px 0 12px; border: 0; background: transparent; color: var(--secondary); font-size: 13px; } +.session-tabs button[aria-selected="true"] { color: var(--brand); font-weight: 600; } +.session-tabs button[aria-selected="true"]::after { position: absolute; content: ""; height: 2px; background: var(--brand); inset: auto 0 -1px; } +.section-count { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 5px; color: var(--muted); background: var(--surface-soft); font-size: 10px; font-variant-numeric: tabular-nums; } +[aria-selected="true"] .section-count { background: var(--brand-soft); color: var(--brand); } +.session-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; } +.list-empty { padding: 33px 14px; text-align: center; font-size: 12px; line-height: 1.8; color: var(--muted); } +.conv-item { display: flex; align-items: center; gap: 11px; width: 100%; padding: 14px 12px; margin-bottom: 4px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; } +.conv-item:hover { background: var(--surface-soft); } +.conv-item.active { background: #edf3ff; border-color: #e3ebfc; } +.avatar { flex-shrink: 0; display: grid; place-items: center; width: 39px; height: 39px; border-radius: 11px; color: #60728f; background: #eaf0f7; font-size: 14px; font-weight: 600; } +.active .avatar { color: var(--brand); background: #dce7fe; } +.conv-item .info { min-width: 0; flex: 1; } +.conv-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.conv-item .name { display: block; font-size: 14px; font-weight: 600; color: var(--text); white-space: nowrap; } +.conv-time { flex-shrink: 0; color: var(--muted); font-size: 10px; font-weight: 400; } +.conv-item .preview { display: block; margin-top: 5px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--secondary); font-size: 12px; } +.badge { flex-shrink: 0; padding: 0 5px; border-radius: 5px; background: var(--brand); color: white; font-size: 10px; } +.queue-item { border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 8px; background: var(--surface); } +.queue-item .qt { font-size: 13px; font-weight: 600; color: var(--text); } +.queue-item small { display: block; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 5px; } +.queue-item button { margin-top: 13px; padding: 7px 12px; min-height: 34px; width: 100%; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 12px; } +.list-footer { flex-shrink: 0; display: flex; align-items: center; gap: 7px; padding: 14px 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; } +.list-footer-mark { width: 6px; height: 6px; border-radius: 50%; background: #91a2bc; } +.list-footer > span { display: flex; align-items: center; gap: 7px; } +#tools-from-list { display: none; } +.chat-area { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: #f5f7fa; } +.chat-header { display: flex; flex-shrink: 0; align-items: center; gap: 12px; min-height: 72px; padding: 13px 28px; background: var(--surface); border-bottom: 1px solid var(--border); } +.participant-avatar { display: grid; place-items: center; flex-shrink: 0; width: 38px; height: 38px; border-radius: 10px; background: #edf2fa; color: #6a7b96; font-size: 14px; } +.participant-copy { flex: 1; min-width: 0; } +.participant-copy h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 650; } +.participant-copy p { margin-top: 2px; color: var(--muted); font-size: 11px; } +.has-active-session .participant-copy p { color: #4b796b; } +.chat-header-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } +.tools-toggle, .close { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 33px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; background: var(--surface); color: var(--secondary); white-space: nowrap; } +.tools-toggle svg { width: 16px; height: 16px; } +.tools-toggle[aria-expanded="true"], .tools-toggle:hover { border-color: #bacbf2; color: var(--brand); background: var(--brand-soft); } +.close:hover:enabled { border-color: #ecc5ca; background: var(--danger-soft); color: var(--danger); } +.close:disabled { cursor: default; opacity: .45; } +.has-active-session .participant-avatar { color: var(--brand); background: var(--brand-soft); } +body:not(.has-active-session) .close { display: none; } +.empty { flex: 1; display: grid; place-items: center; text-align: center; min-height: 0; padding: 30px; } +.empty-illustration { display: grid; place-items: center; width: 52px; height: 52px; margin: 0 auto 18px; border: 1px solid #e0e6ee; border-radius: 15px; background: var(--surface); color: #879bb9; } +.empty-illustration svg { width: 26px; height: 26px; } +.empty h2 { margin-bottom: 8px; font-size: 18px; font-weight: 600; } +.empty p { font-size: 13px; line-height: 1.9; color: var(--muted); } +.session-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } +#chatBox { flex: 1; min-height: 0; overflow-y: auto; padding: 26px clamp(22px, 4vw, 60px); } +.msg { display: flex; align-items: flex-start; gap: 10px; max-width: 1040px; margin: 0 auto 24px; } +.msg.agent { flex-direction: row-reverse; } +.message-avatar { flex-shrink: 0; display: grid; place-items: center; width: 32px; height: 32px; margin-top: 24px; border-radius: 9px; background: #e6ecf4; color: #6e7e97; font-size: 12px; } +.message-avatar svg { width: 17px; height: 17px; } +.msg.agent .message-avatar { background: var(--brand); color: white; } +.message-stack { max-width: min(76%, 720px); min-width: 0; } +.meta { margin-bottom: 6px; font-size: 11px; line-height: 1.5; color: var(--muted); } +.msg.agent .meta { text-align: right; } +.bubble { padding: 12px 16px; border-radius: 3px 10px 10px; border: 1px solid #e4e9f0; background: var(--surface); color: var(--text); font-size: 15px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; } +.msg.agent .bubble { background: #eaf1ff; border-color: #dce6fb; color: #213858; border-radius: 10px 3px 10px 10px; } +.message-image { display: block; max-width: min(240px, 100%); max-height: 320px; object-fit: contain; border-radius: 6px; } +.message-file { color: inherit; text-underline-offset: 3px; font-size: 13px; } +.agent-composer { flex-shrink: 0; padding: 13px 28px 16px; background: var(--surface); border-top: 1px solid var(--border); } +.composer-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; color: var(--secondary); font-size: 12px; } +.composer-heading span { color: var(--muted); font-size: 11px; } +.input-bar { padding: 10px 12px 9px; border: 1px solid #dce3ed; border-radius: 8px; background: var(--surface); } +.input-bar:focus-within { border-color: #8ba8eb; box-shadow: 0 0 0 2px var(--brand-soft); } +#replyText { display: block; width: 100%; min-height: 56px; max-height: 180px; padding: 0; border: 0; resize: none; outline: none; background: transparent; color: var(--text); font-size: 14px; line-height: 1.7; } +#replyText::placeholder { color: #8b98ab; } +.input-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 7px; } +.input-actions > span { color: var(--muted); font-size: 11px; } +#reply-send { min-width: 78px; min-height: 34px; padding: 6px 20px; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 13px; } +#reply-send:hover:enabled { background: var(--brand-hover); } +.reply-status { font-size: 12px; line-height: 1.6; color: var(--success); padding-top: 7px; } +.reply-status.is-error { color: var(--danger); } +.side { display: none; min-height: 0; min-width: 0; overflow-y: auto; background: var(--surface); border-left: 1px solid var(--border); } +.tools-open .layout { grid-template-columns: 300px minmax(0, 1fr) 310px; } +.tools-open .side { display: block; } +.side-heading { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px; border-bottom: 1px solid var(--border); background: var(--surface); } +.side-heading h2 { font-size: 15px; font-weight: 600; } +.side-heading .icon-button { width: 28px; height: 28px; border: 0; font-size: 20px; } +.card { padding: 20px 18px; border-bottom: 1px solid var(--border); } +.card:last-child { border-bottom: 0; } +.card h3 { margin-bottom: 13px; font-size: 13px; font-weight: 600; } +.card-caption { color: var(--muted); font-size: 11px; margin: -5px 0 12px; } +.info-row { font-size: 12px; line-height: 1.8; color: var(--secondary); } +.info-hint { margin-top: 13px; padding: 10px; font-size: 11px; line-height: 1.9; background: var(--surface-soft); color: var(--secondary); border-radius: 6px; } +.quick { display: block; width: 100%; margin-bottom: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; text-align: left; background: var(--surface); color: var(--secondary); font-size: 12px; } +.quick:hover:enabled { border-color: #a5bdee; color: var(--brand); background: var(--brand-soft); } +.quick:disabled { cursor: default; opacity: .5; } +.ticket-item { padding: 11px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--secondary); font-size: 12px; line-height: 1.8; overflow-wrap: anywhere; } +.ticket-item span { display: block; margin-top: 6px; font-size: 10px; color: var(--muted); } +.tools-backdrop, .sessions-back { display: none; } +@media (max-width: 1200px) { + .layout, .tools-open .layout { grid-template-columns: 280px minmax(0, 1fr); } + .side { display: block; position: fixed; top: 0; bottom: 0; right: 0; z-index: 51; width: min(330px, 88vw); border-left: 1px solid var(--border); transform: translateX(105%); visibility: hidden; box-shadow: -10px 0 40px #19263d22; transition: transform .2s; } + .tools-open .side { transform: translateX(0); visibility: visible; } + .tools-open .tools-backdrop { display: block; position: fixed; inset: 0; z-index: 50; border: 0; background: #19263d55; } + .chat-header { padding-left: 22px; padding-right: 22px; } + .agent-composer { padding-left: 22px; padding-right: 22px; } +} +@media (max-width: 900px) and (min-width: 721px) { + .layout, .tools-open .layout { grid-template-columns: 248px minmax(0, 1fr); } + .list-heading { padding-left: 16px; padding-right: 16px; } + .session-tabs { padding-left: 16px; padding-right: 16px; gap: 17px; } + .tools-toggle span { display: none; } + #chatBox { padding-left: 18px; padding-right: 18px; } +} +@media (max-width: 720px) { + .topbar { height: 52px; min-height: 52px; padding: 0 16px; } + .app-identity h1, .nav-divider { display: none; } + .topbar .brand-name { font-size: 14px; } + .topbar-actions { gap: 12px; } + .data-overview summary { font-size: 11px; padding: 5px 8px; } + .stats { width: min(310px, calc(100vw - 32px)); } + .layout, .tools-open .layout { display: flex; flex-direction: column; } + .conv-list { flex: 1; min-height: 0; border-right: 0; } + .list-footer { justify-content: space-between; } + #tools-from-list { display: inline-block; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--secondary); font-size: 11px; } + .list-heading { padding: 20px 18px 15px; } + .session-tabs { padding-left: 18px; } + .list-heading h2 { font-size: 18px; } + .conv-item { padding-top: 16px; padding-bottom: 16px; } + .chat-area { display: none; flex: 1; min-height: 0; } + .chat-view .conv-list { display: none; } + .chat-view .chat-area { display: flex; } + .sessions-back { display: grid; place-items: center; flex-shrink: 0; width: 30px; height: 34px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--secondary); font-size: 28px; line-height: 1; } + .participant-avatar { display: none; } + .chat-header { min-height: 65px; padding: 11px 13px 11px 9px; gap: 8px; } + .participant-copy h2 { font-size: 14px; } + .participant-copy p { font-size: 10px; } + .chat-header-actions { gap: 7px; } + .tools-toggle { width: 33px; padding: 6px; } + .tools-toggle span { display: none; } + .close { padding: 6px 8px; font-size: 11px; } + #chatBox { padding: 21px 14px; } + .msg { gap: 7px; margin-bottom: 20px; } + .message-avatar { width: 27px; height: 27px; border-radius: 8px; } + .message-stack { max-width: calc(100% - 40px); } + .bubble { padding: 10px 12px; font-size: 15px; } + .agent-composer { padding: 11px 13px calc(11px + env(safe-area-inset-bottom)); } + .composer-heading { margin-bottom: 6px; font-size: 11px; } + .composer-heading span { font-size: 10px; } + #replyText { font-size: 16px; min-height: 54px; } + .input-actions > span { font-size: 10px; max-width: 210px; } + #reply-send { min-width: 66px; min-height: 38px; padding: 7px 14px; font-size: 12px; } + .reply-status { font-size: 11px; } +} +@media (max-width: 420px) { + .workspace-link { display: none; } + .input-actions > span { max-width: 185px; } +} diff --git a/static/css/admin_login.css b/static/css/admin_login.css new file mode 100644 index 0000000..e9804d7 --- /dev/null +++ b/static/css/admin_login.css @@ -0,0 +1,57 @@ +/* Split welcome and sign-in card, with a compact mobile fallback. */ +.auth-page { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 36px; background: #edf2f8; } +.auth-shell { width: min(1050px, 100%); min-height: 620px; display: grid; grid-template-columns: 1.08fr 1fr; overflow: hidden; border: 1px solid #dde5ef; border-radius: 19px; box-shadow: 0 24px 75px rgba(31, 53, 87, .1); background: white; } +.auth-story { position: relative; display: flex; flex-direction: column; padding: 38px 40px; background: #17345e; color: white; overflow: hidden; } +.auth-story::before { content: ""; position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size: 40px 40px; pointer-events: none; } +.auth-story > * { position: relative; } +.auth-story .brand { color: white; } +.auth-story .brand-mark { background: #315c9c; } +.auth-story .brand-caption { color: #a9bedb; } +.story-copy { margin-top: 65px; } +.story-copy .eyebrow { color: #a7c3eb; font-size: 11px; } +.story-copy h1 { font-size: 30px; font-weight: 550; line-height: 1.55; letter-spacing: -.03em; margin: 15px 0 14px; } +.story-copy > p { color: #b9cbe3; font-size: 13px; line-height: 1.95; max-width: 340px; } +.service-points { display: grid; gap: 17px; margin-top: 34px; } +.service-point { display: flex; align-items: flex-start; gap: 12px; } +.service-point > span { width: 29px; height: 29px; border: 1px solid #49658b; color: #c8d9f0; border-radius: 8px; display: grid; place-items: center; font-size: 13px; } +.service-point strong { display: block; color: #eaf2ff; font-size: 12px; font-weight: 550; } +.service-point p { color: #aebfd8; font-size: 11px; margin-top: 2px; } +.route-line { display: flex; align-items: center; gap: 12px; margin-top: auto; padding-top: 40px; font-size: 11px; color: #afc6e5; } +.route-line i { height: 1px; flex: 1; background: #506e97; position: relative; } +.route-line i::after { content: ""; position: absolute; right: 0; top: -2px; border-radius: 50%; width: 5px; height: 5px; background: #96b6e2; } +.auth-content { display: flex; flex-direction: column; justify-content: center; padding: 48px 50px 28px; } +.box { width: 100%; max-width: 330px; margin: auto; } +.box .eyebrow { margin-bottom: 10px; } +.box h2 { color: var(--text); font-size: 27px; font-weight: 650; letter-spacing: -.03em; } +.login-description { font-size: 12px; color: var(--muted); margin: 9px 0 30px; line-height: 1.8; } +.field-label { display: block; color: var(--secondary); font-size: 12px; font-weight: 550; margin: 18px 0 8px; } +.field-wrap { position: relative; } +.field-wrap svg { position: absolute; left: 13px; top: 14px; width: 17px; height: 17px; color: var(--muted); pointer-events: none; } +.box input { width: 100%; height: 45px; border: 1px solid var(--border); border-radius: 8px; color: var(--text); background: white; padding: 10px 13px 10px 40px; outline: none; font-size: 13px; } +.box input:focus { border-color: #8ba7e8; box-shadow: 0 0 0 3px var(--brand-soft); } +.box input::placeholder { color: var(--muted); } +.box input[aria-invalid="true"] { border-color: #df8d98; } +#loginButton { display: block; width: 100%; height: 44px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 13px; font-weight: 600; margin-top: 24px; box-shadow: 0 4px 12px rgba(49,95,213,.16); } +#loginButton:hover { background: var(--brand-hover); } +.err { color: var(--danger); background: var(--danger-soft); border-radius: 7px; font-size: 12px; line-height: 1.7; padding: 9px 11px; margin-top: 14px; } +.login-back { display: block; text-align: center; font-size: 12px; color: var(--muted); text-decoration: none; margin-top: 20px; } +.login-back:hover { color: var(--brand-text); } +.auth-footer { color: var(--muted); text-align: center; font-size: 10px; margin-top: 30px; } +.mobile-brand { display: none; } +@media (max-width: 900px) { + .auth-page { padding: 24px; } + .auth-shell { min-height: 590px; grid-template-columns: 1fr 1fr; } + .auth-story { padding: 30px; } + .story-copy h1 { font-size: 27px; } + .story-copy { margin-top: 50px; } + .auth-content { padding: 38px 30px 25px; } +} +@media (max-width: 650px) { + .auth-page { padding: 20px; } + .auth-shell { display: block; max-width: 430px; min-height: 0; border-radius: 14px; } + .auth-story { display: none; } + .auth-content { padding: 30px 28px 22px; } + .mobile-brand { display: flex; margin-bottom: 38px; } + .box h2 { font-size: 25px; } + .auth-footer { margin-top: 35px; } +} diff --git a/static/css/chat.css b/static/css/chat.css new file mode 100644 index 0000000..23f60e0 --- /dev/null +++ b/static/css/chat.css @@ -0,0 +1,240 @@ +/* Customer workspace: navigation, welcome, conversation and composer. */ +body { padding: 20px; height: 100dvh; overflow: hidden; } +.chat-container { + display: flex; width: min(1440px, 100%); height: 100%; margin: auto; + overflow: hidden; position: relative; border: 1px solid var(--border); + border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); +} +.left-panel { + width: 248px; min-width: 0; flex-shrink: 0; display: flex; flex-direction: column; + padding: 28px 18px 18px; background: var(--surface-soft); border-right: 1px solid var(--border); + transition: width .2s, padding .2s, transform .2s; overflow: hidden; +} +.left-panel .brand { margin: 0 7px 30px; white-space: nowrap; } +.new-chat-btn { + min-height: 43px; width: 100%; padding: 10px; border: 0; border-radius: 9px; + color: white; background: var(--brand); font-weight: 600; white-space: nowrap; + box-shadow: 0 3px 8px rgba(49, 95, 213, .13); +} +.new-chat-btn::before { content: "+"; font-size: 20px; font-weight: 400; margin-right: 9px; line-height: 1; } +.new-chat-btn:hover { background: var(--brand-hover); } +.panel-title { color: var(--secondary); font-size: 12px; font-weight: 600; margin: 29px 9px 10px; letter-spacing: .05em; } +.history-search-wrap { position: relative; margin-bottom: 13px; } +.history-search-wrap svg { position: absolute; left: 12px; top: 12px; width: 15px; height: 15px; color: var(--muted); pointer-events: none; } +.history-search { + width: 100%; height: 38px; padding: 8px 10px 8px 35px; border: 1px solid var(--border); + border-radius: 8px; color: var(--text); background: var(--surface); font-size: 12px; +} +.history-search::placeholder { color: var(--muted); } +.history-search:focus { border-color: var(--brand); outline: none; box-shadow: 0 0 0 3px var(--brand-soft); } +.history-wrap { flex: 1; min-height: 0; overflow-y: auto; margin: 0 -5px; padding: 0 3px; } +.history-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 34px 5px; } +.history-empty::before { content: "◌"; display: block; font-size: 26px; color: var(--secondary); margin-bottom: 7px; } +.history-item { display: flex; align-items: center; gap: 3px; padding: 11px 9px; margin-bottom: 4px; border-radius: 9px; border: 1px solid transparent; cursor: pointer; } +.history-item:hover { background: var(--surface); border-color: var(--border); } +.history-item.active { background: var(--brand-soft); border-color: var(--border); } +.history-text-wrap { flex: 1; min-width: 0; } +.history-title { color: var(--text); font-size: 13px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.history-time { color: var(--muted); font-size: 10px; margin-top: 4px; } +.del-history-btn { flex-shrink: 0; width: 23px; height: 25px; padding: 0; border: 0; background: transparent; color: var(--muted); border-radius: 5px; font-size: 13px; opacity: 0; } +.history-item:hover .del-history-btn, .history-item:focus-within .del-history-btn { opacity: 1; } +.del-history-btn:hover { background: var(--surface-hover); color: var(--danger); } +.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; } +.clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; } +.clear-all-btn:hover { color: var(--danger); } +.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; } +.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; } +.main-chat { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; } +.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; } +.header-copy { min-width: 0; flex: 1; } +.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; } +#app-title { display: block; color: var(--text); font-weight: 650; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.toggle-divider { width: 32px; height: 32px; padding: 0; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); font-size: 0; } +.toggle-divider::before { content: "☰"; font-size: 16px; } +.toggle-divider:hover { background: var(--surface-soft); color: var(--brand-text); } +.title-actions { display: flex; gap: 7px; align-items: center; flex-shrink: 0; } +.theme-toggle { height: 32px; min-width: 33px; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 11px; } +.theme-toggle:hover { border-color: var(--brand); color: var(--brand-text); background: var(--brand-soft); } +.service-label { display: flex; gap: 7px; align-items: center; color: var(--secondary); font-size: 11px; margin-right: 8px; } +#chatDom { flex: 1; min-height: 0; overflow: auto; overflow-x: hidden; padding: 30px 38px 20px; } +.ai-welcome-card, .hot-questions, .recent-chat { max-width: 760px; margin-left: auto; margin-right: auto; } +.ai-welcome-card { padding-top: 22px; } +.ai-welcome-top { display: flex; flex-direction: column; align-items: center; text-align: center; } +.ai-avatar { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; color: var(--brand-text); background: var(--brand-soft); margin-bottom: 18px; } +.ai-avatar svg { width: 27px; height: 27px; } +.welcome-eyebrow { color: var(--brand-text); font-size: 11px; font-weight: 600; letter-spacing: .06em; margin-bottom: 9px; } +.ai-welcome-card h3 { font-size: clamp(22px, 2.2vw, 30px); line-height: 1.4; letter-spacing: -.035em; font-weight: 650; color: var(--text); } +.ai-welcome-card p { color: var(--secondary); font-size: 13px; line-height: 1.8; margin-top: 10px; } +.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0 0; } +.suggest-item { display: flex; align-items: center; gap: 13px; min-height: 83px; padding: 17px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--text); text-align: left; width: 100%; } +.suggest-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); } +.suggest-icon { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 10px; color: var(--brand-text); background: var(--brand-soft); flex-shrink: 0; } +.suggest-icon svg { width: 19px; height: 19px; } +.suggest-copy { min-width: 0; } +.suggest-copy strong { display: block; font-size: 14px; font-weight: 600; } +.suggest-copy small { display: block; color: var(--secondary); font-size: 12px; margin-top: 4px; line-height: 1.6; } +.suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; } +.hot-questions { margin-top: 24px; } +.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; } +.hot-capsule-wrap { display: flex; flex-wrap: wrap; gap: 7px; } +.hot-capsule { display: inline-flex; align-items: center; border: 1px solid var(--border); background: var(--surface); border-radius: 7px; padding: 6px 10px; color: var(--secondary); font-size: 11px; cursor: pointer; } +.hot-capsule:hover { color: var(--brand-text); background: var(--brand-soft); border-color: #a8bfef; } +.recent-chat { margin-top: 24px; } +#recentChatWrap { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 18px; } +.recent-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 9px 0; font-size: 11px; color: var(--secondary); cursor: pointer; } +.recent-item > span:first-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.recent-item:hover { color: var(--brand-text); } +.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; } +.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); } +.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; } +.hot-bar-label { font-size: 10px; color: var(--muted); margin-right: 3px; } +.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; } +.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); } +.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); } +#upload-btn { flex: 0 0 36px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--secondary); font-size: 18px; } +#upload-btn:hover { color: var(--brand-text); } +#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; } +#input-text::placeholder { color: var(--muted); } +#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; } +#send-button:hover { background: var(--brand-hover); } +#send-button.stop { background: var(--danger); } +.composer-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 2px 0; } +.disclaimer, .keyboard-hint { color: var(--muted); font-size: 11px; } +.keyboard-hint { white-space: nowrap; } +.msg-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 820px; margin: 0 auto 24px; } +.msg-row.user-line { align-items: flex-end; } +.message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; } +.author-mark { width: 26px; height: 26px; display: grid; place-items: center; color: var(--brand-text); background: var(--brand-soft); border-radius: 8px; } +.author-mark svg { width: 16px; height: 16px; } +.user-message, .robot-message { max-width: 88%; border-radius: 12px; padding: 14px 17px; font-size: 1em; line-height: 1.8; overflow-wrap: anywhere; min-width: 0; } +.user-message { background: var(--brand); color: white; border-top-right-radius: 4px; white-space: pre-wrap; } +.robot-message { background: var(--surface-soft); border: 1px solid var(--border); color: var(--text); border-top-left-radius: 4px; } +.msg-time { color: var(--muted); font-size: 10px; padding: 0 3px; } +.robot-message p { margin: 4px 0; } +.robot-message ul, .robot-message ol { margin: 7px 0 7px 20px; padding: 0; } +.robot-message li { margin: 5px 0; } +.robot-message code { background: var(--surface-hover); color: var(--brand-text); padding: 2px 5px; border-radius: 4px; font-size: .93em; } +.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); } +.msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; } +.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); } +.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); } +.err-line { margin-bottom: 9px; } +.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; } +.hint-bubble, .stopped-hint { color: var(--muted); font-size: 11px; } +.followup-list { display: grid; gap: 6px; margin-top: 13px; } +.followup-item { cursor: pointer; border: 1px solid var(--border); border-radius: 7px; padding: 6px 10px; color: var(--brand-text); font-size: 11px; background: var(--surface); } +.followup-item:hover { border-color: var(--brand); } +.robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; } +.track-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 14px; } +.track-no { font-weight: 650; font-size: 17px; letter-spacing: .02em; } +.track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; } +.track-status { padding: 4px 10px; color: var(--success); background: var(--success-soft); border-radius: 6px; font-size: 12px; white-space: nowrap; } +.track-node { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); } +.track-node::before { content: ""; position: absolute; top: 12px; bottom: 0; left: 3px; width: 1px; background: var(--border); } +.track-node:last-child { padding-bottom: 0; } +.track-node:last-child::before { display: none; } +.track-dot { position: absolute; left: 0; top: 7px; width: 7px; height: 7px; border: 2px solid var(--muted); border-radius: 50%; background: var(--surface); } +.track-node.active .track-dot { border-color: var(--brand); background: var(--brand); box-shadow: 0 0 0 4px var(--brand-soft); } +.track-node.active .track-text { color: var(--text); font-weight: 600; } +.track-text { display: grid; grid-template-columns: 126px 1fr; column-gap: 12px; line-height: 1.7; } +.track-event { font-size: 13px; font-weight: 500; } +.active .track-event { font-weight: 650; } +.track-time { color: var(--muted); font-size: 11px; font-weight: 400; padding-top: 2px; } +.track-place { grid-column: 2; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 2px; } +.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; } +.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); } +.typing::after { content: " ···"; color: var(--brand-text); } +.sidebar-backdrop { display: none; } +.attachment-preview { display: flex; align-items: center; gap: 12px; padding: 10px 12px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); color: var(--text); } +.attachment-preview img { height: 42px; width: 42px; object-fit: cover; border: 1px solid var(--border); border-radius: 7px; } +.attachment-file-icon { display: grid; place-items: center; flex-shrink: 0; width: 42px; height: 42px; font-size: 10px; font-weight: 650; border-radius: 7px; background: var(--brand-soft); color: var(--brand-text); } +.attachment-copy { flex: 1; min-width: 0; } +.attachment-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; } +.attachment-copy small { display: block; font-size: 10px; color: var(--muted); margin-top: 2px; } +.attachment-remove { flex-shrink: 0; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 7px; color: var(--secondary); background: transparent; font-size: 21px; } +.attachment-remove:hover { color: var(--danger); background: var(--danger-soft); } +.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; } +.composer-status.is-error { color: var(--danger); } +.message-image { display: block; max-width: min(240px, 100%); max-height: 320px; object-fit: contain; border-radius: 8px; } +.message-file { display: block; max-width: 100%; overflow-wrap: anywhere; color: inherit; padding: 6px 0; font-size: 13px; text-underline-offset: 3px; } +@media (min-width: 901px) and (max-height: 780px) { + #chatDom { padding-top: 22px; } + .ai-welcome-card { padding-top: 0; } + .ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; } + .welcome-eyebrow { margin-bottom: 6px; } + .ai-welcome-card p { margin-top: 8px; } + .suggest-grid { margin-top: 20px; gap: 10px; } + .suggest-item { min-height: 72px; padding: 13px 16px; } + .hot-questions { margin-top: 18px; } +} +@media (min-width: 1500px) { #chatDom { padding-top: 48px; } .ai-welcome-card { padding-top: 35px; } } +@media (max-width: 1100px) { + body { padding: 12px; } + .left-panel { width: 220px; padding-left: 14px; padding-right: 14px; } + #chatDom { padding: 22px 25px; } + .composer { width: calc(100% - 50px); } + .service-label { display: none; } + .suggest-item { padding: 13px; gap: 10px; } + .suggest-copy small { font-size: 11px; } +} +@media (max-width: 900px) { + body { padding: 0; } + .chat-container { border: 0; border-radius: 0; height: 100dvh; } + .left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); } + .left-panel .brand { display: none; } + .side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; } + .chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); } + .main-chat { width: 100%; } + .chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; } + #chatDom { padding: 24px 20px 15px; } + .ai-welcome-card { padding-top: 20px; } + .composer { width: calc(100% - 40px); } + .user-message, .robot-message { max-width: 94%; } + .del-history-btn { opacity: 1; } +} +@media (max-width: 520px) { + .header-kicker { font-size: 9px; } + #app-title { font-size: 13px; } + .chat-title { padding: 12px; gap: 8px; } + .title-actions { gap: 4px; } + .theme-toggle { padding: 0 6px; min-width: 29px; font-size: 10px; } + #font-toggle { display: none; } + #chatDom { padding: 20px 16px; } + .ai-avatar { display: none; } + .ai-welcome-card { padding-top: 12px; } + .ai-welcome-card h3 { font-size: 23px; } + .ai-welcome-card p { max-width: 300px; margin: 10px auto 0; font-size: 12px; } + .suggest-grid { gap: 9px; margin-top: 22px; } + .suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 114px; padding: 13px; gap: 9px; } + .suggest-icon { width: 32px; height: 32px; border-radius: 8px; } + .suggest-copy strong { font-size: 13px; } + .suggest-copy small { font-size: 11px; } + .suggest-arrow { display: none; } + .hot-questions { margin-top: 22px; } + .hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; } + .hot-questions .hot-capsule { flex-shrink: 0; white-space: nowrap; } + .hot-capsule { font-size: 10px; padding: 6px 8px; } + #recentChatWrap { grid-template-columns: 1fr; gap: 0; } + .composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); } + .hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; margin-bottom: 8px; } + .hot-bar-label { display: none; } + .hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; } + .input-area { gap: 7px; padding: 7px; } + #send-button { min-width: 57px; padding: 0 12px; } + #upload-btn { flex-basis: 36px; height: 42px; } + #input-text { font-size: 16px; } + #send-button { min-height: 42px; } + .attachment-remove { width: 40px; height: 40px; } + .keyboard-hint { display: none; } + .composer-footer { justify-content: center; padding-top: 7px; } + .disclaimer { font-size: 10px; } + .track-text { grid-template-columns: 1fr; gap: 2px; } + .track-place { grid-column: 1; } + .robot-message.track-card { padding: 16px; } + .track-no { font-size: 15px; } + .to-bottom { right: 15px; } +} diff --git a/static/css/theme.css b/static/css/theme.css new file mode 100644 index 0000000..de6d9ea --- /dev/null +++ b/static/css/theme.css @@ -0,0 +1,85 @@ +/* Shared visual tokens and accessible controls. */ +:root { + color-scheme: light; + --page: #edf1f7; + --surface: #ffffff; + --surface-soft: #f6f8fc; + --surface-hover: #edf2fa; + --text: #19263d; + --secondary: #566780; + --muted: #64748b; + --border: #e2e8f1; + --brand: #315fd5; + --brand-hover: #254fbe; + --brand-soft: #edf2ff; + --brand-text: #315fd5; + --success: #148462; + --success-soft: #eaf7f1; + --danger: #c74650; + --danger-soft: #fff0f1; + --shadow: 0 10px 40px rgba(28, 47, 80, .06); +} +body.dark { + color-scheme: dark; + --page: #0c1422; + --surface: #151f30; + --surface-soft: #1a273c; + --surface-hover: #21314b; + --text: #e7eef9; + --secondary: #b2c0d5; + --muted: #95a5bd; + --border: #2a3a53; + --brand: #426bd4; + --brand-hover: #4772db; + --brand-soft: #223759; + --brand-text: #a6c2ff; + --success: #75d7b3; + --success-soft: #193b35; + --danger: #ff9ca4; + --danger-soft: #412b38; + --shadow: 0 10px 40px rgba(0, 0, 0, .2); +} +* { box-sizing: border-box; } +html, body { margin: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + color: var(--text); + background: var(--page); + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} +button, input, textarea { font: inherit; } +button, a, input, textarea { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: wait; opacity: .65; } +button, input, textarea, a { outline-offset: 3px; } +:focus-visible { outline: 3px solid #92b3ff; } +button { transition: background .16s, border-color .16s, color .16s, box-shadow .16s; } +a { color: var(--brand-text); } +h1, h2, h3, h4, p { margin: 0; } +svg { display: block; flex-shrink: 0; } +[hidden] { display: none !important; } +::selection { background: #cddcff; color: #122b59; } +.brand { display: flex; align-items: center; gap: 11px; text-decoration: none; color: var(--text); } +.brand-mark { + display: grid; place-items: center; width: 38px; height: 38px; + background: var(--brand); color: white; border-radius: 11px; flex-shrink: 0; +} +.brand-mark svg { width: 23px; height: 23px; } +.brand-name { font-size: 17px; font-weight: 700; letter-spacing: .02em; line-height: 1.3; } +.brand-caption { display: block; color: var(--muted); font-size: 9px; font-weight: 600; letter-spacing: .12em; margin-top: 3px; } +.eyebrow { font-size: 11px; font-weight: 600; letter-spacing: .1em; color: var(--brand-text); } +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); display: inline-block; flex-shrink: 0; } +.icon-button { + display: inline-grid; place-items: center; width: 36px; height: 36px; + border: 1px solid var(--border); border-radius: 9px; color: var(--secondary); background: var(--surface); +} +.icon-button:hover { color: var(--brand-text); background: var(--brand-soft); } +.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; } +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-thumb { background: #b7c3d5; border-radius: 10px; } +::-webkit-scrollbar-track { background: transparent; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } +} diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..9b4d673 --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,393 @@ +var curSid = 0; +var sessions = {}; +var drafts = {}; +var queueItems = []; +var accepting = new Set(); +var sendingSid = ""; +var closingSid = ""; +var polling = false; +var listSignature = ""; +var typeTimer = null; +var toolsOpen = false; +var toolsOpener = null; +var replyInput = document.getElementById("replyText"); + +function escapeText(value){ + return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){ + return {"&":"&", "<":"<", ">":">", '"':""", "'":"'"}[character]; + }); +} + +async function adminRequest(path, options){ + const response = await fetch(path, options); + if(!response.ok) throw new Error("HTTP " + response.status); + const data = await response.json(); + if(data && data.ok === false) throw new Error("Request rejected"); + return data; +} + +function postAdmin(path, data){ + return adminRequest(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(data)}); +} + +function setReplyStatus(message, isError){ + const status = document.getElementById("reply-status"); + status.textContent = message || ""; + status.hidden = !message; + status.classList.toggle("is-error", Boolean(isError)); +} + +function updateComposer(){ + document.body.classList.toggle("has-active-session", Boolean(curSid)); + const send = document.getElementById("reply-send"); + send.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid); + send.textContent = sendingSid === curSid ? "发送中…" : "发送"; + const close = document.getElementById("close-session"); + close.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid); + close.textContent = closingSid === curSid ? "正在结束…" : "结束会话"; + document.querySelectorAll(".quick").forEach(function(button){ + button.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid); + }); +} + +function resizeReply(){ + replyInput.style.height = "auto"; + replyInput.style.height = Math.min(replyInput.scrollHeight, 180) + "px"; +} + +function setSessionTab(tab){ + const active = tab !== "queue"; + document.getElementById("sessionList").hidden = !active; + document.getElementById("queue").hidden = active; + document.getElementById("tab-active").setAttribute("aria-selected", String(active)); + document.getElementById("tab-queue").setAttribute("aria-selected", String(!active)); + document.getElementById("tab-active").tabIndex = active ? 0 : -1; + document.getElementById("tab-queue").tabIndex = active ? -1 : 0; +} + +function setChatView(open){ + document.body.classList.toggle("chat-view", Boolean(open && curSid)); +} + +function showSessionList(){ + setChatView(false); + document.getElementById("tab-active").focus(); +} + +function searchTerm(){ + return document.getElementById("session-search").value.trim().toLowerCase(); +} + +function renderQueue(){ + const term = searchTerm(); + const visible = queueItems.filter(function(item){ + return ("#" + item.id + " " + item.ctime).toLowerCase().includes(term); + }); + document.getElementById("queue-count").textContent = queueItems.length; + document.getElementById("session-browser").classList.toggle("queue-empty", queueItems.length === 0); + document.getElementById("queue").innerHTML = visible.length + ? visible.map(function(item){ + const sid = Number(item.id); + return '
对话 #'+sid+''+escapeText(item.ctime)+'
'; + }).join("") + : '
'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'
'; +} + +async function loadQueue(){ + try{ + queueItems = await adminRequest("/api/human/queue"); + renderQueue(); + if(!curSid && queueItems.length && !sessions[String(queueItems[0].id)]){ + accept(queueItems[0].id); + } + }catch(error){ + document.getElementById("queue").innerHTML = '
队列暂时无法加载,稍后重试
'; + } +} + +async function loadTickets(){ + const element = document.getElementById("ticketList"); + try{ + const list = await adminRequest("/api/tickets"); + element.innerHTML = list.length ? list.map(function(ticket){ + return '
'+escapeText(ticket.content)+''+escapeText(ticket.ctime)+'
'; + }).join("") : '
暂时没有客户留言
'; + }catch(error){ + element.innerHTML = '
留言暂时无法加载
'; + } +} + +async function accept(sid){ + sid = String(sid); + if(sessions[sid]){ switchSession(sid); return; } + if(accepting.has(sid)) return; + accepting.add(sid); + try{ + await postAdmin("/api/human/accept", {sid:Number(sid)}); + if(!sessions[sid]) sessions[sid] = {lastId:0, msgs:[], unread:0}; + switchSession(sid); + loadQueue(); + poll(); + }catch(error){ + document.getElementById("queue").innerHTML = '
接入失败,请稍后重试
'; + }finally{ + accepting.delete(sid); + } +} + +function switchSession(sid){ + sid = String(sid); + if(!sessions[sid]) return; + if(curSid) drafts[curSid] = replyInput.value; + curSid = sid; + document.getElementById("emptyTip").style.display = "none"; + document.getElementById("chatContent").style.display = "flex"; + setChatView(true); + document.getElementById("chatTitle").textContent = "客户 #" + sid; + document.getElementById("chat-subtitle").textContent = "正在接待 · 人工会话"; + document.getElementById("cInfo").textContent = "会话编号:#" + sid; + document.getElementById("chatBox").innerHTML = ""; + sessions[sid].msgs.forEach(appendMsg); + sessions[sid].unread = 0; + replyInput.value = drafts[sid] || ""; + resizeReply(); + document.getElementById("chatBox").scrollTop = document.getElementById("chatBox").scrollHeight; + setReplyStatus(""); + updateComposer(); + renderList(); + setSessionTab("active"); + postAdmin("/api/human/read", {sid:Number(sid)}).catch(function(){}); +} + +function showEmptySession(){ + curSid = 0; + clearTimeout(typeTimer); + document.getElementById("chatContent").style.display = "none"; + document.getElementById("emptyTip").style.display = "flex"; + document.getElementById("cInfo").textContent = "选择会话后查看客户信息"; + document.getElementById("chatTitle").textContent = "会话接待"; + document.getElementById("chat-subtitle").textContent = "选择一段会话,开始回复客户"; + replyInput.value = ""; + setReplyStatus(""); + updateComposer(); + setChatView(false); +} + +function appendMsg(message){ + const box = document.getElementById("chatBox"); + const follow = box.scrollHeight - box.scrollTop - box.clientHeight < 100; + const div = document.createElement("div"); + div.className = "msg " + (message.side === "customer" ? "cust" : "agent"); + let content = escapeText(message.content); + content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '客户附件'); + content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '↗ 查看附件'); + const avatar = message.side === "customer" ? "客" : ''; + div.innerHTML = '
'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'
'+content+'
'; + box.appendChild(div); + if(follow || message.side === "agent") box.scrollTop = box.scrollHeight; +} + +function renderList(){ + const term = searchTerm(); + const ids = Object.keys(sessions); + document.getElementById("active-count").textContent = ids.length; + const signature = JSON.stringify([curSid, term, ids.map(function(sid){ + const session = sessions[sid]; + const last = session.msgs[session.msgs.length-1]; + return [sid, session.unread, last && last.id, last && last.content]; + })]); + if(signature === listSignature) return; + listSignature = signature; + const element = document.getElementById("sessionList"); + element.innerHTML = ""; + const visible = ids.filter(function(sid){ + return ("对话 #"+sid).includes(term) || sessions[sid].msgs.some(function(message){ + return message.content.toLowerCase().includes(term); + }); + }); + if(!visible.length){ + element.innerHTML = '
'+(term ? "没有匹配的进行中会话" : "暂无进行中的会话")+'
'; + } + visible.forEach(function(sid){ + const session = sessions[sid]; + const last = session.msgs[session.msgs.length-1]; + const preview = last ? (last.side === "agent" ? "客服:" : "") + last.content.substring(0, 36) : "等待客户消息"; + const div = document.createElement("button"); + div.type = "button"; + div.className = "conv-item" + (sid === curSid ? " active" : ""); + div.setAttribute("aria-pressed", String(sid === curSid)); + div.innerHTML = '客客户 #'+sid+''+(last && last.ctime ? escapeText(last.ctime.slice(11,16)) : '')+''+escapeText(preview)+''+(session.unread ? ''+session.unread+'' : ''); + div.onclick = function(){ switchSession(sid); }; + element.appendChild(div); + }); +} + +async function poll(){ + if(polling) return; + polling = true; + try{ + const list = await adminRequest("/api/human/active"); + list.forEach(function(session){ + if(!sessions[String(session.id)]) sessions[String(session.id)] = {lastId:0, msgs:[], unread:0}; + }); + await Promise.all(Object.keys(sessions).map(async function(sid){ + const session = sessions[sid]; + const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId); + if(sessions[sid] !== session) return; + if(state.status === "closed"){ + delete sessions[sid]; + delete drafts[sid]; + if(sid === curSid) showEmptySession(); + return; + } + state.messages.forEach(function(message){ + if(session.msgs.some(function(existing){ return existing.id === message.id; })) return; + session.lastId = Math.max(session.lastId, message.id); + session.msgs.push(message); + if(message.side === "customer" && sid !== curSid) session.unread += 1; + if(sid === curSid) appendMsg(message); + }); + })); + document.getElementById("sync-status").hidden = true; + renderList(); + }catch(error){ + document.getElementById("sync-status").hidden = false; + }finally{ + polling = false; + } +} + +async function sendReply(){ + if(!curSid || sendingSid || closingSid) return; + const sid = curSid; + const draft = replyInput.value; + if(!draft.trim()) return; + drafts[sid] = draft; + sendingSid = sid; + setReplyStatus(""); + updateComposer(); + try{ + await postAdmin("/api/human/send", {sid:Number(sid), side:"agent", content:draft.trim()}); + if(drafts[sid] === draft) drafts[sid] = ""; + if(curSid === sid && replyInput.value === draft){ + replyInput.value = ""; + resizeReply(); + } + if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); } + poll(); + }catch(error){ + if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true); + }finally{ + sendingSid = ""; + updateComposer(); + } +} + +function quickReply(text){ + if(!curSid || sendingSid || closingSid) return; + replyInput.value = text; + drafts[curSid] = text; + resizeReply(); + setReplyStatus("常用回复已填入,可编辑后发送"); + if(toolsOpen) setToolsOpen(false); + replyInput.focus(); +} + +async function closeSession(){ + if(!curSid || closingSid || sendingSid) return; + const sid = curSid; + closingSid = sid; + updateComposer(); + try{ + await postAdmin("/api/human/close", {sid:Number(sid)}); + delete sessions[sid]; + delete drafts[sid]; + if(curSid === sid) showEmptySession(); + renderList(); + }catch(error){ + if(curSid === sid) setReplyStatus("结束失败,会话仍保留,请稍后重试。", true); + }finally{ + closingSid = ""; + updateComposer(); + } +} + +function setToolsOpen(open){ + const opening = Boolean(open) && !toolsOpen; + toolsOpen = Boolean(open); + document.body.classList.toggle("tools-open", toolsOpen); + document.getElementById("tools-toggle").setAttribute("aria-expanded", String(toolsOpen)); + document.getElementById("tools-from-list").setAttribute("aria-expanded", String(toolsOpen)); + const panel = document.getElementById("service-tools"); + panel.setAttribute("aria-hidden", String(!toolsOpen)); + panel.toggleAttribute("inert", !toolsOpen); + if(opening){ + document.getElementById("data-overview").open = false; + document.getElementById("tools-close").focus(); + } +} + +replyInput.addEventListener("keydown", function(event){ + if(event.key === "Enter" && !event.shiftKey && !event.isComposing){ + event.preventDefault(); + sendReply(); + } +}); +replyInput.addEventListener("input", function(){ + resizeReply(); + if(!curSid) return; + drafts[curSid] = replyInput.value; + setReplyStatus(""); + const sid = curSid; + postAdmin("/api/human/typing", {sid:Number(sid)}).catch(function(){}); + clearTimeout(typeTimer); + typeTimer = setTimeout(function(){ + postAdmin("/api/human/stop_typing", {sid:Number(sid)}).catch(function(){}); + }, 3000); +}); +document.getElementById("session-search").addEventListener("input", function(){ renderList(); renderQueue(); }); +document.getElementById("sessions-back").onclick = showSessionList; +document.getElementById("tab-active").onclick = function(){ setSessionTab("active"); }; +document.getElementById("tab-queue").onclick = function(){ setSessionTab("queue"); }; +["tab-active", "tab-queue"].forEach(function(id){ + document.getElementById(id).addEventListener("keydown", function(event){ + if(!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = event.key === "Home" || (event.key !== "End" && id === "tab-queue") ? "tab-active" : "tab-queue"; + setSessionTab(next === "tab-active" ? "active" : "queue"); + document.getElementById(next).focus(); + }); +}); +document.getElementById("tools-toggle").onclick = function(){ toolsOpener = "tools-toggle"; setToolsOpen(!toolsOpen); }; +document.getElementById("tools-from-list").onclick = function(){ toolsOpener = "tools-from-list"; setToolsOpen(!toolsOpen); }; +document.getElementById("tools-close").onclick = function(){ + setToolsOpen(false); + const fromList = toolsOpener === "tools-from-list" && !document.body.classList.contains("chat-view") && window.matchMedia("(max-width: 720px)").matches; + document.getElementById(fromList ? "tools-from-list" : "tools-toggle").focus(); +}; +document.getElementById("tools-backdrop").onclick = document.getElementById("tools-close").onclick; +document.addEventListener("keydown", function(event){ + if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click(); + if(event.key === "Escape") document.getElementById("data-overview").open = false; + if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){ + const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href]')); + const first = items[0], last = items[items.length-1]; + if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); } + else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); } + } +}); +window.addEventListener("resize", function(){ setToolsOpen(toolsOpen); }); +adminRequest("/api/admin/stats").then(function(data){ + [data.total_conv, data.total_msg, data.today_conv, data.good, data.bad].forEach(function(value, index){ + document.getElementById("s"+(index+1)).textContent = value; + }); +}).catch(function(){}); +setToolsOpen(false); +setSessionTab("active"); +updateComposer(); +loadQueue(); +loadTickets(); +poll(); +setInterval(loadQueue, 3000); +setInterval(loadTickets, 5000); +setInterval(poll, 1000); diff --git a/static/js/admin_login.js b/static/js/admin_login.js new file mode 100644 index 0000000..c0c269a --- /dev/null +++ b/static/js/admin_login.js @@ -0,0 +1,58 @@ +const loginForm = document.getElementById("loginForm"); +const loginButton = document.getElementById("loginButton"); +const userInput = document.getElementById("user"); +const passwordInput = document.getElementById("pwd"); +const loginError = document.getElementById("err"); +let loginPending = false; + +function showLoginError(message){ + loginError.textContent = message; + loginError.hidden = false; +} + +async function login(){ + if(loginPending) return; + const username = userInput.value.trim(); + const password = passwordInput.value; + userInput.setAttribute("aria-invalid", String(!username)); + passwordInput.setAttribute("aria-invalid", String(!password)); + if(!username || !password){ + showLoginError("请输入客服账号和登录密码"); + (!username ? userInput : passwordInput).focus(); + return; + } + loginPending = true; + loginError.hidden = true; + loginButton.disabled = true; + loginButton.textContent = "正在登录…"; + loginForm.setAttribute("aria-busy", "true"); + try{ + const response = await fetch("/admin/login", { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({username: username, password: password}) + }); + if(response.ok){ + location.href = "/admin/dashboard"; + return; + } + showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" : "暂时无法登录,请稍后重试"); + }catch(error){ + showLoginError("网络连接失败,请检查网络后重试"); + }finally{ + loginPending = false; + loginButton.disabled = false; + loginButton.textContent = "登录工作台 →"; + loginForm.removeAttribute("aria-busy"); + } +} + +loginForm.addEventListener("submit", function(event){ + event.preventDefault(); + login(); +}); +[userInput, passwordInput].forEach(function(input){ + input.addEventListener("input", function(){ + loginError.hidden = true; + input.removeAttribute("aria-invalid"); + }); +}); diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000..42f36e2 --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,1406 @@ +document.addEventListener('DOMContentLoaded', function(){ +const chatDom = document.getElementById("chatDom"); +const inputDom = document.getElementById("input-text"); +const sendBtn = document.getElementById("send-button"); +const uploadBtn = document.getElementById("upload-btn"); +const uploadInput = document.getElementById("upload-input"); +const composerStatus = document.getElementById("composer-status"); +let uploadPending = false; +let uploadVersion = 0; +let followLatest = true; + +function setComposerStatus(message, isError){ + composerStatus.textContent = message || ""; + composerStatus.hidden = !message; + composerStatus.classList.toggle("is-error", Boolean(isError)); +} + +function clearAttachment(){ + uploadVersion += 1; + window.__pendingImg = ""; + window.__pendingTag = "[图片]"; + const preview = document.getElementById("imgPreview"); + if(preview) preview.remove(); +} + +function scrollChatToEnd(force){ + if(force) followLatest = true; + if(followLatest) chatDom.scrollTop = chatDom.scrollHeight; +} + +uploadBtn.onclick = ()=> uploadInput.click(); +uploadInput.onchange = function(){ + const f = uploadInput.files[0]; + if(!f || uploadPending) return; + const version = ++uploadVersion; + const fd = new FormData(); + fd.append("file", f); + uploadPending = true; + uploadBtn.disabled = true; + uploadBtn.setAttribute("aria-busy", "true"); + uploadBtn.textContent = "…"; + setComposerStatus(t("uploading")); + fetch("/api/upload", {method:"POST", body:fd}) + .then(async function(response){ + const data = await response.json(); + if(!response.ok || !data.ok) throw new Error(data.error || t("uploadFailed")); + return data; + }) + .then(data=>{ + if(version === uploadVersion){ + window.__pendingImg = data.url; + window.__pendingTag = data.is_image ? "[图片]" : "[文件]"; + var old = document.getElementById("imgPreview"); + if(old) old.remove(); + var prev = document.createElement("div"); + prev.id = "imgPreview"; + prev.className = "attachment-preview"; + if(data.is_image){ + var image = document.createElement("img"); + image.src = data.url; + image.alt = data.name || "待发送图片"; + prev.appendChild(image); + }else{ + var filename = document.createElement("span"); + filename.className = "attachment-file-icon"; + filename.textContent = data.name.split(".").pop().slice(0,5).toUpperCase(); + prev.appendChild(filename); + } + const copy = document.createElement("div"); + copy.className = "attachment-copy"; + const name = document.createElement("strong"); + name.textContent = data.name; + const hint = document.createElement("small"); + hint.textContent = t("attachmentReady"); + copy.append(name, hint); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "attachment-remove"; + remove.textContent = "×"; + remove.setAttribute("aria-label", t("removeAttachment")); + remove.onclick = function(){ clearAttachment(); inputDom.focus(); }; + prev.append(copy, remove); + var area = document.querySelector(".input-area"); + area.before(prev); + setComposerStatus(""); + inputDom.focus(); + } + }) + .catch(function(error){ + const message = 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(""); + }); + uploadInput.value = ""; +}; +const leftHistoryWrap = document.getElementById("historyWrap"); +const recentChatWrap = document.getElementById("recentChatWrap"); +const clearAllBtn = document.querySelector('.clear-all-btn'); +const newChatBtn = document.querySelector('.new-chat-btn'); +window.__busy = false; // 生成中状态,用 window 属性避免作用域问题 +// 生成中状态(语言切换等会引用,需提前声明) + +// ===== 深色模式 ===== +(function initTheme(){ + const KEY = "wuliu-theme"; + const btn = document.getElementById("theme-toggle"); + function apply(mode){ + document.body.classList.toggle("dark", mode === "dark"); + if(btn) btn.textContent = mode === "dark" ? "☀" : "☾"; + } + let saved = null; + try{ saved = localStorage.getItem(KEY); }catch(e){} + if(!saved){ + saved = "light"; + } + apply(saved); + if(btn){ + btn.onclick = function(){ + const next = document.body.classList.contains("dark") ? "light" : "dark"; + apply(next); + try{ localStorage.setItem(KEY, next); }catch(e){} + } + } +})(); + +// ===== 字体大小切换 ===== +(function(){ + var KEY = "wuliu-fontsize"; + var SIZES = [14, 16, 18, 20]; + var idx = 1; + try{ var s = Number(localStorage.getItem(KEY)); if(Number.isInteger(s) && s >= 0 && s < SIZES.length && localStorage.getItem(KEY) !== null) idx = s; }catch(e){} + var btn = document.getElementById("font-toggle"); + function apply(){ + document.body.style.fontSize = SIZES[idx] + "px"; + if(btn) btn.textContent = SIZES[idx] + "px"; + } + apply(); + if(btn){ + btn.onclick = function(){ + idx = (idx + 1) % SIZES.length; + apply(); + try{ localStorage.setItem(KEY, idx); }catch(e){} + }; + } +})(); + +// ===== 中英文切换 ===== +const I18N = { + zh: { + appTitle: "柏悦物流智能助手", + newChat: "新建对话", + clearAll: "清空全部历史", + history: "历史对话", + searchPh: "搜索历史对话...", + confirmClear: "确定清空全部历史对话吗?无法恢复!", + confirmDel: "确定要删除这条历史对话吗?", + emptySearch: "没有匹配的历史对话", + emptyHistory: "暂无历史对话", + thinking: "正在思考中...", + busyRetry: "服务繁忙,请稍后重试", + sessionFail: "会话创建失败,请确认服务已启动", + networkFail: "网络连接中断,请检查网络后重试", + stopped: "已停止生成", + stoppedMark: "(已停止生成)", + inputPh: "请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)", + send: "发送", + stop: "停止", + copy: "复制", + copied: "已复制", + retry: "重答", + clickRetry: "点击重试", + welcomeTitle: "物流问题,随时为您解答", + welcomeSub: "查询物流轨迹、了解专线报价,或联系人工客服。", + welcomeEyebrow: "中欧 · 英国物流服务", + serviceScope: "中欧与英国专线咨询", + serviceTag: "物流咨询服务", + quickLabel: "快捷咨询", + footerTrack: "查运单", + footerQuote: "专线报价", + footerException: "异常处理", + footerHuman: "联系人工", + disclaimer: "报价与时效仅供参考,请以客服最终确认为准", + keyboardHint: "Enter 发送 · Shift + Enter 换行", + inputShort: "输入问题或运单号…", + uploading: "附件正在上传,请稍候…", + uploadFailed: "附件上传失败,请重新选择文件", + serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。", + attachmentReady: "已准备好,发送后将附在消息中", + removeAttachment: "移除待发送附件", + you: "您", + assistant: "柏悦物流助手", + humanAgent: "人工客服", + trackingNo: "运单号", + moreTracking: "展开全部轨迹", + lessTracking: "收起较早轨迹", + quickSubs: ["输入运单号,查看运输进度", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"], + welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。", + hot: "热点问题", + recent: "最近对话记录", + sideCollapse: "收起侧栏", + sideExpand: "展开侧栏", + themeTip: "切换深色/浅色", + langTip: "中英文切换(Ctrl+L)", + toBottom: "回到底部", + quick: [ + "帮我查询物流轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服" + ], + hotList: [ + "如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?" + ] + }, + en: { + appTitle: "Baiyue Logistics AI Assistant", + newChat: "New Chat", + clearAll: "Clear All History", + history: "History", + searchPh: "Search history...", + confirmClear: "Clear all conversation history? This cannot be undone.", + confirmDel: "Delete this conversation?", + emptySearch: "No matching conversations", + emptyHistory: "No conversations yet", + thinking: "Thinking...", + busyRetry: "Service busy, please try again later", + sessionFail: "Failed to create session, please check the service is running", + networkFail: "Connection lost, please check your network and retry", + stopped: "Stopped", + stoppedMark: "(Generation stopped)", + inputPh: "Ask a question or enter a tracking number... (Enter to send, Shift+Enter for newline)", + send: "Send", + stop: "Stop", + copy: "Copy", + copied: "Copied", + retry: "Regenerate", + clickRetry: "Retry", + welcomeTitle: "Your next shipment, made clearer.", + welcomeSub: "Track a parcel, explore freight options, or speak to our team.", + welcomeEyebrow: "EUROPE & UK LOGISTICS", + serviceScope: "Europe & UK freight support", + serviceTag: "Logistics support", + quickLabel: "Quick help", + footerTrack: "Tracking", + footerQuote: "Get a quote", + footerException: "Parcel issue", + footerHuman: "Human support", + disclaimer: "Quotes and transit times are subject to final confirmation", + keyboardHint: "Enter to send · Shift + Enter for a new line", + inputShort: "Question or tracking number…", + uploading: "Uploading your attachment…", + uploadFailed: "Upload failed. Please select the file again.", + serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.", + attachmentReady: "Ready to attach to your next message", + removeAttachment: "Remove pending attachment", + you: "You", + assistant: "Baiyue Assistant", + humanAgent: "Support agent", + trackingNo: "Tracking number", + moreTracking: "View full tracking history", + lessTracking: "Show recent updates", + quickSubs: ["Check the latest shipment progress", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"], + welcomeTip: "Type your question, or pick a quick option below.", + hot: "Popular Questions", + recent: "Recent Conversations", + sideCollapse: "Collapse sidebar", + sideExpand: "Expand sidebar", + themeTip: "Toggle dark/light", + langTip: "Switch language (Ctrl+L)", + toBottom: "Back to bottom", + quick: [ + "Track my shipment", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent" + ], + hotList: [ + "How do I track my shipment?", "How long does Europe sea freight take?", + "My parcel shows delivered but I didn't get it", "How do I file a claim?" + ] + } +}; +let LANG = "zh"; +function t(key){ + return (I18N[LANG] || I18N.zh)[key]; +} + +function applyLanguage(lang){ + LANG = lang === "en" ? "en" : "zh"; + const dict = I18N[LANG]; + document.documentElement.lang = LANG === "en" ? "en" : "zh-CN"; + document.querySelectorAll("[data-i18n]").forEach(function(element){ + const value = dict[element.dataset.i18n]; + if(typeof value === "string") element.textContent = value; + }); + try{ localStorage.setItem("wuliu-lang", LANG); }catch(e){} + + const titleEl = document.getElementById("app-title"); + if(titleEl) titleEl.textContent = dict.appTitle; + const langBtn = document.getElementById("lang-toggle"); + if(langBtn){ + langBtn.textContent = LANG === "en" ? "EN" : "中"; + langBtn.title = dict.langTip; + } + const themeBtn = document.getElementById("theme-toggle"); + if(themeBtn) themeBtn.title = dict.themeTip; + document.querySelectorAll(".new-chat-btn").forEach(function(b){ b.textContent = dict.newChat; }); + document.querySelectorAll(".clear-all-btn").forEach(function(b){ b.textContent = dict.clearAll; }); + document.querySelectorAll(".panel-title").forEach(function(e){ e.textContent = dict.history; }); + const hs = document.getElementById("history-search"); + if(hs) hs.placeholder = dict.searchPh; + if(inputDom) inputDom.placeholder = window.innerWidth > 900 ? dict.inputPh : dict.inputShort; + sendBtn.textContent = window.__busy ? dict.stop : dict.send; + const tb = document.getElementById("to-bottom"); + 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; + + // 欢迎页(若当前显示的是欢迎页则重绘) + if(chatDom.querySelector(".ai-welcome-card")){ + const scroll = chatDom.scrollTop; + chatDom.innerHTML = welcomeHTML(); + chatDom.scrollTop = scroll; + } + // 历史列表 / 最近对话(空状态与条目都要刷新) + if(typeof renderSideHistory === 'function'){ + try{ + renderSideHistory(); + renderRecentChat(); + }catch(e){ /* 列表尚未初始化时忽略 */ } + } + // 已有消息上的复制/重答按钮 + document.querySelectorAll(".msg-action-btn").forEach(function(b){ + const n = b.dataset.action; + if(n === "copy") b.textContent = dict.copy; + if(n === "retry") b.textContent = dict.retry; + }); + document.querySelectorAll(".retry-btn").forEach(function(b){ + if(!b.dataset.keep) b.textContent = dict.clickRetry; + }); +} + +(function initLang(){ + let saved = null; + try{ saved = localStorage.getItem("wuliu-lang"); }catch(e){} + if(!saved){ + saved = (navigator.language || "zh").toLowerCase().indexOf("zh") === 0 ? "zh" : "en"; + } + applyLanguage(saved); + const btn = document.getElementById("lang-toggle"); + if(btn){ + btn.onclick = function(){ applyLanguage(LANG === "en" ? "zh" : "en"); }; + } + // 快捷键:Ctrl+L 切换语言(不占用输入框) + document.addEventListener("keydown", function(e){ + if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l"){ + e.preventDefault(); + applyLanguage(LANG === "en" ? "zh" : "en"); + } + }); +})(); + +// ===== 历史搜索 ===== +const historySearch = document.getElementById("history-search"); +if(historySearch){ + historySearch.addEventListener("input", renderSideHistory); +} + +let historyList = []; +let sseInstance = null; +let activeBubble = null; // 正在输出的气泡 +let cancelActive = null; // 中止当前请求(切换对话/重复发送时用) +let currentConvId = null; // 当前会话 id,null 表示还没建 + +function formatTime(timeStr) { + const target = new Date(timeStr); + const y = target.getFullYear(); + const m = String(target.getMonth() + 1).padStart(2, '0'); + const d = String(target.getDate()).padStart(2, '0'); + const h = String(target.getHours()).padStart(2, '0'); + const min = String(target.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${d} ${h}:${min}`; +} + +// 先转义再套格式,防止模型输出里的标签被浏览器执行 +function escHtml(s){ + return String(s).replace(/[&<>"]/g, function(c){ + return {"&":"&", "<":"<", ">":">", '"':"""}[c]; + }); +} + +// 轻量 Markdown:加粗、行内代码、无序/有序列表、换行 +function mdToHtml(text){ + const inline = function(s){ + return s.replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/`([^`]+)`/g, "$1"); + }; + const lines = escHtml(text).split("\n"); + let html = ""; + let list = null; + for (let i = 0; i < lines.length; i++){ + const t = lines[i].trim(); + let m; + if ((m = t.match(/^[-*•]\s+(.*)$/))){ // 无序列表 + if (list !== "ul"){ if (list) html += ""; html += "