保留历史消息时间并恢复订单卡片操作

This commit is contained in:
2026-10-29 18:43:25 +08:00
parent 1f66307bba
commit f4e87b0c02
3 changed files with 33 additions and 11 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ tests/ # 按业务模块组织的回归
- `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` 访问。
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 在报价追问中保留重量、尺寸、渠道和区域,遇到新的完整问题停止回溯;英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
+18 -10
View File
@@ -807,12 +807,13 @@ function openConversation(id){
msgs.forEach(m=>{
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
try{
addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)));
const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime);
attachActions(bubble, m.content, '', false);
}catch(e){
addMsg(m.content, false);
addMsg(m.content, false, undefined, m.ctime);
}
}else{
const b = addMsg(m.content, m.role === "user");
const b = addMsg(m.content, m.role === "user", undefined, m.ctime);
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
}
});
@@ -925,12 +926,23 @@ function createMessageRow(isUser, authorRole){
return row;
}
function addTrackCard(card){
function appendMessageTime(row, timeStr){
const ts = document.createElement('div');
ts.className = 'msg-time';
const stored = typeof timeStr === 'string' && timeStr.match(/(?:^|[T\s])(\d{2}:\d{2})/);
const now = new Date();
ts.textContent = stored ? stored[1] : String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');
if(timeStr) ts.title = timeStr;
row.appendChild(ts);
}
function addTrackCard(card, timeStr){
const row = createMessageRow(false);
const bubble = document.createElement("div");
bubble.className = "robot-message track-card";
fillTrackCard(bubble, card);
row.appendChild(bubble);
appendMessageTime(row, timeStr);
chatDom.appendChild(row);
scrollChatToEnd();
return bubble;
@@ -1180,7 +1192,7 @@ function startHumanPoll(){
humanPollTimer = pollTimer;
}
function addMsg(content, isUser, authorRole){
function addMsg(content, isUser, authorRole, timeStr){
if(chatDom.querySelector('.ai-welcome-card')) chatDom.innerHTML = "";
const rowDiv = createMessageRow(isUser, authorRole);
let robotMsg;
@@ -1236,11 +1248,7 @@ function addMsg(content, isUser, authorRole){
rowDiv.appendChild(robotMsg);
}
// 时间戳
const ts = document.createElement("div");
ts.className = "msg-time";
const d = new Date();
ts.textContent = String(d.getHours()).padStart(2,'0') + ":" + String(d.getMinutes()).padStart(2,'0');
rowDiv.appendChild(ts);
appendMessageTime(rowDiv, timeStr);
chatDom.appendChild(rowDiv);
scrollChatToEnd(Boolean(isUser));
if(!isUser){
+14
View File
@@ -278,6 +278,20 @@ test('a failed history deletion preserves the active conversation for the next m
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id,9);
});
test('history text and order cards preserve their original times and card actions', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'历史时间',ctime:'2026-10-28 07:12'}]});
app.hooks.messages=() => response([
{role:'user',content:'FL202610290001',ctime:'2026-10-28 07:12'},
{role:'assistant',content:'__CARD__'+JSON.stringify({kind:'order',no:'FL202610290001',status:'履约中',fields:[],nodes:[],summary:'订单概要'}),ctime:'2026-10-28 07:13'},
{role:'assistant',content:'后续补充回复',ctime:'2026-10-28 07:14'}
]);
await app.click(app.element('historyWrap').children[0]);
const times=app.element('chatDom').querySelectorAll('.msg-time');
assert.deepEqual(times.map(item => item.textContent),['07:12','07:13','07:14']);
assert.equal(times[0].title,'2026-10-28 07:12');
assert.ok(app.element('chatDom').querySelector('.track-card').querySelector('.msg-actions'));
});
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:'最新回复'}]});