'+escapeText(item.title)+'
'+escapeText(item.content)+'
'+escapeText(roles[item.role] || item.role)+' · '+escapeText(item.ctime)+'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 = '
'+escapeText(item.content)+'
'+escapeText(roles[item.role] || item.role)+' · '+escapeText(item.ctime)+'查看咨询,及时接待与回复
+选择一段会话,开始回复客户