diff --git a/AGENTS.md b/AGENTS.md index 5a83285..83f880d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,9 @@ | 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/` | @@ -58,4 +58,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 the root `.env`; environment variables override it. Restart after changes. 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. +Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads the root `.env`; environment variables override it. Restart after changes. Imports are inert; `create_app` initializes runtime storage unless `INITIALIZE_RUNTIME=False`. Default HOST remains `0.0.0.0`. Business tables are initialized empty; complete admin authentication remains unresolved. diff --git a/README.md b/README.md index ce7bd0a..d8c43ee 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,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=` | 删除单个会话和消息 | @@ -204,10 +204,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,主要事件包括: @@ -222,14 +230,13 @@ data: {"d": 1} ## 数据库与运行数据 -首次启动会自动创建 `history.db` 中的 `chat_history`、`messages`、`feedback`、`human_sessions` 和 `human_messages` 五张表。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。 +首次启动会自动创建 `history.db` 中的 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders` 和 `tickets` 表,并兼容已有数据。`agents`、`orders`、`tickets` 初始为空,客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。 普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。 ## 当前限制 - 运单轨迹为模拟数据,尚未接入真实物流查询接口。 -- `agents`、`orders`、`tickets` 被接口使用,但初始化代码尚未创建这三张表;新环境下客服登录、手机号订单查询、离线留言等功能需要先补齐数据库初始化。 - 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。 - 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。 - 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。 diff --git a/docs/architecture.md b/docs/architecture.md index 96c27ca..b1f0b9b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,10 +27,10 @@ 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;保留全局事件入口 | +| 页面 | `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_config.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | 配置测试隔离 `.env` 和环境变量;数据、HTTP 与 SSE 使用隔离配置和模拟模型;Node 内置测试验证草稿、异步发送和服务资料抽屉;浏览器使用临时测试库 | 除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。 @@ -52,7 +52,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 启动 @@ -102,10 +102,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` 缺少初始化、后台鉴权不完整、轨迹为模拟数据,仍是已知限制。临时 UI/接口测试中补表只属于测试夹具,不代表修复生产初始化。 +- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。后台鉴权不完整、轨迹为模拟数据仍是已知限制。 ## 验证与维护 diff --git a/logistics_chat/business_service.py b/logistics_chat/business_service.py index 317ed63..4b8efe0 100644 --- a/logistics_chat/business_service.py +++ b/logistics_chat/business_service.py @@ -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() diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index f1f8b8e..c04494e 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -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) diff --git a/logistics_chat/db.py b/logistics_chat/db.py index e2090ee..3fce7e1 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -87,6 +87,45 @@ 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, + password TEXT NOT NULL, + name TEXT NOT NULL + ) + ''') + cur.execute(''' + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT, + track_no TEXT, + status TEXT, + created TEXT + ) + ''') + cur.execute(''' + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT, + phone TEXT, + ctime TEXT, + status 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())) diff --git a/logistics_chat/history_service.py b/logistics_chat/history_service.py index ab27ca2..95042fb 100644 --- a/logistics_chat/history_service.py +++ b/logistics_chat/history_service.py @@ -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(): diff --git a/logistics_chat/human_service.py b/logistics_chat/human_service.py index 4221ecf..ebd8787 100644 --- a/logistics_chat/human_service.py +++ b/logistics_chat/human_service.py @@ -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() diff --git a/logistics_chat/routes/admin.py b/logistics_chat/routes/admin.py index 2c34de0..253aef1 100644 --- a/logistics_chat/routes/admin.py +++ b/logistics_chat/routes/admin.py @@ -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') diff --git a/logistics_chat/routes/business.py b/logistics_chat/routes/business.py index 3da2ff8..99d1e26 100644 --- a/logistics_chat/routes/business.py +++ b/logistics_chat/routes/business.py @@ -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 {}) diff --git a/logistics_chat/routes/human.py b/logistics_chat/routes/human.py index 69f8f8e..0689296 100644 --- a/logistics_chat/routes/human.py +++ b/logistics_chat/routes/human.py @@ -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']) diff --git a/requirements.txt b/requirements.txt index bfe1ad8..5198d19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/start.bat b/start.bat index 2ec3d0c..f26b4c1 100644 --- a/start.bat +++ b/start.bat @@ -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 diff --git a/static/css/admin.css b/static/css/admin.css index a4ec9d6..f4e8fde 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -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; } diff --git a/static/css/chat.css b/static/css/chat.css index 23f60e0..519ecf8 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -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; } diff --git a/static/css/theme.css b/static/css/theme.css index de6d9ea..62e16bf 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -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; } diff --git a/static/js/admin.js b/static/js/admin.js index 9b4d673..37826cb 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -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 = '
队列暂时无法加载,稍后重试
'; } @@ -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, '客户附件'); 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+'
'; + 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(); diff --git a/static/js/admin_tools.js b/static/js/admin_tools.js new file mode 100644 index 0000000..26f1642 --- /dev/null +++ b/static/js/admin_tools.js @@ -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 '

'+escapeText(item.title)+'

'+escapeText(item.content)+'

'+escapeText(roles[item.role] || item.role)+' · '+escapeText(item.ctime)+'
'; + }).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); diff --git a/static/js/chat.js b/static/js/chat.js index 42f36e2..3b0792a 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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; diff --git a/static/js/support.js b/static/js/support.js new file mode 100644 index 0000000..67dfa5e --- /dev/null +++ b/static/js/support.js @@ -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(); + } + }); + } +}; diff --git a/templates/admin.html b/templates/admin.html index ddd66a0..268f834 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -15,6 +15,8 @@

客服工作台

+ + 客户入口 ↗
数据概览 @@ -25,7 +27,8 @@
今日新对话—
有帮助—
需改进—
- 导出记录 ↗ +
服务评价—
+ 导出 Excel ↗
@@ -36,6 +39,7 @@

客户会话

查看咨询,及时接待与回复

+
@@ -54,6 +58,7 @@

会话接待

选择一段会话,开始回复客户

+
@@ -64,7 +69,7 @@