修复聊天异步状态、流中断和报价上下文

This commit is contained in:
2026-10-29 18:41:02 +08:00
parent a081966342
commit 1f66307bba
16 changed files with 565 additions and 116 deletions
+3
View File
@@ -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 天 |
+3 -3
View File
@@ -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 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
+8
View File
@@ -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 []
+67 -22
View File
@@ -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'})
+6 -1
View File
@@ -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] == '新对话'):
+4
View File
@@ -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()
+7 -2
View File
@@ -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):
+7 -1
View File
@@ -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'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
r'(?![A-Za-z0-9_-])', ' ', message)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
r'(?:kg|lbs?|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
references = []
for token in IDENTIFIER.findall(message):
if sum(character.isdigit() for character in token) < 3:
@@ -192,6 +198,6 @@ def answer_query(message, conv_id):
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
if len(matches) > 1:
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
if not references and re.search(r'运费|费用|多少钱|价格', message):
if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
return {'card': build_card(matches[0])}
+24 -5
View File
@@ -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 "实重:未提供"]
+22 -8
View File
@@ -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)
+117 -66
View File
@@ -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();
+168 -7
View File
@@ -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:'最新回复'}]});
+97
View File
@@ -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'
+12 -1
View File
@@ -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')
+8
View File
@@ -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')
+12
View File
@@ -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()