diff --git a/README.md b/README.md index 2bf5f17..7aa62b9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部 - **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L` - **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试 +- **会话恢复**:连接中断保留已收到的正文;切换话题取消旧附件,等待建会话时保留新草稿;历史加载失败保留已有列表 - **深色模式**:右上角切换,偏好本地保存 - **响应式**:窄屏下侧栏自动变为抽屉 - **附件预览**:发送前查看文件名或缩略图,可移除待发送附件 @@ -23,6 +24,8 @@ 代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位: +英国报价及其连续追问使用英国费率。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。 + | 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 | |---|---|---| | 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 | diff --git a/docs/architecture.md b/docs/architecture.md index 762e851..8b5757c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,14 +104,14 @@ tests/ # 按业务模块组织的回归 ## 必须保留的接口与数据约定 -- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。 +- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。 - `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径和重量/日期,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。 - `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。 - 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。 -- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。 +- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表,迟到的刷新不覆盖新结果。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。 - 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。 - 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。 -- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。 +- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 在报价追问中保留重量、尺寸、渠道和区域,遇到新的完整问题停止回溯;英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。 - 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。 - `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。 - 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。 diff --git a/logistics_chat/ai_client.py b/logistics_chat/ai_client.py index 1c89fd3..1b664e4 100644 --- a/logistics_chat/ai_client.py +++ b/logistics_chat/ai_client.py @@ -29,6 +29,7 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')): """回答超长时,让模型压缩到规定字数内;失败则原样返回""" if len(text) <= limit: return text + r = None try: body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': (EN_COMPRESS_RULE if has_chinese(text) is False else '') + f'你是文字精简助手。把用户给的内容压缩到 {limit} 字以内(必须严格满足,超出即为不合格),必须保留所有数字、金额、时效和渠道名称,删除重复表述、客套话和次要说明,结构保持不变。'}, {'role': 'user', 'content': text}], 'stream': False, 'max_tokens': 800} r = post_completion(body, timeout=(5, 30)) @@ -39,6 +40,9 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')): print(f'[压缩失败] 状态码 {r.status_code}') except Exception as e: print(f'[压缩异常] {e}') + finally: + if r is not None: + r.close() return text @@ -46,6 +50,7 @@ def gen_followups(user_msg, answer_text): """根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)""" if not get_setting('DEEPSEEK_API_KEY'): return [] + r = None try: body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': '你是物流客服助手。根据用户问题和你的回答,生成3个客户最可能接着问的简短问题(每个不超过20字),只输出JSON字符串数组,不要任何解释。'}, {'role': 'user', 'content': f'用户问:{user_msg}\n你的回答:{answer_text[:500]}\n\n请生成3个追问:'}], 'stream': False, 'max_tokens': 200} r = post_completion(body, timeout=(5, 15)) @@ -58,4 +63,7 @@ def gen_followups(user_msg, answer_text): return [str(x).strip() for x in arr if str(x).strip()][:3] except Exception: pass + finally: + if r is not None: + r.close() return [] diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index cda9309..c94cbe8 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -11,7 +11,7 @@ from .knowledge import search_knowledge from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply, calc_warehouse_card, detect_surcharge, surcharge_reply, check_island) from .tracking import CARD_PREFIX -from .order_data import answer_query +from .order_data import answer_query, extract_reference @@ -21,46 +21,76 @@ def sse(payload): return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n' +def shipping_intent_with_context(message, conv_id): + intent = parse_shipping_intent(message) + intent['dim_unit'] = detect_dim_unit(message) + followup = re.match(r'^(?:那|那么|换成|改成|改为|如果|还是|再算|同样)', message.strip()) + if conv_id and (followup or (intent['channel'] and not intent['weight'] and not intent['dims'])): + connection = connect() + try: + recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 6", (conv_id,)).fetchall() + finally: + connection.close() + for (previous,) in recent: + if extract_reference(previous): + break + earlier = parse_shipping_intent(previous) + if not any(earlier.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')): + break + for key in ('weight', 'channel', 'region'): + if intent.get(key) is None: + intent[key] = earlier.get(key) + if intent['dims'] is None and earlier['dims']: + intent['dims'] = earlier['dims'] + intent['dim_unit'] = detect_dim_unit(previous) + if not re.match(r'^(?:那|那么|换成|改成|改为|如果|还是|再算|同样)', previous.strip()): + break + if intent['channel']: + base = intent['channel'].removesuffix('_uk') + intent['channel'] = base + '_uk' if intent['region'] == 'uk' else base + return intent + + def create_chat_stream(msg, conv_id): if not msg: return (sse({'e': '问题不能为空'}), {}) - if '[图片]' in msg and len(msg) < 300: - return (sse({'t': '收到您的图片,请描述一下您的问题。'}) + sse({'d': 1}), {}) + if conv_id: + connection = connect() + try: + exists = connection.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone() + finally: + connection.close() + if not exists: + return (sse({'e':'该会话已删除,请新建对话后重试。'}), {}) order_answer = answer_query(msg, conv_id) + if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg): + order_answer = {'t':'收到您的图片,请描述一下您的问题。'} + ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None + if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or re.search(r'报价|价格|运费|费用|多少钱', msg)): + order_answer = {'t':'当前报价表尚未包含该目的地的费率,请提供目的仓或邮编、货物品名、件数和尺寸,由人工客服确认报价。'} + if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']: + order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'} if order_answer is not None: def track_stream(): save_message(conv_id, 'user', msg) - yield sse(order_answer) content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False) if 'card' in order_answer else order_answer['t']) save_message(conv_id, 'assistant', content) + yield sse(order_answer) yield sse({'d': 1}) return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) if not get_setting('DEEPSEEK_API_KEY'): return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {}) know_text = search_knowledge(msg) - ship = parse_shipping_intent(msg) - ship['dim_unit'] = detect_dim_unit(msg) freight_text = None - if not ship['weight'] and (not ship['dims']) and ship['channel']: - prev_q = last_user_question(conv_id) - if prev_q: - prev_ship = parse_shipping_intent(prev_q) - if prev_ship['weight'] or prev_ship['dims']: - ship['weight'] = prev_ship['weight'] - ship['dims'] = prev_ship['dims'] - ship['dim_unit'] = detect_dim_unit(prev_q) if ship['weight'] or ship['dims']: - if re.search('英国|UK|伦敦|英格兰', msg, re.I): - uk_map = {'sea': 'sea_uk', 'rail': 'rail_uk', 'truck': 'truck_uk', 'air': 'air_uk'} - ship['channel'] = uk_map.get(ship['channel']) freight_text = freight_reply(ship) if not ship['channel']: freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)' extra_parts = [] cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I) - if cbm_m: + if cbm_m and ship['region'] != 'uk': cbm = float(cbm_m.group(1)) ch = '海运' if re.search('卡航|卡车', msg): @@ -146,8 +176,11 @@ def create_chat_stream(msg, conv_id): return parts = [] interrupted = False + answer_saved = False try: got_text = False + completed = False + finish_reason = None for raw in resp.iter_lines(): if not raw: continue @@ -156,10 +189,15 @@ def create_chat_stream(msg, conv_id): continue data = line[5:].strip() if data == '[DONE]': + completed = True break try: j = json.loads(data) - chunk = j['choices'][0]['delta'].get('content') or '' + choice = j['choices'][0] + chunk = choice.get('delta', {}).get('content') or '' + finish_reason = choice.get('finish_reason') or finish_reason + if finish_reason == 'stop': + completed = True except Exception: continue if chunk: @@ -169,6 +207,10 @@ def create_chat_stream(msg, conv_id): if not got_text: yield sse({'e': '模型没有返回内容,请重试'}) return + if not completed or finish_reason not in (None, 'stop'): + interrupted = True + yield sse({'e': '回答未生成完整,请点击重试'}) + return full_text = ''.join(parts) limit = get_setting('MAX_ANSWER_CHARS') if has_chinese(full_text) else get_setting('MAX_ANSWER_CHARS_EN') if len(full_text) > limit: @@ -179,6 +221,7 @@ def create_chat_stream(msg, conv_id): full_text = short yield sse({'replace': short}) save_message(conv_id, 'assistant', full_text) + answer_saved = True if is_first_turn: try: fu = gen_followups(msg, full_text) @@ -195,7 +238,9 @@ def create_chat_stream(msg, conv_id): yield sse({'e': '传输中断,请点击重试'}) finally: text = ''.join(parts) - if interrupted and len(text) >= 20: - save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)') - resp.close() + try: + if interrupted and text and not answer_saved: + save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)') + finally: + resp.close() return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) diff --git a/logistics_chat/db.py b/logistics_chat/db.py index 340eb41..d0fc474 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -203,7 +203,12 @@ def save_message(conv_id, role, content): if last and last[0] == 'user' and (last[1] == content): conn.close() return 'duplicate' - cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (conv_id, role, content, now_str())) + inserted = cur.execute('INSERT INTO messages(conv_id, role, content, ctime) ' + 'SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM chat_history WHERE id=?)', + (conv_id, role, content, now_str(), conv_id)).rowcount + if not inserted: + conn.close() + return 'deleted' if role == 'user': row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone() if row and (not row[0] or row[0] == '新对话'): diff --git a/logistics_chat/history_service.py b/logistics_chat/history_service.py index 6222fc3..f97dfa2 100644 --- a/logistics_chat/history_service.py +++ b/logistics_chat/history_service.py @@ -37,6 +37,8 @@ def get_messages(conv_id): def del_history(hid): conn = connect() cur = conn.cursor() + cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions WHERE conv_id=?)', (hid,)) + cur.execute('DELETE FROM human_sessions WHERE conv_id=?', (hid,)) cur.execute('DELETE FROM messages WHERE conv_id=?', (hid,)) cur.execute('DELETE FROM chat_history WHERE id=?', (hid,)) conn.commit() @@ -47,6 +49,8 @@ def del_history(hid): def clear_all(): conn = connect() cur = conn.cursor() + cur.execute('DELETE FROM human_messages') + cur.execute('DELETE FROM human_sessions') cur.execute('DELETE FROM messages') cur.execute('DELETE FROM chat_history') conn.commit() diff --git a/logistics_chat/human_service.py b/logistics_chat/human_service.py index e73c06b..fcb5b73 100644 --- a/logistics_chat/human_service.py +++ b/logistics_chat/human_service.py @@ -14,6 +14,10 @@ def human_request(data): return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'} now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') conn = connect() + conn.execute('BEGIN IMMEDIATE') + if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone(): + conn.close() + return ({'ok':False, 'error':'conversation_not_found'}, 404) row = conn.execute("SELECT id FROM human_sessions WHERE conv_id=? AND status!='closed' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone() if row: sid = row[0] @@ -79,10 +83,11 @@ def human_active(): def human_accept(data): sid = data.get('sid') conn = connect() - conn.execute("UPDATE human_sessions SET status='active' WHERE id=?", (sid,)) + changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') " + 'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount conn.commit() conn.close() - return {'ok': True} + return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409) def human_close(data): diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index cb14554..046e3ac 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -97,6 +97,12 @@ def lookup_order(reference): def extract_reference(message): message = re.sub(r'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message) + message = re.sub(r'(? 1: return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'} - if not references and re.search(r'运费|费用|多少钱|价格', message): + if re.search(r'运费|费用|多少钱|价格', message): return {'t':'该订单未记录实际运费,请联系人工客服核实。'} return {'card': build_card(matches[0])} diff --git a/logistics_chat/pricing.py b/logistics_chat/pricing.py index 05b84fc..65102f0 100644 --- a/logistics_chat/pricing.py +++ b/logistics_chat/pricing.py @@ -120,10 +120,22 @@ def parse_dims(t): return [float(x) for x in dims] return None +def shipping_region(text): + if re.search(r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|' + r'\b(?-i:US)\b|\b(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)\b|\bto\s+us\b', text, re.I): + return 'unsupported' + if re.search(r'英国|伦敦|英格兰|\b(?:UK|United Kingdom|Britain)\b', text, re.I): + return 'uk' + if re.search(r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|' + r'\b(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)\b', text, re.I): + return 'eu' + return None + + def parse_shipping_intent(text): - """从话里抠出:重量(kg)、渠道、长宽高(cm)""" + """解析重量、渠道、尺寸和费率区域;尺寸单位由调用方识别。""" t = text.replace(" ", "") - intent = {"weight": None, "channel": None, "dims": None} + intent = {"weight": None, "channel": None, "dims": None, "region": shipping_region(text)} # 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量) intent["dims"] = parse_dims(text) @@ -192,17 +204,21 @@ def pick_rate(weight_kg, tiers): def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None): """海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档 - country 传入国家代码(DE/FR/PL/CZ/ES)时,优先选择包含该后缀的仓点组。 + country 传入国家代码(DE/FR/PL/CZ/ES)时,仅选择包含该后缀的仓点组;无匹配不报价。 """ if not RX or not RX.WAREHOUSE_CARD: return None keys = list(RX.WAREHOUSE_CARD) key = warehouse_key + if key and key not in RX.WAREHOUSE_CARD: + return None if not key and country: for k in keys: if ("-" + country) in k.upper(): key = k break + if not key: + return None if not key: key = keys[0] row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]] @@ -213,7 +229,9 @@ def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None): tier = "1CBM+" if cbm >= 1 else "0.5CBM+" stores = [s for s in key.splitlines() if s.strip()] if country: - stores = [s for s in stores if ("-" + country) in s.upper()] or stores + stores = [s for s in stores if ("-" + country) in s.upper()] + if not stores: + return None else: stores = stores[:1] note = "报价表中的仓点分组:" + "、".join(stores) @@ -315,7 +333,8 @@ def freight_reply(intent): dims = intent["dims"] unit = intent.get("dim_unit") or "cm" lines = [] - channels = [intent["channel"]] if intent["channel"] else ["sea", "air"] + default_channels = ['sea_uk', 'air_uk'] if intent.get('region') == 'uk' else ['sea', 'air'] + channels = [intent["channel"]] if intent["channel"] else default_channels for ch in channels: c = calc_freight(w, ch, dims, unit) parts = [f"实重 {w:g} kg" if w else "实重:未提供"] diff --git a/logistics_chat/routes/chat.py b/logistics_chat/routes/chat.py index 31884b3..f8b33f0 100644 --- a/logistics_chat/routes/chat.py +++ b/logistics_chat/routes/chat.py @@ -8,17 +8,31 @@ bp = Blueprint('chat', __name__) @bp.route('/stream_chat', methods=['GET', 'POST']) def stream_chat(): + def error(message): + return Response(chat_service.sse({'e':message}) + chat_service.sse({'d':1}), mimetype='text/event-stream') + if request.method == 'POST': - data = request.get_json(silent=True) or {} - msg = (data.get('msg') or '').strip() + data = request.get_json(silent=True) + if not isinstance(data, dict): + return error('请求格式无效,请重新发送问题。') + msg = data.get('msg', '') conv_id = data.get('conv_id') - try: - conv_id = int(conv_id) if conv_id else None - except (TypeError, ValueError): - conv_id = None else: - msg = (request.args.get('msg') or '').strip() - conv_id = request.args.get('conv_id', type=int) + msg = request.args.get('msg', '') + conv_id = request.args.get('conv_id') + if not isinstance(msg, str): + return error('问题必须是文本,请重新输入。') + msg = msg.strip() + if isinstance(conv_id, (bool, float)): + return error('会话编号无效,请新建对话后重试。') + if conv_id not in (None, '', 0): + if not isinstance(conv_id, (str, int)) or not str(conv_id).isdigit(): + return error('会话编号无效,请新建对话后重试。') + conv_id = int(conv_id) + if not 0 < conv_id <= 9223372036854775807: + return error('会话编号无效,请新建对话后重试。') + else: + conv_id = None frames, headers = chat_service.create_chat_stream(msg, conv_id) body = frames if isinstance(frames, str) else stream_with_context(frames) return Response(body, mimetype='text/event-stream', headers=headers) diff --git a/static/js/chat.js b/static/js/chat.js index 3b34d11..8206321 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -7,6 +7,8 @@ const uploadInput = document.getElementById("upload-input"); const composerStatus = document.getElementById("composer-status"); let uploadPending = false; let uploadVersion = 0; +let uploadController = null; +let historyRequestVersion = 0; let followLatest = true; let creatingConv = null; let sendPreparing = false; @@ -28,8 +30,31 @@ function setComposerStatus(message, isError){ composerStatus.classList.toggle("is-error", Boolean(isError)); } +async function requestJson(path, options){ + const controller = new AbortController(); + const timer = setTimeout(function(){ controller.abort(); }, 10000); + try{ + const response = await fetch(path, Object.assign({}, options, {signal:controller.signal})); + const data = await response.json(); + if(!response.ok){ + const error = new Error(data && (data.e || data.error) || t('networkFail')); + error.status = response.status; + throw error; + } + return data; + }finally{ clearTimeout(timer); } +} + function clearAttachment(){ uploadVersion += 1; + if(uploadController){ + uploadController.abort(); + uploadController = null; + uploadPending = false; + uploadBtn.disabled = false; + uploadBtn.removeAttribute('aria-busy'); + uploadBtn.textContent = '+'; + } window.__pendingImg = ""; window.__pendingTag = "[图片]"; const preview = document.getElementById("imgPreview"); @@ -53,7 +78,10 @@ uploadInput.onchange = function(){ uploadBtn.setAttribute("aria-busy", "true"); uploadBtn.textContent = "…"; setComposerStatus(t("uploading")); - fetch("/api/upload", {method:"POST", body:fd}) + const controller = new AbortController(); + uploadController = controller; + const timer = setTimeout(function(){ controller.abort(); }, 15000); + fetch("/api/upload", {method:"POST", body:fd, signal:controller.signal}) .then(async function(response){ const data = await response.json(); if(!response.ok || !data.ok) throw new Error(data.error || t("uploadFailed")); @@ -100,15 +128,18 @@ uploadInput.onchange = function(){ } }) .catch(function(error){ - const message = error instanceof SyntaxError || error instanceof TypeError ? t("uploadFailed") : (error.message || t("uploadFailed")); + const message = error.name === 'AbortError' || error instanceof SyntaxError || error instanceof TypeError ? t("uploadFailed") : (error.message || t("uploadFailed")); if(version === uploadVersion) setComposerStatus(message, true); }) .finally(function(){ - uploadPending = false; - uploadBtn.disabled = false; - uploadBtn.removeAttribute("aria-busy"); - uploadBtn.textContent = "+"; - if(version !== uploadVersion) setComposerStatus(""); + clearTimeout(timer); + if(uploadController === controller){ + uploadController = null; + uploadPending = false; + uploadBtn.disabled = false; + uploadBtn.removeAttribute("aria-busy"); + uploadBtn.textContent = "+"; + } }); uploadInput.value = ""; }; @@ -542,14 +573,18 @@ function welcomeHTML(){ // 清空全部历史 if(clearAllBtn){ clearAllBtn.onclick = function(){ + if(openingConv === 'clearing') return; if(!confirm(t("confirmClear"))) return; const version = resetConversation(null); openingConv = 'clearing'; - fetch("/api/clear_all", {method:"DELETE"}) - .then(response=>{ - if(!response.ok) throw new Error('Clear failed'); + chatDom.innerHTML = welcomeHTML(); + setComposerStatus(t('historyLoading')); + requestJson("/api/clear_all", {method:"DELETE"}) + .then(data=>{ + if(!data.ok) throw new Error('Clear failed'); if(version !== conversationVersion) return; openingConv = null; + setComposerStatus(''); chatDom.innerHTML = welcomeHTML(); loadHistory(); }).catch(function(){ @@ -563,6 +598,7 @@ clearAllBtn.onclick = function(){ // 新建对话:只清界面,等用户开口时再建会话 if(newChatBtn){ newChatBtn.onclick = function(){ + if(openingConv === 'clearing') return; resetConversation(null); inputDom.value = ""; autoGrow(); @@ -576,19 +612,20 @@ newChatBtn.onclick = function(){ } function loadHistory(){ - fetch("/api/get_history") - .then(res=>res.json()) + const version = ++historyRequestVersion; + return requestJson("/api/get_history") .then(data=>{ + if(version !== historyRequestVersion || !Array.isArray(data)) return; historyList = data; renderSideHistory(); renderRecentChat(); - }) + }).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ }); } function renderSideHistory(){ leftHistoryWrap.innerHTML = ""; const kw = (document.getElementById("history-search")?.value || "").trim().toLowerCase(); - const pinned = JSON.parse(localStorage.getItem("pinned_conv") || "[]"); + const pinned = pinnedConversations(); let list = kw ? historyList.filter(item => (item.title || "").toLowerCase().indexOf(kw) >= 0) : historyList.slice(); @@ -637,10 +674,10 @@ function renderSideHistory(){ pinBtn.style.marginRight = "4px"; pinBtn.onclick = (e)=>{ e.stopPropagation(); - let p = JSON.parse(localStorage.getItem("pinned_conv") || "[]"); + let p = pinnedConversations(); if(p.includes(item.id)){ p = p.filter(x=>x!==item.id); } else { p.push(item.id); } - localStorage.setItem("pinned_conv", JSON.stringify(p)); + try{ localStorage.setItem("pinned_conv", JSON.stringify(p)); }catch(error){} renderSideHistory(); }; const delBtn = document.createElement("button"); @@ -650,14 +687,18 @@ function renderSideHistory(){ delBtn.onclick = (e)=>{ e.stopPropagation(); if(confirm(t("confirmDel"))){ - fetch(`/api/del_history?id=${item.id}`, {method:"DELETE"}) - .then(()=>{ + const version = conversationVersion; + return requestJson(`/api/del_history?id=${item.id}`, {method:"DELETE"}) + .then(data=>{ + if(!data.ok) throw new Error('Delete failed'); if(item.id === currentConvId){ - currentConvId = null; + resetConversation(null); chatDom.innerHTML = welcomeHTML(); } - loadHistory(); - }) + return loadHistory(); + }).catch(function(){ + if(version === conversationVersion) setComposerStatus(t('networkFail'), true); + }); } } div.appendChild(textWrap); @@ -668,6 +709,13 @@ function renderSideHistory(){ }) } +function pinnedConversations(){ + try{ + const stored = JSON.parse(localStorage.getItem('pinned_conv') || '[]'); + return Array.isArray(stored) ? stored.filter(Number.isInteger) : []; + }catch(error){ return []; } +} + function renderRecentChat(){ const wrap = document.getElementById("recentChatWrap"); if(!wrap) return; // 切过对话后旧节点会失效,每次重新取 @@ -701,10 +749,9 @@ function ensureConv(){ if(currentConvId) return Promise.resolve(currentConvId); if(creatingConv) return creatingConv; const version = conversationVersion; - const pending = fetch("/api/new_chat", {method:"POST"}) - .then(async function(res){ - const data = await res.json(); - if(!res.ok || !data.id) throw new Error('Session failed'); + const pending = requestJson("/api/new_chat", {method:"POST"}) + .then(function(data){ + if(!data || !data.id) throw new Error('Session failed'); return data; }) .then(data=>{ @@ -725,6 +772,7 @@ function resetConversation(id){ if(cancelActive){ cancelActive(); cancelActive = null; } if(sseInstance){ sseInstance.close(); sseInstance = null; } conversationVersion += 1; + clearAttachment(); stopHumanMode(); currentConvId = id; openingConv = null; @@ -739,15 +787,16 @@ function resetConversation(id){ // 打开历史会话:还原整段对话,而不是把标题重发一遍 let openingConv = null; function openConversation(id){ + if(openingConv === 'clearing') return; if(openingConv === id) return; // 防连点 const version = resetConversation(id); openingConv = id; setComposerStatus(t('historyLoading')); renderSideHistory(); - fetch("/api/get_messages?conv_id=" + id) - .then(res=>{ if(!res.ok) throw new Error('History failed'); return res.json(); }) + requestJson("/api/get_messages?conv_id=" + id) .then(msgs=>{ if(version !== conversationVersion) return; + if(!Array.isArray(msgs)) throw new Error('History failed'); openingConv = null; setComposerStatus(''); chatDom.innerHTML = ""; @@ -896,6 +945,7 @@ window.fillInput = function(words){ // 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史 window.askNewTopic = function(words){ + if(openingConv === 'clearing') return; if(activeQuickTopic === words && (sendPreparing || window.__busy)) return; resetConversation(null); activeQuickTopic = words; @@ -962,23 +1012,13 @@ function stopHumanMode(){ } async function humanRequest(path, data){ - const controller = new AbortController(); - const timer = setTimeout(function(){ controller.abort(); }, 10000); - try{ - const response = await fetch(path, {method:data ? 'POST' : 'GET', - headers:data ? {'Content-Type':'application/json'} : {}, - body:data ? JSON.stringify(data) : undefined, signal:controller.signal}); - const result = await response.json(); - if(!response.ok){ - const error = new Error('Human request failed'); - error.status = response.status; - throw error; - } - return result; - }finally{ clearTimeout(timer); } + return requestJson(path, {method:data ? 'POST' : 'GET', + headers:data ? {'Content-Type':'application/json'} : {}, + body:data ? JSON.stringify(data) : undefined}); } window.showHuman = async function(){ + if(openingConv === 'clearing') return; if(humanRequestToken || humanClosing) return; if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; } if(window.humanSid) stopHumanMode(); @@ -1213,7 +1253,7 @@ function addMsg(content, isUser, authorRole){ function renderError(bubble, text, retry){ bubble.classList.remove("typing"); bubble.classList.add("error-bubble"); - bubble.innerHTML = ""; + if(!bubble.dataset.raw) bubble.innerHTML = ""; const line = document.createElement("div"); line.className = "err-line"; line.textContent = "⚠️ " + (/API Key|DEEPSEEK/i.test(text) ? t("serviceUnavailable") : text); @@ -1346,6 +1386,7 @@ async function doSend(retryText){ const sendWord = (isRetry ? retryText : inputDom.value).trim(); var imgUrl = isRetry ? "" : (window.__pendingImg || ""); var tag = window.__pendingTag || "[图片]"; + const draft = inputDom.value, attachmentVersion = uploadVersion; if(!sendWord && !imgUrl){ inputDom.focus(); return; @@ -1378,10 +1419,8 @@ async function doSend(retryText){ if(version !== conversationVersion || humanRequestToken) return; if(!isRetry){ addMsg(fullContent, true); - inputDom.value = ""; - autoGrow(); - window.__pendingImg = ""; window.__pendingTag = "[图片]"; - var p = document.getElementById("imgPreview"); if(p) p.remove(); + if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); } + if(attachmentVersion === uploadVersion) clearAttachment(); inputDom.focus(); } @@ -1394,7 +1433,7 @@ async function doSend(retryText){ let firstChunk = true; let settled = false; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开" - const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 15000); + const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 90000); const totalTimer = setTimeout(function(){ fail("生成时间过长,已中断,请重试"); }, 120000); function cleanup(){ @@ -1477,8 +1516,10 @@ async function doSend(retryText){ }catch(err){ obj = {t: dataText}; // 兼容旧的纯文本帧 } + if(!obj || typeof obj !== 'object') return; if(obj.e){ fail(obj.e); return; } if(obj.card){ + clearTimeout(firstTimer); aiBubble.classList.add("track-card"); fillTrackCard(aiBubble, obj.card); aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" + @@ -1488,6 +1529,7 @@ async function doSend(retryText){ return; } if(obj.replace){ + clearTimeout(firstTimer); aiBubble.dataset.raw = obj.replace; aiBubble.innerHTML = parseBold(obj.replace); scrollChatToEnd(); @@ -1524,34 +1566,43 @@ async function doSend(retryText){ headers: {"Content-Type": "application/json"}, body: JSON.stringify({msg: fullContent, conv_id: convId}), signal: controller.signal - }).then(function(res){ - if(!res.ok){ throw new Error("HTTP " + res.status); } + }).then(async function(res){ + if(!res.ok){ + let data; + try{ data = await res.json(); }catch(e){} + fail(data && (data.e || data.error) || t('networkFail')); + return; + } + if(!res.body || typeof res.body.getReader !== 'function'){ fail(t('networkFail')); return; } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; + function drain(final){ + function dispatch(frame){ + const data = frame.split(/\r?\n/).filter(line => line.indexOf('data:') === 0) + .map(line => line.slice(5).trimStart()).join('\n'); + if(data) handleFrame(data); + } + let boundary; + while(!settled && (boundary = /\r?\n\r?\n/.exec(buffer))){ + const frame = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary[0].length); + dispatch(frame); + } + if(final && !settled && buffer.trim()){ dispatch(buffer); buffer = ''; } + } function pump(){ return reader.read().then(function(r){ + if(settled) return; if(r.done){ - // Single-frame attachment acknowledgements may end without a d frame. - if(!settled){ - if(aiBubble.dataset.raw) finish(); - else fail(t("networkFail")); - } + buffer += decoder.decode(); + drain(true); + if(!settled) fail(t("networkFail")); return; } buffer += decoder.decode(r.value, {stream: true}); - let idx; - // SSE 以空行分隔事件 - while((idx = buffer.indexOf("\n\n")) >= 0){ - const chunk = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); - chunk.split("\n").forEach(function(line){ - if(line.indexOf("data:") === 0){ - handleFrame(line.slice(5).trimStart()); - } - }); - } - return pump(); + drain(false); + if(!settled) return pump(); }); } return pump(); diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 425667e..0fa4e1f 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -6,8 +6,8 @@ const vm = require('node:vm'); const flush = () => new Promise(setImmediate); async function customerPage(pageOptions = {}){ - const elements = new Map(), listeners = {}, intervals = new Map(), calls = [], hooks = {}; - let intervalId = 0, nextConvId = 1; + const elements = new Map(), listeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {}; + let intervalId = 0, timerId = 0, nextConvId = 1; class Element { constructor(){ this.children = []; this.attributes = {}; this.value = ''; this.style = {}; @@ -25,6 +25,7 @@ async function customerPage(pageOptions = {}){ set textContent(value){ this.text = value; this.html = ''; this.children = []; } get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); } setAttribute(name, value){ this.attributes[name] = value; } + removeAttribute(name){ delete this.attributes[name]; } getAttribute(name){ return this.attributes[name]; } addEventListener(name, callback){ this.listeners[name] = callback; } click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); } @@ -61,14 +62,17 @@ async function customerPage(pageOptions = {}){ class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } } let state = {status:'active', messages:[]}; const context = vm.createContext({ - document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, + document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true, + FormData:class { append(){} }, requestAnimationFrame:callback => callback(), - localStorage:{getItem:() => null, setItem(){}}, + localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}}, window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), prompt:() => pageOptions.offlineMessage || null}, setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; }, - clearInterval:id => intervals.delete(id), setTimeout(){}, clearTimeout(){}, + clearInterval:id => intervals.delete(id), + setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; }, + clearTimeout:id => timers.delete(id), fetch: async (url, options) => { - calls.push({url, body:options && options.body && JSON.parse(options.body)}); + calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)}); if(hooks[url]) return hooks[url](options); if(url.startsWith('/api/human/poll') && hooks.poll) return hooks.poll(url); if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url); @@ -83,7 +87,7 @@ async function customerPage(pageOptions = {}){ const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'} : url === '/api/human/request' ? {ok:true, session_id:7} : url.startsWith('/api/human/poll') ? state - : ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) ? {ok:true} + : ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true} : url === '/api/get_history' ? (pageOptions.history || []) : []; return {ok:true, json:async () => data}; }, @@ -97,6 +101,7 @@ async function customerPage(pageOptions = {}){ await flush(); return {context, element, calls, hooks, quickButtons, setState:value => { state=value; }, + runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); }, tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); }, click:async button => { await button.click(); await flush(); }, }; @@ -117,6 +122,162 @@ test('the final customer-visible reply is processed before closure and a rating assert.equal(app.context.window.humanSid, 0); }); +function streamResponse(chunks){ + let index=0; + return {ok:true, body:{getReader:() => ({read:async () => index < chunks.length + ? {done:false, value:typeof chunks[index] === 'string' ? new TextEncoder().encode(chunks[index++]) : chunks[index++]} + : {done:true}})}}; +} + +test('AI send preserves a newer draft typed while the conversation is being created', async () => { + const app=await customerPage({human:false}); + let resolve; + app.hooks['/api/new_chat']=() => new Promise(done => { resolve=done; }); + const sending=app.context.window.fillInput('第一条问题'); + app.element('input-text').value='正在补充的下一条问题'; + resolve(response({id:18,welcome:'welcome'})); + await sending; + await flush(); + assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '第一条问题'); + assert.equal(app.element('input-text').value, '正在补充的下一条问题'); +}); + +test('switching to a quick topic discards the previous topic attachment', async () => { + const app=await customerPage({human:false}); + app.context.window.__pendingImg='/uploads/20261029100145_6787.txt'; + app.context.window.__pendingTag='[文件]'; + await app.click(app.quickButtons[1]); + assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价'); +}); + +test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => { + const app=await customerPage({human:false}); + const bytes=new TextEncoder().encode('data: {"t":"完整中文回复"}\r\n\r\ndata: {"d":1}\r\n\r\n'); + const chunks=Array.from({length:Math.ceil(bytes.length/5)}, (_,index) => bytes.slice(index*5,index*5+5)); + app.hooks['/stream_chat']=() => streamResponse(chunks); + await app.context.window.fillInput('请回复'); + await flush(); + assert.match(app.element('chatDom').textContent, /完整中文回复/); + assert.equal(app.element('chatDom').querySelector('.retry-btn'), null); + assert.equal(app.context.window.__busy, false); +}); + +test('a stream ending without completion retains partial text and offers retry', async () => { + const app=await customerPage({human:false}); + app.hooks['/stream_chat']=() => streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']); + await app.context.window.fillInput('请继续说明'); + await flush(); + assert.match(app.element('chatDom').textContent, /已经收到的部分回答/); + assert.ok(app.element('chatDom').querySelector('.retry-btn')); + assert.equal(app.context.window.__busy, false); +}); + +test('rate limit responses show the server message and allow retry', async () => { + const app=await customerPage({human:false}); + app.hooks['/stream_chat']=() => response({e:'请求太频繁,请稍后再试'},429); + await app.context.window.fillInput('请查单'); + await flush(); + assert.match(app.element('chatDom').textContent, /请求太频繁/); + assert.ok(app.element('chatDom').querySelector('.retry-btn')); +}); + +test('a stalled conversation creation times out and can be retried without losing the draft', async () => { + const app=await customerPage({human:false}); + app.hooks['/api/new_chat']=options => new Promise((resolve,reject) => { + if(options.signal) options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'), {name:'AbortError'}))); + }); + app.context.window.fillInput('请帮我查询'); + await app.runTimers(10000); + assert.match(app.element('composer-status').textContent, /会话创建失败/); + assert.equal(app.element('input-text').value, '请帮我查询'); + app.hooks['/api/new_chat']=() => response({id:19,welcome:'welcome'}); + await app.context.window.fillInput('请帮我查询'); + await flush(); + assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1); +}); + +test('history refresh failures preserve the last successfully loaded list', async () => { + const app=await customerPage({human:false,history:[{id:9,title:'保留的对话',ctime:'2026-10-29 12:00'}]}); + app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429); + await app.click(app.element('.new-chat-btn')); + await flush(); + assert.match(app.element('historyWrap').textContent, /保留的对话/); +}); + +test('deleting the active conversation aborts its stream and resets the composer', async () => { + const app=await customerPage({human:false,history:[{id:9,title:'当前对话',ctime:'2026-10-29 12:00'}]}); + await app.click(app.element('historyWrap').children[0]); + app.hooks['/stream_chat']=() => ({ok:true,body:{getReader:() => ({read:() => new Promise(() => {})})}}); + await app.context.window.fillInput('请等待回复'); + await flush(); + const remove=app.element('historyWrap').children[0].children[2]; + await remove.onclick({stopPropagation(){}}); + await flush(); + assert.equal(app.context.window.__busy,false); + assert.equal(app.element('send-button').textContent,'发送'); + assert.ok(app.calls.find(call => call.url === '/stream_chat')); +}); + +test('switching topics aborts an upload and does not erase the new conversation status', async () => { + const app=await customerPage({human:false}); + let aborted=false; + app.hooks['/api/upload']=options => new Promise((resolve,reject) => { + options.signal.addEventListener('abort', () => { aborted=true; reject(Object.assign(new Error('aborted'),{name:'AbortError'})); }); + }); + app.element('upload-input').files=[{name:'old.png'}]; + app.element('upload-input').onchange(); + await app.click(app.quickButtons[0]); + await flush(); + assert.equal(aborted,true); + assert.equal(app.element('upload-btn').disabled,false); + assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1); +}); + +test('an upload timeout releases the composer and reports an actionable failure', async () => { + const app=await customerPage({human:false}); + app.hooks['/api/upload']=options => new Promise((resolve,reject) => { + options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'),{name:'AbortError'}))); + }); + app.element('upload-input').files=[{name:'slow.png'}]; + app.element('upload-input').onchange(); + await app.runTimers(15000); + assert.equal(app.element('upload-btn').disabled,false); + assert.match(app.element('composer-status').textContent,/附件上传失败/); + await app.context.window.fillInput('请继续查单'); + await flush(); + assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1); +}); + +test('malformed stored pin preferences cannot break the conversation list', async () => { + const app=await customerPage({human:false,storage:{pinned_conv:'{broken'},history:[{id:9,title:'正常对话',ctime:'now'}]}); + assert.match(app.element('historyWrap').textContent,/正常对话/); +}); + +test('clearing history blocks new conversations until its timed request settles', async () => { + const app=await customerPage({human:false,history:[{id:9,title:'保留历史',ctime:'now'}]}); + app.hooks['/api/clear_all']=options => new Promise((resolve,reject) => { + options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'),{name:'AbortError'}))); + }); + app.element('.clear-all-btn').click(); + await app.click(app.quickButtons[1]); + await app.context.window.showHuman(); + assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0); + await app.runTimers(10000); + assert.match(app.element('historyWrap').textContent,/保留历史/); + await app.click(app.quickButtons[0]); + assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1); +}); + +test('a failed history deletion preserves the active conversation for the next message', async () => { + const app=await customerPage({human:false,history:[{id:9,title:'当前历史',ctime:'now'}]}); + await app.click(app.element('historyWrap').children[0]); + app.hooks['/api/del_history?id=9']=() => response({ok:false},503); + await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}}); + await app.context.window.fillInput('继续这个问题'); + await flush(); + assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id,9); +}); + test('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:'最新回复'}]}); diff --git a/tests/test_api.py b/tests/test_api.py index bd7eb5b..a59edce 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -122,6 +122,83 @@ class ApiTests(unittest.TestCase): self.assertEqual(events[0]['card']['no'], '15252125') self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment) + def test_image_attachments_do_not_swallow_order_questions_and_are_saved(self): + cid = self.conversation() + image = '[图片]/uploads/20261029100145_6787.png' + events = frames(self.client.post('/stream_chat', json={'msg':'查询15252125 '+image, 'conv_id':cid})) + self.assertEqual(events[0]['card']['no'], '15252125') + events = frames(self.client.post('/stream_chat', json={'msg':image, 'conv_id':cid})) + self.assertIn('收到您的图片', events[0]['t']) + messages = self.client.get(f'/api/get_messages?conv_id={cid}').json + self.assertEqual(messages[-2]['content'], image) + self.assertIn('收到您的图片', messages[-1]['content']) + + def test_bad_chat_payloads_produce_readable_errors_without_calling_model(self): + self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key' + invalid = ([1], 'hello', {'msg':7}, {'msg':['test']}, {'msg':'物流咨询', 'conv_id':{}}, + {'msg':'物流咨询', 'conv_id':-1}, {'msg':'物流咨询', 'conv_id':1.5}, {'msg':'物流咨询','conv_id':False}, + {'msg':'物流咨询','conv_id':999999999999999999999999}) + with patch('logistics_chat.ai_client.post_completion') as model: + for payload in invalid: + with self.subTest(payload=payload): + events = frames(self.client.post('/stream_chat', json=payload)) + self.assertIn('e', events[0]) + model.assert_not_called() + + def test_deleted_conversations_close_human_sessions_and_reject_new_chat_messages(self): + cid = self.conversation() + with patch('logistics_chat.human_service.datetime') as clock: + from datetime import datetime + clock.now.return_value = datetime(2026,10,29,12) + sid = self.client.post('/api/human/request',json={'conv_id':cid}).json['session_id'] + self.client.post('/api/human/send',json={'sid':sid,'content':'原会话内容'}) + self.client.delete(f'/api/del_history?id={cid}') + self.assertEqual(self.client.get('/api/human/queue').json, []) + self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], []) + with patch('logistics_chat.ai_client.post_completion') as model: + events = frames(self.client.post('/stream_chat',json={'msg':'15252125','conv_id':cid})) + self.assertIn('已删除',events[0]['e']) + model.assert_not_called() + with patch('logistics_chat.human_service.datetime') as clock: + clock.now.return_value = datetime(2026,10,29,12) + self.assertEqual(self.client.post('/api/human/request',json={'conv_id':cid}).status_code,404) + + def test_uk_quotes_and_followups_keep_the_uk_rates(self): + self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key' + cid = self.conversation() + for message, expected in (('30kg英国空运报价','英国空运专线'), ('那海运呢?','英国海运'), + ('那空运呢?','英国空运专线'), ('那50公斤呢?','英国空运专线')): + with self.subTest(message=message): + with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]): + frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid})) + prompt = model.call_args.args[0]['messages'][0]['content'] + self.assertIn(expected,prompt) + self.assertNotIn('欧洲空运专线',prompt) + self.assertNotIn('欧洲海运快线',prompt) + + def test_us_quotes_never_reuse_european_price_tables(self): + with patch('logistics_chat.ai_client.post_completion') as model: + events = frames(self.client.post('/stream_chat',json={'msg':'30KG发美国FBA仓,海运报价','conv_id':self.conversation()})) + self.assertIn('目的地',events[0]['t']) + self.assertIn('人工',events[0]['t']) + model.assert_not_called() + + def test_uk_volume_quotes_do_not_reuse_continental_warehouse_prices(self): + with patch('logistics_chat.ai_client.post_completion') as model: + events = frames(self.client.post('/stream_chat',json={'msg':'2方发英国海运多少钱'})) + self.assertIn('未提供',events[0]['t']) + self.assertIn('按方费率',events[0]['t']) + model.assert_not_called() + + def test_closed_human_sessions_cannot_be_reopened_by_a_late_accept(self): + with patch('logistics_chat.human_service.datetime') as clock: + from datetime import datetime + clock.now.return_value = datetime(2026,10,29,12) + sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id'] + self.client.post('/api/human/close',json={'sid':sid}) + self.assertEqual(self.client.post('/api/human/accept',json={'sid':sid}).status_code,409) + self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'],'closed') + def test_human_lifecycle_typing_and_read_receipts(self): self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400) with patch('logistics_chat.human_service.datetime') as clock: @@ -238,6 +315,26 @@ class ApiTests(unittest.TestCase): self.assertEqual(events[-1], {'d': 1}) self.assertEqual([call.kwargs['timeout'] for call in post.call_args_list], [(5, 90), (5, 30), (5, 15)]) self.assertTrue(streamed.closed) + self.assertTrue(all(response.closed for response in responses)) + + def test_incomplete_model_streams_are_reported_and_saved_as_partial(self): + self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key' + for reason in (None, 'length'): + with self.subTest(reason=reason): + cid = self.conversation() + response = ModelResponse('这是已经生成的部分内容,需要保留以便用户了解中断前的信息。') + def lines(): + yield ('data: '+json.dumps({'choices':[{'delta':{'content':response.content}, 'finish_reason':reason}]})).encode() + if reason: + yield b'data: [DONE]' + response.iter_lines = lines + with patch('logistics_chat.ai_client.post_completion', return_value=response): + events = frames(self.client.post('/stream_chat', json={'msg':'物流咨询', 'conv_id':cid})) + self.assertTrue(any('e' in event for event in events)) + self.assertFalse(any(event.get('d') for event in events)) + self.assertTrue(response.closed) + messages = self.client.get(f'/api/get_messages?conv_id={cid}').json + self.assertIn('回答中断', messages[-1]['content']) def test_stream_network_and_model_errors(self): self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key' diff --git a/tests/test_order_data.py b/tests/test_order_data.py index 76b4ea1..b83cc4c 100644 --- a/tests/test_order_data.py +++ b/tests/test_order_data.py @@ -51,12 +51,23 @@ class OrderDataTests(unittest.TestCase): self.assertEqual(order_data.extract_reference('查询 INB20261029001-01'), ['INB20261029001-01']) def test_weight_dates_and_attachment_urls_are_not_order_identifiers(self): - for text in ('2000KG海运报价', '2026-10-29', '[文件]/uploads/20261029100145_6787.txt'): + for text in ('2000KG海运报价', '100000 kg海运报价', '30.000000 KG', '2026-10-29', + '2026-10-29T16:53:00', '[文件]/uploads/20261029100145_6787.txt'): self.assertEqual(order_data.extract_reference(text), []) events = self.events('帮我查询物流轨迹') self.assertIn('订单号', events[0]['t']) self.assertEqual(events[-1], {'d':1}) + def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self): + for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的', + 'FL202610290001 30.000000 KG', 'FL202610290001 运费多少钱?'): + with self.subTest(message=message): + event = self.events(message)[0] + if '运费' in message: + self.assertIn('未记录实际运费', event['t']) + else: + self.assertEqual(event['card']['no'], self.record['order_no']) + def test_unknown_orders_never_produce_fake_tracking_or_call_model(self): with patch('logistics_chat.ai_client.post_completion') as model: events = self.events('查询 UNKNOWN20260001') diff --git a/tests/test_rules.py b/tests/test_rules.py index 56ed147..6d6b2c2 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -41,6 +41,12 @@ class RuleTests(unittest.TestCase): self.assertEqual(pricing.parse_shipping_intent('1吨铁路')['weight'], 1000) self.assertEqual(pricing.parse_shipping_intent('100 lbs air')['weight'], 45.36) + def test_shipping_regions_distinguish_country_codes_from_english_pronouns(self): + self.assertEqual(pricing.shipping_region('Give us a quote for 30kg to Germany'),'eu') + self.assertEqual(pricing.shipping_region('30kg air to US'),'unsupported') + self.assertEqual(pricing.shipping_region('30kg air to us'),'unsupported') + self.assertEqual(pricing.shipping_region('30kg to UK'),'uk') + def test_warehouse_country_and_surcharge(self): result = pricing.calc_warehouse_card(2, '海运', country='DE') self.assertIn('-DE', result['warehouse']) @@ -51,6 +57,8 @@ class RuleTests(unittest.TestCase): self.assertIn('500', pricing.surcharge_reply(product, fee, 100)) self.assertEqual(pricing.check_island('07000')[1], 'IT') self.assertIsNone(pricing.check_island('not a postcode')) + self.assertIsNone(pricing.calc_warehouse_card(2, '海运', country='GB')) + self.assertIsNone(pricing.calc_warehouse_card(2, '海运', warehouse_key='unknown')) def test_tracking_recognition_and_card_context(self): self.assertEqual(tracking.extract_tracking_no('查询15252125'), '15252125') diff --git a/tests/test_storage.py b/tests/test_storage.py index bdcd115..230616a 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -117,6 +117,18 @@ class StorageTests(unittest.TestCase): self.assertEqual(connection.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0], 0) connection.close() + def test_late_stream_writes_cannot_recreate_messages_for_deleted_conversations(self): + connection = db.connect() + cid = connection.execute("INSERT INTO chat_history(title,ctime) VALUES('temporary','now')").lastrowid + connection.commit() + db.save_message(cid,'user','发送中的问题') + connection.execute('DELETE FROM messages WHERE conv_id=?',(cid,)) + connection.execute('DELETE FROM chat_history WHERE id=?',(cid,)) + connection.commit() + db.save_message(cid,'assistant','迟到的模型回复') + self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?',(cid,)).fetchone()[0],0) + connection.close() + if __name__ == '__main__': unittest.main()