31 changed files with 933 additions and 86 deletions
+4 -4
View File
@@ -11,9 +11,9 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| 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` |
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView` |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView`; `admin_tools.js`: notes, transfer, search; `support.js`: emoji, notifications, rating |
| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`: `default_config`; `hooks.py`, `runtime.py` in that package |
| CLI / tests | `start.bat`: `probe_python`; `scripts/`, `tests/`, `examples/requests/` |
| Deployment | `Jenkinsfile`, `Dockerfile`; `deploy/gray_config.py`: `GrayConfig`; publish/activate/TLS scripts; `docs/deployment.md` |
@@ -51,7 +51,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
Tests run `tests/frontend/admin.test.cjs` when Node.js is available; no npm dependencies.
Tests run `tests/frontend/admin.test.cjs` and `chat.test.cjs` when Node.js is available; no npm dependencies.
## Commit & Pull Request Guidelines
@@ -59,4 +59,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
## Security & Configuration Tips
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
+1 -1
View File
@@ -31,7 +31,7 @@ RUN python -m pytest -q -p no:cacheprovider
FROM ${PYTHON_BASE_IMAGE} AS certbot
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN python -m pip install --no-cache-dir certbot==5.9.0
RUN python -m pip install --no-cache-dir certbot==5.8.0
ENTRYPOINT ["certbot"]
FROM runtime AS production
Vendored
+1 -1
View File
@@ -23,7 +23,7 @@ pipeline {
}
sh '''set -eu
for file in deploy/*.sh; do bash -n "$file"; done
node --test tests/frontend/admin.test.cjs
node --test tests/frontend/*.test.cjs
'''
}
}
+11 -3
View File
@@ -201,7 +201,7 @@ py -3 scripts/close_active_sessions.py
|---|---|---|
| GET | `/` | 客户聊天页面 |
| GET / POST | `/stream_chat` | SSE 流式对话;POST JSON 接收 `msg`、`conv_id` |
| POST | `/api/new_chat` | 新建会话,返回 `id` |
| POST | `/api/new_chat` | 新建会话并保存欢迎语,返回 `id`、`welcome` |
| GET | `/api/get_history` | 会话列表 |
| GET | `/api/get_messages?conv_id=` | 单个会话的消息 |
| DELETE | `/api/del_history?id=` | 删除单个会话和消息 |
@@ -212,10 +212,18 @@ py -3 scripts/close_active_sessions.py
| POST | `/api/human/send` | 发送人工消息 |
| GET | `/api/human/poll` | 轮询人工消息 |
| POST | `/api/human/close` | 关闭人工会话 |
| POST | `/api/human/transfer` | 将接待中的会话转回排队列表 |
| POST | `/api/human/note` | 保存会话备注 |
| POST | `/api/human/recall` | 按 `mid` 撤回客服消息 |
| POST | `/api/rate` | 提交人工服务评价(`sid`、1–5 的 `stars`、`comment`) |
| GET | `/api/agents/list` | 客服列表,不包含密码 |
| GET | `/api/search_chat?q=` | 搜索历史消息,最多返回 50 条 |
| GET | `/admin` | 客服登录页面 |
| POST | `/admin/login` | 校验客服账号密码 |
| GET | `/admin/dashboard` | 客服工作台 |
| GET | `/api/export_chat` | 导出聊天记录为 CSV |
| GET | `/api/export_chat` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
SSE 每帧为一条 JSON,主要事件包括:
@@ -230,7 +238,7 @@ data: {"d": 1}
## 数据库与运行数据
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`agents`、`orders` 和 `tickets` 八张表,不自动创建客服账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders` 和 `tickets` 九张表,并兼容已有数据,不预置账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
+2
View File
@@ -6,6 +6,8 @@ services:
pull_policy: never
restart: unless-stopped
init: true
environment:
TZ: Asia/Shanghai
ports:
- "127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"
env_file:
+7 -5
View File
@@ -28,11 +28,11 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 运费与附加费 | `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/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
| 登录、统计、订单、留言、导出 | `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_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证异步交互 |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_dev_integration.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs`、`chat.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证草稿、异步切换、转接、撤回及评价;浏览器使用临时测试库 |
除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。
@@ -53,7 +53,7 @@ logistics_chat/
├── chat_service.py # 问答编排与 SSE 帧
├── history_service.py # 历史会话 CRUD
├── human_service.py # 人工状态与消息
├── business_service.py # 反馈、统计、登录、订单、留言、CSV
├── business_service.py # 反馈、评价、统计、登录、订单、留言、历史搜索、Excel/CSV
├── file_service.py # 附件校验、保存、清理
├── hooks.py # 限流及 HTML/SSE 缓存头
├── runtime.py # Waitress / Flask 启动
@@ -104,9 +104,11 @@ tests/ # 按业务模块组织的回归
- 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`。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
- 后台鉴权和会话隔离仍不完整,轨迹为模拟数据。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
## 验证与维护
+2
View File
@@ -4,6 +4,8 @@
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
## Jenkins 任务
任务名为 `baiyue-logistics-chat-gray`。定义选择 Pipeline script from SCM,本项目 Git 地址为仓库,分支字段为 `${SOURCE_REF}`,脚本路径为 `Jenkinsfile`。公开仓库可匿名读取;转为私有后配置只读 SCM 凭据。
+68 -8
View File
@@ -3,6 +3,7 @@
from datetime import datetime
from .db import connect, now_str
from .tracking import card_to_text
def feedback(data):
@@ -22,8 +23,10 @@ def admin_stats():
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]
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
conn.close()
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv}
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv,
'rating_avg': round(rating_avg, 1), 'rating_count': rating_count}
def my_orders(phone):
@@ -61,15 +64,72 @@ def admin_login(data):
return ({'ok': False}, 401)
def export_chat():
def agents_list():
conn = connect()
# rowid also works with the legacy agents table that has an explicit INTEGER id.
rows = conn.execute('SELECT rowid, username, name FROM agents ORDER BY rowid').fetchall()
conn.close()
return [{'id': row[0], 'username': row[1], 'name': row[2]} for row in rows]
def rate_chat(data):
sid, stars = data.get('sid'), data.get('stars', 0)
comment = data.get('comment', '')
if not sid or type(stars) is not int or not 1 <= stars <= 5 or not isinstance(comment, str):
return ({'ok': False}, 400)
conn = connect()
if not conn.execute('SELECT 1 FROM human_sessions WHERE id=?', (sid,)).fetchone():
conn.close()
return ({'ok': False}, 404)
conn.execute('INSERT INTO ratings(sid, stars, comment, ctime) VALUES(?,?,?,?)',
(sid, stars, comment, datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
conn.commit()
conn.close()
return {'ok': True}
def search_chat(keyword):
if not keyword:
return {'results': []}
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 WHERE m.content LIKE ? ORDER BY m.id DESC LIMIT 50''',
('%' + keyword + '%',)).fetchall()
conn.close()
return [{'title': row[0], 'role': row[1], 'content': card_to_text(row[2]), 'ctime': row[3]} for row in rows]
def export_chat(format='xlsx'):
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]])
if format == 'csv':
import csv
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()
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
workbook = Workbook()
sheet = workbook.active
sheet.title = '聊天记录'
sheet.append(['对话标题', '角色', '内容', '时间'])
for cell in sheet[1]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='315FD5')
cell.alignment = Alignment(horizontal='center')
roles = {'user': '客户', 'assistant': '客服', 'system': '系统'}
for title, role, content, ctime in rows:
sheet.append([title, roles.get(role, role), content or '', ctime])
for cell in sheet[sheet.max_row]:
cell.data_type = 's'
for column, width in zip('ABCD', (20, 8, 80, 20)):
sheet.column_dimensions[column].width = width
buf = io.BytesIO()
workbook.save(buf)
return buf.getvalue()
+2 -2
View File
@@ -24,7 +24,7 @@ def create_chat_stream(msg, conv_id):
if not msg:
return (sse({'e': '问题不能为空'}), {})
if '[图片]' in msg and len(msg) < 300:
return (sse({'t': '收到您的图片,请描述一下您的问题。'}), {})
return (sse({'t': '收到您的图片,请描述一下您的问题。'}) + sse({'d': 1}), {})
track_no = extract_tracking_no(re.sub(r'\[(?:图片|文件)\]/uploads/\S+', '', msg))
if track_no:
card = build_track(track_no)
@@ -94,7 +94,7 @@ def create_chat_stream(msg, conv_id):
if know_text:
follow_src = prev_q
_c = connect()
_msg_count = _c.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?', (conv_id,)).fetchone()[0]
_msg_count = _c.execute("SELECT COUNT(*) FROM messages WHERE conv_id=? AND role='user'", (conv_id,)).fetchone()[0]
_c.close()
is_first_turn = _msg_count == 0
save_message(conv_id, 'user', msg)
+14
View File
@@ -87,6 +87,20 @@ def init_db():
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')
if 'recalled_id' not in hm_cols:
cur.execute('ALTER TABLE human_messages ADD COLUMN recalled_id INTEGER')
hs_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_sessions)').fetchall()]
if 'note' not in hs_cols:
cur.execute("ALTER TABLE human_sessions ADD COLUMN note TEXT DEFAULT ''")
cur.execute('''
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sid INTEGER NOT NULL,
stars INTEGER NOT NULL,
comment TEXT DEFAULT '',
ctime TEXT NOT NULL
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS agents (
username TEXT PRIMARY KEY,
+6 -1
View File
@@ -3,15 +3,20 @@
from .db import connect, now_str
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。'
def new_chat():
now = now_str()
conn = connect()
cur = conn.cursor()
cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now))
conv_id = cur.lastrowid
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
(conv_id, 'assistant', WELCOME_TEXT, now))
conn.commit()
conn.close()
return {'id': conv_id}
return {'id': conv_id, 'welcome': WELCOME_TEXT}
def get_history():
+56 -3
View File
@@ -10,6 +10,8 @@ def human_request(data):
conv_id = data.get('conv_id')
if not conv_id:
return ({'ok': False, 'error': 'no conv'}, 400)
if not 8 <= datetime.now().hour < 22:
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
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()
@@ -25,14 +27,16 @@ def human_request(data):
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()
msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
st = conn.execute("SELECT status, COALESCE(note, '') 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]}
return {'status': st[0] if st else 'closed', 'note': st[1] if st else '',
'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3],
read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]}
def human_send(data):
@@ -81,6 +85,55 @@ def human_close(data):
return {'ok': True}
def human_transfer(data, typing_map):
sid = data.get('sid')
conn = connect()
changed = conn.execute("UPDATE human_sessions SET status='waiting' WHERE id=? AND status='active'", (sid,)).rowcount
if not changed:
conn.close()
return ({'ok': False, 'error': '会话当前无法转接'}, 409)
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)',
(sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
conn.commit()
conn.close()
typing_map.pop(str(sid), None)
return {'ok': True}
def human_note(data):
note = data.get('note', '')
if not isinstance(note, str):
return ({'ok': False}, 400)
conn = connect()
changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount
conn.commit()
conn.close()
return {'ok': True} if changed else ({'ok': False}, 404)
def human_recall(data):
conn = connect()
mid = data.get('mid')
# Only the first request can change the original message; retries emit no duplicate event.
conn.execute('BEGIN IMMEDIATE')
row = conn.execute('SELECT session_id, side, content FROM human_messages WHERE id=?', (mid,)).fetchone()
if not row:
conn.close()
return ({'ok': False}, 404)
if row[1] == 'system' and row[2] == '该消息已撤回':
conn.close()
return {'ok': True}
if row[1] != 'agent':
conn.close()
return ({'ok': False}, 400)
conn.execute("UPDATE human_messages SET side='system', content='该消息已撤回' WHERE id=?", (mid,))
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime, recalled_id) VALUES(?,?,?,?,?)',
(row[0], 'system', '该消息已撤回', datetime.now().strftime('%Y-%m-%d %H:%M:%S'), mid))
conn.commit()
conn.close()
return {'ok': True}
def human_typing(data, typing_map):
sid = data.get('sid')
typing_map[str(sid)] = time.time()
+14 -1
View File
@@ -18,7 +18,20 @@ def admin_login():
@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'})
format = 'csv' if request.args.get('format') == 'csv' else 'xlsx'
mimetype = 'text/csv' if format == 'csv' else 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
return Response(business_service.export_chat(format), mimetype=mimetype,
headers={'Content-Disposition': 'attachment;filename=chat_export.' + format})
@bp.route('/api/agents/list')
def agents_list():
return business_service.agents_list()
@bp.route('/api/search_chat')
def search_chat():
return business_service.search_chat(request.args.get('q', '').strip())
@bp.route('/admin')
+5
View File
@@ -19,3 +19,8 @@ def add_ticket():
@bp.route('/api/tickets')
def list_tickets():
return business_service.list_tickets()
@bp.route('/api/rate', methods=['POST'])
def rate_chat():
return business_service.rate_chat(request.json or {})
+15
View File
@@ -41,6 +41,21 @@ def human_close():
return human_service.human_close(request.json)
@bp.route('/api/human/transfer', methods=['POST'])
def human_transfer():
return human_service.human_transfer(request.json or {}, current_app.extensions['human_typing'])
@bp.route('/api/human/note', methods=['POST'])
def human_note():
return human_service.human_note(request.json or {})
@bp.route('/api/human/recall', methods=['POST'])
def human_recall():
return human_service.human_recall(request.json or {})
@bp.route('/api/human/typing', methods=['POST'])
def human_typing():
return human_service.human_typing(request.json, current_app.extensions['human_typing'])
+1
View File
@@ -9,6 +9,7 @@ iniconfig==2.3.1
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.4
openpyxl>=3.1,<4
packaging==26.3
pluggy==1.7.0
Pygments==2.21.0
+1 -1
View File
@@ -60,7 +60,7 @@ if not errorlevel 1 (
echo OK - port is free.
echo [2/3] Checking dependencies ...
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv" >nul 2>nul
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv, openpyxl" >nul 2>nul
if errorlevel 1 (
echo Missing packages, installing ...
"%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt
+15
View File
@@ -107,6 +107,21 @@ body:not(.has-active-session) .close { display: none; }
#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); }
.system-msg { display: block; text-align: center; color: var(--muted); font-size: 12px; }
.recall-button { border: 0; background: transparent; padding: 0 0 0 10px; color: var(--muted); font-size: 11px; }
.night-badge { padding: 4px 7px; border-radius: 5px; background: var(--brand-soft); color: var(--secondary); font-size: 11px; }
.note-input { width: 100%; padding: 9px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); resize: vertical; font-size: 13px; }
.search-dialog { width: min(660px, calc(100vw - 32px)); max-height: calc(100dvh - 60px); padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
.search-dialog::backdrop { background: #19263d55; }
.search-dialog header, .search-dialog form { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
.search-dialog h2 { font-size: 17px; }
.search-dialog input { flex: 1; min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: 7px; }
.search-dialog .quick { width: auto; margin: 0; }
.search-result { padding: 14px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
.search-result h3 { font-size: 14px; }
.search-result p { margin: 8px 0; white-space: pre-wrap; font-size: 13px; }
.search-result small { color: var(--muted); }
@media (max-width: 720px) { .night-badge { display: none; } .participant-copy { min-width: 55px; } .chat-header-actions { gap: 4px; } }
.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; }
+5
View File
@@ -93,6 +93,11 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.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); }
.service-rating { margin: 12px auto; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); text-align: center; color: var(--secondary); font-size: 13px; }
.rating-choices { display: flex; justify-content: center; gap: 4px; margin-top: 6px; }
.rating-choices button { padding: 3px 8px; background: transparent; border: 0; color: var(--muted); font-size: 28px; }
.rating-choices button.selected, .rating-choices button:focus-visible { color: #bd8610; }
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
#input-text::placeholder { color: var(--muted); }
#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; }
+6
View File
@@ -76,6 +76,12 @@ svg { display: block; flex-shrink: 0; }
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); }
.text-button { border: 0; padding: 5px 0; background: transparent; color: var(--brand-text); font-size: 12px; }
.emoji-control { position: relative; flex-shrink: 0; }
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
.emoji-picker button:hover { background: var(--surface-hover); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-thumb { background: #b7c3d5; border-radius: 10px; }
+33 -6
View File
@@ -48,6 +48,7 @@ function updateComposer(){
document.querySelectorAll(".quick").forEach(function(button){
button.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
});
updateAdminTools();
}
function resizeReply(){
@@ -95,11 +96,12 @@ function renderQueue(){
async function loadQueue(){
try{
queueItems = await adminRequest("/api/human/queue");
renderQueue();
if(!curSid && queueItems.length && !sessions[String(queueItems[0].id)]){
accept(queueItems[0].id);
const list = await adminRequest("/api/human/queue");
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
}
queueItems = list;
renderQueue();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">队列暂时无法加载,稍后重试</div>';
}
@@ -153,6 +155,7 @@ function switchSession(sid){
resizeReply();
document.getElementById("chatBox").scrollTop = document.getElementById("chatBox").scrollHeight;
setReplyStatus("");
loadSessionNote(sid);
updateComposer();
renderList();
setSessionTab("active");
@@ -168,6 +171,7 @@ function showEmptySession(){
document.getElementById("chatTitle").textContent = "会话接待";
document.getElementById("chat-subtitle").textContent = "选择一段会话,开始回复客户";
replyInput.value = "";
document.getElementById("noteInput").value = "";
setReplyStatus("");
updateComposer();
setChatView(false);
@@ -177,12 +181,28 @@ function appendMsg(message){
const box = document.getElementById("chatBox");
const follow = box.scrollHeight - box.scrollTop - box.clientHeight < 100;
const div = document.createElement("div");
div.setAttribute("data-message-id", String(message.id));
if(message.side === "system"){
div.className = "msg system-msg";
div.textContent = message.content;
box.appendChild(div);
if(follow) box.scrollTop = box.scrollHeight;
return;
}
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
let content = escapeText(message.content);
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener"><img src="$1" alt="客户附件" class="message-image"></a>');
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
const avatar = message.side === "customer" ? "客" : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/></svg>';
div.innerHTML = '<span class="message-avatar" aria-hidden="true">'+avatar+'</span><div class="message-stack"><div class="meta">'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'</div><div class="bubble">'+content+'</div></div>';
if(message.side === "agent"){
const recall = document.createElement("button");
recall.type = "button";
recall.className = "recall-button";
recall.textContent = "撤回";
recall.onclick = function(){ recallMsg(message.id); };
div.querySelector(".meta").appendChild(recall);
}
box.appendChild(div);
if(follow || message.side === "agent") box.scrollTop = box.scrollHeight;
}
@@ -234,7 +254,7 @@ async function poll(){
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"){
if(state.status !== "active"){
delete sessions[sid];
delete drafts[sid];
if(sid === curSid) showEmptySession();
@@ -243,8 +263,10 @@ async function poll(){
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);
if(message.recalled_id && applyAdminRecall(sid, message.recalled_id)) return;
session.msgs.push(message);
if(message.side === "customer" && sid !== curSid) session.unread += 1;
if(message.side === "customer") SupportUI.notify("客户消息 #"+sid, message.content.substring(0, 50));
if(sid === curSid) appendMsg(message);
});
}));
@@ -370,7 +392,7 @@ 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 items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
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(); }
@@ -381,7 +403,12 @@ 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;
});
document.getElementById("s6").textContent = (data.rating_avg || 0) + " / 5("+(data.rating_count || 0)+")";
}).catch(function(){});
SupportUI.initEmoji(document.getElementById("agent-emoji-toggle"), document.getElementById("agent-emoji-panel"), replyInput, function(){
if(curSid) drafts[curSid] = replyInput.value;
resizeReply();
});
setToolsOpen(false);
setSessionTab("active");
updateComposer();
+119
View File
@@ -0,0 +1,119 @@
// Workspace notes, transfers, recalls and historical message search.
function updateAdminTools(){
document.getElementById('transfer-session').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
document.getElementById('agent-emoji-toggle').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
document.getElementById('noteInput').disabled = !curSid || Boolean(sessions[curSid].noteLoading);
document.getElementById('note-save').disabled = !curSid || !sessions[curSid].noteLoaded || Boolean(sessions[curSid].noteLoading);
}
async function loadSessionNote(sid){
const session = sessions[sid];
const input = document.getElementById('noteInput');
input.value = session.noteDraft || '';
if(session.noteLoaded){ updateAdminTools(); return; }
session.noteLoading = true;
updateAdminTools();
try{
const state = await adminRequest('/api/human/poll?sid='+Number(sid)+'&after=0');
if(sessions[sid] !== session) return;
session.noteDraft = state.note || '';
session.noteLoaded = true;
if(curSid === sid) input.value = session.noteDraft;
}catch(error){
if(curSid === sid) setReplyStatus('备注加载失败,请重新选择会话后重试。', true);
}finally{
session.noteLoading = false;
updateAdminTools();
}
}
async function saveNote(){
if(!curSid || !sessions[curSid].noteLoaded) return;
const sid = curSid, session = sessions[sid];
const note = document.getElementById('noteInput').value;
session.noteDraft = note;
try{
await postAdmin('/api/human/note', {sid:Number(sid), note:note});
if(curSid === sid) setReplyStatus(session.noteDraft === note ? '备注已保存' : '先前备注已保存,当前修改尚未保存');
}catch(error){
if(curSid === sid) setReplyStatus('备注保存失败,内容已保留。', true);
}
}
async function transferSession(){
if(!curSid || closingSid || sendingSid) return;
if(!window.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。')) return;
const sid = curSid;
closingSid = sid;
updateComposer();
try{
await postAdmin('/api/human/transfer', {sid:Number(sid)});
delete sessions[sid];
if(curSid === sid) showEmptySession();
renderList();
loadQueue();
}catch(error){
if(curSid === sid) setReplyStatus('转接未成功,会话仍保留。', true);
}finally{
closingSid = '';
updateComposer();
}
}
function applyAdminRecall(sid, mid){
const message = sessions[sid].msgs.find(function(item){ return item.id === mid; });
if(message){ message.side = 'system'; message.content = '该消息已撤回'; }
if(curSid === sid){
const row = document.querySelector('#chatBox [data-message-id="'+Number(mid)+'"]');
if(row){ row.className = 'msg system-msg'; row.textContent = '该消息已撤回'; }
}
return Boolean(message);
}
async function recallMsg(mid){
if(!curSid) return;
const sid = curSid;
try{
await postAdmin('/api/human/recall', {mid:mid});
if(sessions[sid]){ applyAdminRecall(sid, mid); renderList(); }
}catch(error){
if(curSid === sid) setReplyStatus('撤回失败,请稍后重试。', true);
}
}
let searchVersion = 0;
async function doSearch(){
const keyword = document.getElementById('searchBox').value.trim();
const version = ++searchVersion;
const body = document.getElementById('searchBody');
if(!keyword){ body.textContent = '请输入关键词搜索历史消息'; return; }
body.textContent = '正在搜索…';
try{
const list = await adminRequest('/api/search_chat?q='+encodeURIComponent(keyword));
if(version !== searchVersion) return;
const roles = {user:'客户', assistant:'客服', system:'系统'};
body.innerHTML = list.length ? list.map(function(item){
return '<article class="search-result"><h3>'+escapeText(item.title)+'</h3><p>'+escapeText(item.content)+'</p><small>'+escapeText(roles[item.role] || item.role)+' · '+escapeText(item.ctime)+'</small></article>';
}).join('') : '没有匹配的历史消息';
}catch(error){
if(version === searchVersion) body.textContent = '搜索失败,请稍后重试';
}
}
document.getElementById('history-search-toggle').onclick = function(){
document.getElementById('searchResult').showModal();
document.getElementById('searchBox').focus();
};
document.getElementById('search-close').onclick = function(){ document.getElementById('searchResult').close(); };
document.getElementById('history-search-form').addEventListener('submit', function(event){ event.preventDefault(); doSearch(); });
document.getElementById('note-save').onclick = saveNote;
document.getElementById('noteInput').addEventListener('input', function(){
if(curSid) sessions[curSid].noteDraft = this.value;
});
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function updateNightBadge(){
const hour = new Date().getHours();
document.getElementById('nightBadge').hidden = hour >= 8 && hour < 22;
}
updateNightBadge();
setInterval(updateNightBadge, 60000);
+97 -39
View File
@@ -8,6 +8,10 @@ const composerStatus = document.getElementById("composer-status");
let uploadPending = false;
let uploadVersion = 0;
let followLatest = true;
let creatingConv = null;
let sendPreparing = false;
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function setComposerStatus(message, isError){
composerStatus.textContent = message || "";
@@ -165,6 +169,16 @@ const I18N = {
thinking: "正在思考中...",
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。",
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
offlinePrompt: "请输入您的留言:",
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
humanReply: "客服回复",
recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!",
ratingFailed: "评价未提交,请重试。",
networkFail: "网络连接中断,请检查网络后重试",
stopped: "已停止生成",
stoppedMark: "(已停止生成)",
@@ -228,6 +242,16 @@ const I18N = {
thinking: "Thinking...",
busyRetry: "Service busy, please try again later",
sessionFail: "Failed to create session, please check the service is running",
autoWelcome: "Welcome to Baiyue Logistics. How can we help? Enter a tracking number or choose a quick option below.",
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.",
offlinePrompt: "Please leave your message:",
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
humanReply: "Support reply",
recalled: "This message was recalled",
ratingQuestion: "How was your support experience?",
ratingThanks: "Thank you for your feedback!",
ratingFailed: "Your rating was not submitted. Please try again.",
networkFail: "Connection lost, please check your network and retry",
stopped: "Stopped",
stoppedMark: "(Generation stopped)",
@@ -622,12 +646,19 @@ function renderRecentChat(){
// 没有会话就先建一个,拿到 conv_id
function ensureConv(){
if(currentConvId) return Promise.resolve(currentConvId);
return fetch("/api/new_chat", {method:"POST"})
.then(res=>res.json())
if(creatingConv) return creatingConv;
creatingConv = fetch("/api/new_chat", {method:"POST"})
.then(async function(res){
const data = await res.json();
if(!res.ok || !data.id) throw new Error('Session failed');
return data;
})
.then(data=>{
currentConvId = data.id;
if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId;
});
}).finally(function(){ creatingConv = null; });
return creatingConv;
}
// 打开历史会话:还原整段对话,而不是把标题重发一遍
@@ -791,21 +822,15 @@ window.showHuman = async function(){
return;
}
var now = new Date();
var day = now.getDay();
var h = now.getHours();
var work = (day >= 1 && day <= 5) && (h >= 9 && h < 19);
var work = h >= 8 && h < 22;
if(!work){
addMsg("人工客服工作时间:周一至周五 9:00-19:00\n当前非工作时间,请留言,工作时间会尽快回复。", false);
var msg = window.prompt("请输入您的留言:");
if(msg){
fetch("/api/ticket",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:msg, phone:""})});
addMsg("留言已提交,工作时间会尽快回复您。", false);
}
leaveOfflineMessage();
return;
}
addMsg("正在为您转接人工客服...", false);
if(!currentConvId){
try{ await ensureConv(); }catch(e){}
try{ await ensureConv(); }catch(e){ setComposerStatus(t('sessionFail'), true); return; }
}
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId||0})})
.then(function(r){return r.json()})
@@ -814,6 +839,8 @@ window.showHuman = async function(){
window.humanSid = d.session_id;
addMsg("您已加入人工排队,请稍候...", false);
startHumanPoll();
}else if(d.closed){
leaveOfflineMessage();
}else{
addMsg("转接失败,请稍后再试。", false);
}
@@ -823,19 +850,61 @@ window.showHuman = async function(){
});
}
window.humanSid = 0;
async function leaveOfflineMessage(){
addMsg(t('humanHours'), false);
const message = window.prompt(t('offlinePrompt'));
if(!message) return;
try{
const response = await fetch('/api/ticket', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({content:message, phone:''})});
const data = await response.json();
if(!response.ok || !data.ok) throw new Error('Ticket failed');
addMsg(t('offlineSaved'), false);
}catch(error){ setComposerStatus(t('networkFail'), true); }
}
function startHumanPoll(){
let last=0;
const t=setInterval(()=>{
if(!window.humanSid){ clearInterval(t); return; }
fetch("/api/human/poll?sid="+window.humanSid+"&after="+last)
let polling=false;
const sid=window.humanSid;
const pollTimer=setInterval(()=>{
if(window.humanSid !== sid){ clearInterval(pollTimer); return; }
if(polling) return;
polling=true;
fetch("/api/human/poll?sid="+sid+"&after="+last)
.then(r=>r.json())
.then(d=>{
if(window.humanSid !== sid) return;
d.messages.forEach(m=>{
if(m.id <= last) return;
last = m.id;
if(m.recalled_id){
const recalled = chatDom.querySelector('[data-human-message-id="'+Number(m.recalled_id)+'"]');
if(recalled){ recalled.className='human-system-message'; recalled.textContent=t('recalled'); return; }
}
if(m.side === 'system'){
const system = document.createElement('div');
system.className = 'human-system-message';
system.setAttribute('data-human-message-id', String(m.id));
system.textContent = m.content;
chatDom.appendChild(system);
}else if(m.side === 'agent'){
const bubble = addMsg(m.content, false);
bubble.closest('.msg-row').setAttribute('data-human-message-id', String(m.id));
SupportUI.notify(t('humanReply'), m.content.substring(0, 50));
}
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
});
if(d.status==="closed"){
clearInterval(t);
clearInterval(pollTimer);
window.humanSid=0;
window.__busy=false;
if(typeof setBusy==="function") setBusy(false);
addMsg("人工客服已结束,您可以继续向 AI 提问。", false);
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
addMsg(t('humanClosed'), false);
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed')});
scrollChatToEnd();
return;
}
// 客服正在输入
@@ -850,15 +919,8 @@ function startHumanPoll(){
}else if(!d.typing && oldT){
oldT.remove();
}
d.messages.forEach(m=>{
if(m.id <= last) return;
last = m.id;
if(m.side==="agent") addMsg(m.content, false);
var oldT2 = document.getElementById("human-typing");
if(oldT2) oldT2.remove();
});
})
.catch(()=>{});
.catch(()=>{}).finally(function(){ polling=false; });
}, 1000);
}
@@ -1089,9 +1151,18 @@ async function doSend(retryText){
return;
}
if(window.__busy && !retryText) return; // 防连点
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
if(cancelActive){ cancelActive(); cancelActive = null; }
sendPreparing = true;
try{
await ensureConv();
}catch(error){
setComposerStatus(t('sessionFail'), true);
return;
}finally{
sendPreparing = false;
}
if(!isRetry){
addMsg(fullContent, true);
inputDom.value = "";
@@ -1101,19 +1172,6 @@ async function doSend(retryText){
inputDom.focus();
}
// 没有会话就先建一个,后面所有消息都挂在它下面
try{
await ensureConv();
}catch(err){
const bubble = addMsg("", false);
renderError(bubble, t("sessionFail"), function(){
const row = bubble.closest(".msg-row");
if(row) row.remove();
doSend(fullContent);
});
return;
}
const aiBubble = addMsg(t("thinking"), false);
aiBubble.classList.add("typing");
activeBubble = aiBubble;
+88
View File
@@ -0,0 +1,88 @@
// Shared controls for the customer page and service workspace.
var SupportUI = {
async enableNotifications(button){
if(!('Notification' in window)) return;
try{
const permission = await Notification.requestPermission();
button.setAttribute('aria-pressed', String(permission === 'granted'));
}catch(error){}
},
notify(title, body){
if(!document.hidden || !('Notification' in window) || Notification.permission !== 'granted') return;
try{
const notice = new Notification(title, {body:body});
notice.onclick = function(){ window.focus(); notice.close(); };
}catch(error){}
},
addRating(container, sid, labels){
const box = document.createElement('section');
box.className = 'service-rating';
const question = document.createElement('p');
question.textContent = labels.question;
box.appendChild(question);
const choices = document.createElement('div');
choices.className = 'rating-choices';
box.appendChild(choices);
const status = document.createElement('p');
status.setAttribute('role', 'status');
box.appendChild(status);
let sending = false;
[1, 2, 3, 4, 5].forEach(function(stars){
const button = document.createElement('button');
button.type = 'button';
button.textContent = '★';
button.setAttribute('aria-label', stars+' / 5');
button.onmouseenter = function(){
Array.from(choices.children).forEach(function(item, index){ item.classList.toggle('selected', index < stars); });
};
button.onclick = async function(){
if(sending) return;
sending = true;
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
try{
const response = await fetch('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
const data = await response.json();
if(!response.ok || !data.ok) throw new Error('Rating failed');
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
}catch(error){
sending = false;
status.textContent = labels.failed;
Array.from(choices.children).forEach(function(item){ item.disabled = false; });
}
};
choices.appendChild(button);
});
container.appendChild(box);
return box;
},
initEmoji(button, panel, input, onInsert){
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
button.onclick = function(){
panel.hidden = !panel.hidden;
button.setAttribute('aria-expanded', String(!panel.hidden));
};
emojis.forEach(function(emoji){
const choice = document.createElement('button');
choice.type = 'button';
choice.textContent = emoji;
choice.onclick = function(){
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
const end = input.selectionEnd == null ? start : input.selectionEnd;
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
input.focus();
input.setSelectionRange(start + emoji.length, start + emoji.length);
if(onInsert) onInsert();
};
panel.appendChild(choice);
});
panel.addEventListener('keydown', function(event){
if(event.key === 'Escape'){
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
button.focus();
}
});
}
};
+11 -2
View File
@@ -15,6 +15,8 @@
<span class="nav-divider" aria-hidden="true"></span><h1>客服工作台</h1>
</div>
<div class="topbar-actions">
<span id="nightBadge" class="night-badge" hidden>非工作时段</span>
<button id="notifications-toggle" class="icon-button" type="button" aria-label="开启浏览器通知" title="开启浏览器通知" aria-pressed="false">🔔</button>
<a class="workspace-link" href="/">客户入口 ↗</a>
<details class="data-overview" id="data-overview">
<summary><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4 4v16h17M8 16v-5M13 16V7M18 16V3"/></svg>数据概览</summary>
@@ -25,7 +27,8 @@
<div class="stat"><span>今日新对话</span><b id="s3">—</b></div>
<div class="stat"><span>有帮助</span><b id="s4">—</b></div>
<div class="stat"><span>需改进</span><b id="s5">—</b></div>
<a class="export-link" href="/api/export_chat">导出记录 ↗</a>
<div class="stat"><span>服务评价</span><b id="s6">—</b></div>
<a class="export-link" href="/api/export_chat">导出 Excel ↗</a>
</div>
</details>
</div>
@@ -36,6 +39,7 @@
<div class="list-heading">
<h2>客户会话</h2><p>查看咨询,及时接待与回复</p>
<div class="session-search-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg><input id="session-search" type="search" aria-label="搜索客户会话" placeholder="搜索会话或消息" autocomplete="off"></div>
<button id="history-search-toggle" class="text-button" type="button">搜索历史记录 ↗</button>
</div>
<div class="session-tabs" role="tablist" aria-label="会话状态">
<button id="tab-active" type="button" role="tab" aria-selected="true" aria-controls="sessionList">接待中 <span id="active-count" class="section-count">0</span></button>
@@ -54,6 +58,7 @@
<div class="participant-copy"><h2 id="chatTitle">会话接待</h2><p id="chat-subtitle">选择一段会话,开始回复客户</p></div>
<div class="chat-header-actions">
<button id="tools-toggle" class="tools-toggle" type="button" aria-label="资料与快捷回复" aria-controls="service-tools" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="M6 16a3 3 0 0 1 6 0M14 8h3M14 12h3M14 16h3"/></svg><span>资料与回复</span></button>
<button id="transfer-session" class="close" type="button" onclick="transferSession()">转接</button>
<button id="close-session" class="close" type="button" onclick="closeSession()">结束会话</button>
</div>
</header>
@@ -64,7 +69,7 @@
<div id="chatContent" class="session-panel" style="display:none">
<div id="chatBox" role="log" aria-live="polite" aria-label="会话消息"></div>
<footer class="agent-composer">
<div class="composer-heading"><label for="replyText">回复客户</label><span>Shift + Enter 换行</span></div>
<div class="composer-heading"><label for="replyText">回复客户</label><div class="emoji-control"><button id="agent-emoji-toggle" class="text-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="agent-emoji-panel">😊 表情</button><div id="agent-emoji-panel" class="emoji-picker" hidden></div></div><span>Shift + Enter 换行</span></div>
<div class="input-bar">
<textarea id="replyText" rows="2" aria-label="回复客户" placeholder="输入回复内容,按 Enter 发送…"></textarea>
<div class="input-actions"><span>常用回复可在「资料与回复」中选择</span><button id="reply-send" type="button" onclick="sendReply()">发送</button></div>
@@ -76,6 +81,7 @@
<aside class="side" id="service-tools" aria-label="客户信息与回复" aria-hidden="true" inert>
<header class="side-heading"><h2>服务资料</h2><button id="tools-close" type="button" class="icon-button" aria-label="关闭服务资料">×</button></header>
<section class="card"><h3>客户信息</h3><div class="info-row" id="cInfo">选择会话后查看客户信息</div><p class="info-hint">咨询运费时,可先确认运单号、货物重量、尺寸与目的地。</p></section>
<section class="card"><h3><label for="noteInput">会话备注</label></h3><textarea id="noteInput" class="note-input" rows="3" placeholder="填写本次会话的跟进事项" disabled></textarea><button id="note-save" type="button" class="quick" disabled>保存备注</button></section>
<section class="card"><h3>常用回复</h3>
<p class="card-caption">点击填入草稿,编辑后再发送</p>
<button type="button" class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,有什么可以帮您?</button>
@@ -87,6 +93,9 @@
</aside>
</main>
<button class="tools-backdrop" id="tools-backdrop" type="button" aria-label="关闭服务资料"></button>
<dialog id="searchResult" class="search-dialog" aria-labelledby="search-title"><header><h2 id="search-title">搜索历史记录</h2><button id="search-close" class="icon-button" type="button" aria-label="关闭搜索">×</button></header><form id="history-search-form"><label class="sr-only" for="searchBox">历史消息关键词</label><input id="searchBox" type="search" placeholder="输入关键词搜索聊天记录" required><button type="submit" class="quick">搜索</button></form><div id="searchBody" aria-live="polite">请输入关键词搜索历史消息</div></dialog>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_tools.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
</body>
</html>
+3
View File
@@ -31,6 +31,7 @@
<button class="toggle-divider" type="button" aria-label="切换历史侧栏" aria-expanded="true"></button>
<div class="header-copy"><div class="header-kicker">BAIYUE · CUSTOMER SUPPORT</div><span id="app-title">柏悦物流智能助手</span></div>
<div class="title-actions">
<button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button>
<span class="service-label"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceTag">智能客服</span></span>
<button type="button" id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
<button type="button" id="font-toggle" class="theme-toggle" title="字体大小">16px</button>
@@ -57,6 +58,7 @@
</div>
<div class="input-area">
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
<button type="button" id="send-button">发送</button>
@@ -67,6 +69,7 @@
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
</main>
</div>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body>
</html>
+86 -1
View File
@@ -4,6 +4,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8');
const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8');
const flush = () => new Promise(setImmediate);
function response(data, status = 200){
@@ -32,6 +34,13 @@ async function workspace(compact = false){
appendChild(element){ this.children.push(element); }
addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; }
querySelector(selector){
if(selector === '.meta') return this.meta || (this.meta = new Element());
return null;
}
setSelectionRange(start, end){ this.selectionStart = start; this.selectionEnd = end; }
showModal(){ this.open = true; }
close(){ this.open = false; }
focus(){ document.activeElement = this; }
click(){ if(this.onclick) return this.onclick(); }
}
@@ -41,24 +50,36 @@ async function workspace(compact = false){
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
querySelector(selector){
const match = selector.match(/data-message-id="(\d+)"/);
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
},
addEventListener(name, callback){ documentListeners[name] = callback; },
};
const calls = [];
const hooks = {};
const context = vm.createContext({
document, console,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}},
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
setInterval(){}, setTimeout(){}, clearTimeout(){},
fetch: async (url, options) => {
const body = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url, body});
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue();
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
return response(options ? {ok:true} : []);
},
});
vm.runInContext(sharedSource, context, {filename:'support.js'});
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
vm.runInContext(source, context, {filename:'admin.js'});
await flush();
await context.accept(7);
@@ -211,3 +232,67 @@ test('mobile drafts are measured after the chat view becomes visible', async ()
assert.equal(input.style.height, '128px');
assert.equal(input.value, '需要恢复的多行草稿');
});
test('transfer returns a conversation to the queue without auto-accepting it again', async () => {
const app = await workspace();
app.hooks.queue = () => response([{id:7, ctime:'now'}]);
await app.context.transferSession();
await flush();
assert.equal(app.context.curSid, 0);
assert.equal(app.context.sessions['7'], undefined);
assert.equal(app.calls.filter(call => call.url === '/api/human/accept').length, 1);
});
test('failed transfers retain the conversation and draft', async () => {
const app = await workspace();
app.hooks.transfer = () => response({ok:false}, 503);
app.element('replyText').value = '等待确认的草稿';
await app.context.transferSession();
assert.equal(app.context.curSid, '7');
assert.equal(app.element('replyText').value, '等待确认的草稿');
assert.match(app.element('reply-status').textContent, /转接未成功/);
});
test('a late note response cannot overwrite a different session note', async () => {
const app = await workspace();
app.context.sessions['7'].noteLoaded = false;
let resolve;
app.hooks.poll = url => url.includes('sid=7&') ? new Promise(done => { resolve = done; }) : response({status:'active', note:'客户 8 的备注', messages:[]});
const first = app.context.loadSessionNote('7');
app.context.sessions['8'] = {lastId:0, msgs:[], unread:0};
app.context.switchSession('8');
await flush();
resolve(response({status:'active', note:'客户 7 的备注', messages:[]}));
await first;
assert.equal(app.element('noteInput').value, '客户 8 的备注');
});
test('failed note saves keep the edited note available', async () => {
const app = await workspace();
app.hooks.note = () => response({ok:false}, 503);
app.element('noteInput').value = '下个班次继续跟进';
await app.context.saveNote();
assert.equal(app.element('noteInput').value, '下个班次继续跟进');
assert.match(app.element('reply-status').textContent, /备注保存失败/);
});
test('recalling an older reply leaves the newest reply intact', async () => {
const app = await workspace();
app.context.sessions['7'].msgs = [
{id:1, side:'agent', content:'较早回复'}, {id:2, side:'agent', content:'最新回复'},
];
app.context.sessions['7'].msgs.forEach(app.context.appendMsg);
await app.context.recallMsg(1);
assert.equal(app.context.sessions['7'].msgs[0].content, '该消息已撤回');
assert.equal(app.context.sessions['7'].msgs[1].content, '最新回复');
assert.equal(app.element('chatBox').children[0].textContent, '该消息已撤回');
});
test('historical search renders customer text without interpreting HTML', async () => {
const app = await workspace();
app.hooks.search = () => response([{title:'<img onerror=bad()>', content:'<script>bad()</script>', role:'user', ctime:'now'}]);
app.element('searchBox').value = '物流';
await app.context.doSearch();
assert.match(app.element('searchBody').innerHTML, /&lt;script&gt;/);
assert.doesNotMatch(app.element('searchBody').innerHTML, /<script>/);
});
+105
View File
@@ -0,0 +1,105 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const flush = () => new Promise(setImmediate);
async function customerPage(){
const elements = new Map(), listeners = {}, intervals = [], calls = [];
class Element {
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300;
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
remove: name => { this.className = this.className.split(' ').filter(value => value !== name).join(' '); },
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
};
}
set innerHTML(value){ this.html = value; this.children = []; }
get innerHTML(){ return this.html || ''; }
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
setAttribute(name, value){ this.attributes[name] = value; }
getAttribute(name){ return this.attributes[name]; }
addEventListener(){}
appendChild(child){ child.parent = this; this.children.push(child); }
append(...children){ children.forEach(child => this.appendChild(child)); }
focus(){}
querySelector(selector){
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector){
const matches = element => {
if(selector.startsWith('.')) return element.classList.contains(selector.slice(1));
const id = selector.match(/data-human-message-id="(\d+)"/);
return id && element.attributes['data-human-message-id'] === id[1];
};
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
}
closest(selector){ return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector); }
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
}
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
const document = {
body: new Element(), documentElement: {},
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
querySelector: selector => element(selector), querySelectorAll: () => [],
createElement: () => new Element(),
addEventListener: (name, callback) => { listeners[name] = callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, 12])); } }
let state = {status:'active', messages:[]};
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN'},
localStorage:{getItem:() => null, setItem(){}},
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false})},
setInterval: callback => { intervals.push(callback); return intervals.length; },
clearInterval(){}, setTimeout(){}, clearTimeout(){},
fetch: async (url, options) => {
calls.push({url, body:options && options.body && JSON.parse(options.body)});
const data = url === '/api/new_chat' ? {id:1, welcome:'welcome'}
: url === '/api/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/human/poll') ? state
: url === '/api/rate' ? {ok:true} : [];
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
}
listeners.DOMContentLoaded();
await flush();
await context.window.showHuman();
await flush();
return {context, element, calls, setState:value => { state=value; }, tick:async () => { intervals[0](); await flush(); }};
}
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
const app = await customerPage();
app.setState({status:'closed', messages:[{id:1, side:'agent', content:'最后一条回复'}]});
await app.tick();
const log = app.element('chatDom');
assert.match(log.textContent, /最后一条回复/);
assert.match(log.textContent, /人工客服已结束/);
const rating = log.querySelector('.service-rating');
assert.ok(rating);
await rating.querySelector('.rating-choices').children[4].onclick();
assert.match(rating.textContent, /感谢您的评价/);
assert.equal(app.calls.find(call => call.url === '/api/rate').body.stars, 5);
assert.equal(app.context.window.humanSid, 0);
});
test('recalling an earlier reply preserves newer replies in the customer log', async () => {
const app = await customerPage();
app.setState({status:'active', messages:[{id:1, side:'agent', content:'早先的回复'}, {id:2, side:'agent', content:'最新回复'}]});
await app.tick();
app.setState({status:'active', messages:[{id:3, side:'system', content:'该消息已撤回', recalled_id:1}]});
await app.tick();
const log = app.element('chatDom');
assert.match(log.textContent, /该消息已撤回/);
assert.match(log.textContent, /最新回复/);
assert.doesNotMatch(log.textContent, /早先的回复/);
});
+12 -7
View File
@@ -77,7 +77,7 @@ class ApiTests(unittest.TestCase):
for path in ('/', '/admin', '/admin/dashboard'):
parser = Assets()
parser.feed(self.client.get(path).get_data(as_text=True))
self.assertEqual(len(parser.urls), 3)
self.assertGreaterEqual(len(parser.urls), 3)
for url in parser.urls:
resource = self.client.get(url)
self.assertEqual(resource.status_code, 200, url)
@@ -91,15 +91,17 @@ class ApiTests(unittest.TestCase):
self.assertEqual(events[-1], {'d': 1})
self.assertEqual(events[0]['card']['no'], '15252125')
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertEqual(len(messages), 2)
self.assertTrue(messages[1]['content'].startswith('__CARD__'))
self.assertEqual(len(messages), 3)
self.assertTrue(messages[-1]['content'].startswith('__CARD__'))
self.assertEqual(self.client.get('/api/get_history').json[0]['title'], '15252125')
self.assertEqual(self.client.delete(f'/api/del_history?id={cid}').json, {'ok': True})
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json, [])
def test_empty_image_and_missing_key_streams(self):
self.assertIn('e', frames(self.client.post('/stream_chat', json={'msg': ''}))[0])
self.assertIn('收到您的图片', frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))[0]['t'])
image_reply = frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))
self.assertIn('收到您的图片', image_reply[0]['t'])
self.assertEqual(image_reply[-1], {'d': 1})
self.assertIn('API Key', frames(self.client.get('/stream_chat?msg=hello'))[0]['e'])
def test_attachment_filename_is_not_used_as_a_tracking_number(self):
@@ -110,11 +112,14 @@ class ApiTests(unittest.TestCase):
cid = self.conversation()
events = frames(self.client.post('/stream_chat', json={'msg': '查询15252125 ' + attachment, 'conv_id': cid}))
self.assertEqual(events[0]['card']['no'], '15252125')
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[0]['content'], '查询15252125 ' + attachment)
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment)
def test_human_lifecycle_typing_and_read_receipts(self):
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026, 10, 29, 12)
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
self.client.post('/api/human/accept', json={'sid': sid})
self.client.post('/api/human/send', json={'sid': sid, 'side': 'customer', 'content': 'hello'})
@@ -190,7 +195,7 @@ class ApiTests(unittest.TestCase):
db.save_message(cid, 'user', 'hello, "quoted"\nworld')
self.client.post('/api/feedback', json={'vote': 1, 'question': 'hello', 'answer': 'world'})
self.assertEqual(self.client.get('/api/admin/stats').json['good'], 1)
response = self.client.get('/api/export_chat')
response = self.client.get('/api/export_chat?format=csv')
self.assertEqual(response.mimetype, 'text/csv')
self.assertIn('chat_export.csv', response.headers['Content-Disposition'])
self.assertIn('"hello, ""quoted""\nworld"', response.get_data(as_text=True))
+142
View File
@@ -0,0 +1,142 @@
"""Remote dev behavior on the modular backend and the light workspace."""
from datetime import datetime
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpyxl import load_workbook
from logistics_chat import create_app, db
class DevIntegrationTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': root/'test.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
self.client = self.app.test_client()
def session(self):
cid = self.client.post('/api/new_chat').json['id']
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 10, 31, 8)
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
return result['session_id']
def test_new_conversation_persists_one_welcome_before_user_message(self):
data = self.client.post('/api/new_chat').json
self.assertIn('欢迎', data['welcome'])
self.client.post('/stream_chat', json={'msg': '15252125', 'conv_id': data['id']}, buffered=True)
messages = self.client.get('/api/get_messages?conv_id='+str(data['id'])).json
self.assertEqual([message['role'] for message in messages], ['assistant', 'user', 'assistant'])
self.assertEqual(messages[0]['content'], data['welcome'])
def test_daily_service_hours_and_offline_ticket_on_fresh_database(self):
cid = self.client.post('/api/new_chat').json['id']
for hour, open_now in ((7, False), (8, True), (21, True), (22, False)):
with self.subTest(hour=hour), patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 10, 31, hour)
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
self.assertEqual(result['ok'], open_now)
if not open_now:
self.assertTrue(result['closed'])
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
sid = self.session()
self.client.post('/api/human/accept', json={'sid': sid})
self.client.post('/api/human/note', json={'sid': sid, 'note': '请跟进派送'})
self.client.post('/api/human/send', json={'sid': sid, 'side': 'agent', 'content': '正在处理'})
self.client.post('/api/human/typing', json={'sid': sid})
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).json, {'ok': True})
self.assertEqual(self.client.get('/api/human/active').json, [])
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
state = self.client.get(f'/api/human/poll?sid={sid}').json
self.assertEqual(state['note'], '请跟进派送')
self.assertFalse(state['typing'])
self.assertEqual(state['messages'][0]['content'], '正在处理')
self.assertEqual(state['messages'][-1]['side'], 'system')
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).status_code, 409)
self.client.post('/api/human/accept', json={'sid': sid})
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'active')
def test_recall_targets_older_agent_message_and_is_idempotent(self):
sid = self.session()
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
self.client.post('/api/human/send', json={'sid': sid, 'side': side, 'content': text})
before = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
mid = before[0]['id']
for _ in range(2):
self.assertEqual(self.client.post('/api/human/recall', json={'mid': mid}).json, {'ok': True})
self.assertEqual(self.client.post('/api/human/recall', json={'mid': before[1]['id']}).status_code, 400)
self.assertEqual(self.client.post('/api/human/recall', json={'mid': 99999}).status_code, 404)
updates = self.client.get(f'/api/human/poll?sid={sid}&after={before[-1]["id"]}').json['messages']
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0]['recalled_id'], mid)
all_messages = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
self.assertEqual(all_messages[0]['content'], '该消息已撤回')
self.assertEqual(all_messages[2]['content'], '最新回复')
def test_ratings_validate_stars_and_feed_admin_stats(self):
sid = self.session()
for stars in (0, 6, True, '5'):
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).status_code, 400)
self.assertEqual(self.client.post('/api/rate', json={'sid': 999, 'stars': 5}).status_code, 404)
for stars in (4, 5):
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).json, {'ok': True})
stats = self.client.get('/api/admin/stats').json
self.assertEqual((stats['rating_avg'], stats['rating_count']), (4.5, 2))
def test_excel_export_search_and_legacy_agent_schema(self):
cid = self.client.post('/api/new_chat').json['id']
with self.app.app_context():
db.save_message(cid, 'user', '=SUM(1,2)\n查询专线')
connection = db.connect()
connection.executescript('DROP TABLE agents; CREATE TABLE agents '
'(id INTEGER PRIMARY KEY, username TEXT, password TEXT, name TEXT);'
"INSERT INTO agents VALUES(7, 'demo', 'fixture-password', '测试客服');")
connection.commit()
connection.close()
db.init_db()
results = self.client.get('/api/search_chat?q=专线').json
self.assertEqual(len(results), 1)
self.assertIn('SUM', results[0]['content'])
self.assertEqual(self.client.get('/api/search_chat').json, {'results': []})
self.assertEqual(self.client.get('/api/agents/list').json, [{'id': 7, 'username': 'demo', 'name': '测试客服'}])
exported = self.client.get('/api/export_chat')
self.assertIn('chat_export.xlsx', exported.headers['Content-Disposition'])
workbook = load_workbook(io.BytesIO(exported.data))
sheet = workbook.active
self.assertEqual(sheet['C3'].value, '=SUM(1,2)\n查询专线')
self.assertEqual(sheet['C3'].data_type, 's')
self.assertEqual(sheet['B3'].value, '客户')
workbook.close()
def test_existing_schema_is_upgraded_without_losing_business_data(self):
with self.app.app_context():
connection = db.connect()
connection.executescript('DROP TABLE human_sessions; DROP TABLE human_messages;'
'CREATE TABLE human_sessions(id INTEGER PRIMARY KEY, conv_id INTEGER, status TEXT, ctime TEXT);'
'CREATE TABLE human_messages(id INTEGER PRIMARY KEY, session_id INTEGER, side TEXT, content TEXT, ctime TEXT);'
"INSERT INTO human_sessions VALUES(1, 1, 'active', 'now');"
"INSERT INTO human_messages VALUES(1, 1, 'agent', '保留的回复', 'now');"
"INSERT INTO agents VALUES('fixture-agent', 'fixture-password', 'Fixture Agent');")
connection.commit()
connection.close()
db.init_db()
db.init_db()
state = self.client.get('/api/human/poll?sid=1').json
self.assertEqual(state['messages'][0]['content'], '保留的回复')
self.assertEqual(state['note'], '')
self.assertEqual(self.client.get('/api/agents/list').json[0]['username'], 'fixture-agent')
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)