Compare commits

..
10 Commits
Author SHA1 Message Date
kicer e9adbc90cf fix: 防止 AI 在信息不完整时误建无效采购记录
- AI prompt 新增采购意图校验:无消费意图→ignore,信息不全→ask
- 后端 purchase 入口增加兜底拦截:purchase_item 无效(<2字符)直接拒绝
- 杜绝 'deep' 等碎片化信息被当成物品名创建记录
2026-07-22 16:24:21 +08:00
kicer c61d64a249 双击放大后显示表格线 2026-07-22 16:16:20 +08:00
kicer 5f73c384cf fix: 扩展 use_recent_image 触发词,覆盖更自然的引用表述
- AI prompt 新增触发词:上面/前面/刚刚那张是XX的图片、那张图片存到XX
- 客户端 pendingUploads 每次发送后清空是正常行为,不能依赖
- 引用历史图片必须走 use_recent_image 机制
- 移除 purchase 分支和 POST 入口的调试日志
2026-07-22 16:07:14 +08:00
kicer fb07144ef3 debug: purchase 分支和 POST 入口加 attachments 日志
- POST /api/rooms/:roomId/messages 入口打印收到的 attachments
- handleAIResult purchase 分支开头打印 attachments 值
- 用于定位附件关联失败的具体环节
2026-07-22 16:04:41 +08:00
kicer 742bb908a5 fix: 防止 is_new + 附件导致误建空白采购记录
- AI prompt 新增「绝对禁止规则」:纯图片+物品名请求禁止返回 is_new
- AI prompt「新建 vs 更新」补充:附件请求不是新建
- 后端兜底:is_new 但附件非空时,查已有同名记录,存在则强制转为更新模式
- 避免创建 quantity=1 amount=0 的无意义记录
2026-07-22 16:00:20 +08:00
kicer 3ea58fcf3d backup 2026-07-22 15:58:46 +08:00
kicer 3d7732474d fix: delete 支持 quantity/amount 精确匹配,避免误删同名记录
- AI prompt 新增「删除规则补充」:用户指定数量/金额时同时返回对应字段
- 后端 handleAIResult delete 分支改为动态构建 SQL,quantity/amount 存在时追加 AND 条件
- 新增删除查询日志方便排查
2026-07-22 15:44:33 +08:00
kicer 4fe5abb74a fix issues 2026-07-22 15:43:00 +08:00
kicer cf63ed5e3f backup codes 2026-07-22 15:38:37 +08:00
kicer 2feb6004b8 附件图片双击放大显示 2026-07-22 15:12:14 +08:00
2 changed files with 122 additions and 21 deletions
+42 -3
View File
@@ -110,6 +110,14 @@
overflow: auto; font-size: 16px; line-height: 1.5; color: #333;
}
.fullscreen-overlay .fullscreen-text img { max-width: 200px; border-radius: 8px; margin: 4px 0; }
.fullscreen-overlay .fullscreen-text table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
}
.fullscreen-overlay .fullscreen-text th,
.fullscreen-overlay .fullscreen-text td {border: 1px solid #ddd;padding: 4px;text-align: left;font-size: 14px;}
.fullscreen-overlay .fullscreen-text th {background: #f0f0f0;}
.detail-row { margin-bottom: 8px; }
</style>
@@ -368,6 +376,14 @@
document.body.appendChild(overlay);
}
function onImgThumbDblClick(img) {
const overlay = document.createElement('div');
overlay.className = 'fullscreen-overlay';
overlay.innerHTML = `<img src="${img.src}" style="max-width:100%;max-height:100%;object-fit:contain" alt="">`;
overlay.addEventListener('click', () => overlay.remove());
document.body.appendChild(overlay);
}
function onBubbleDblClick(e) {
e.stopPropagation();
const bubble = e.currentTarget;
@@ -382,7 +398,7 @@
document.body.appendChild(overlay);
}
// 临时输入中...气泡
// 临时"输入中..."气泡
function insertTempBubble() {
clearTempBubble();
const tempId = 'temp_' + Date.now();
@@ -391,10 +407,30 @@
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(tempMsg));
scrollToBottom();
tempBubbleTimer = setTimeout(() => { clearTempBubble(); }, 8000);
// 降级轮询:如果 WebSocket 消息丢失,定时从服务端拉取
if (pollTimer) clearInterval(pollTimer);
let pollCount = 0;
pollTimer = setInterval(async () => {
pollCount++;
if (!currentRoom || pollCount > 5) { clearInterval(pollTimer); pollTimer = null; return; }
try {
const token = getToken();
const res = await fetch(API + `/api/rooms/${currentRoom}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
const msgs = await res.json();
const lastCacheId = messageCache.filter(m => !m.isTemp && !m.isPlaceholder).slice(-1)[0]?.id;
const newMsgs = msgs.filter(m => !lastCacheId || m.id > lastCacheId);
if (newMsgs.length) {
clearInterval(pollTimer); pollTimer = null;
clearTempBubble();
newMsgs.forEach(m => appendMessage(m));
}
} catch(e) {}
}, 2000);
}
function clearTempBubble() {
if (tempBubbleTimer) { clearTimeout(tempBubbleTimer); tempBubbleTimer = null; }
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
messageCache = messageCache.filter(m => !m.isTemp);
const tempEls = messagesContainer.querySelectorAll('[data-msg-id^="temp_"]');
tempEls.forEach(el => el.remove());
@@ -404,6 +440,7 @@
let wsReconnectTimer = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000;
let pollTimer = null; // 降级轮询定时器
function initWebSocket() {
const token = getToken();
@@ -417,6 +454,7 @@
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log('📩 WS 收到:', data.type);
if (data.type === 'new_message') {
const msg = data.message;
if (currentRoom === msg.room_id) {
@@ -715,7 +753,8 @@
}
function formatMonth(dateStr) {
const parts = dateStr.split('/');
const clean = dateStr.replace(/-/g, '/');
const parts = clean.split('/');
if (parts.length >= 2) return `${parts[0]}${parseInt(parts[1], 10)}`;
return dateStr.substring(0,7);
}
@@ -733,7 +772,7 @@
const p = await res.json();
document.getElementById('detail-title').textContent = p.item;
let attachHtml = '';
if (p.attachments?.length) attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}">`).join('') + '</div>';
if (p.attachments?.length) attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}" ondblclick="onImgThumbDblClick(this)">`).join('') + '</div>';
const historyHtml = p.history?.map(h => `<li><span class="history-time">${h.timestamp}</span> ${h.user}: ${h.action}</li>`).join('') || '';
document.getElementById('detail-content').innerHTML = `
<div class="detail-row"><strong>数量:</strong>${p.quantity}</div>
+79 -17
View File
@@ -27,6 +27,19 @@ const TIMEZONE = 'Asia/Shanghai';
function timestamp() {
return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false });
}
function normalizeTime(str) {
if (!str) return timestamp();
// 已经是标准格式 yyyy/M/d HH:mm:ss 直接返回
if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(str)) return str;
// 纯日期 yyyy-MM-dd 或 yyyy/MM/dd → 补充 00:00:00
const clean = str.replace(/-/g, '/');
if (/^\d{4}\/\d{1,2}\/\d{1,2}$/.test(clean)) return clean + ' 00:00:00';
// 带时间但分隔符是 - → 统一换成 /
const withSlash = str.replace(/-/g, '/');
if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(withSlash)) return withSlash;
// 无法识别,返回当前时间
return timestamp();
}
function currentTimeStr() { return timestamp(); }
// 初始化用户
@@ -103,7 +116,7 @@ app.get('/api/rooms', auth, (req, res) => {
rooms = db.prepare("SELECT * FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`);
}
const result = rooms.map(room => {
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(room.id);
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(room.id);
return {
...room,
last_message: lastMsg ? lastMsg.text : '',
@@ -144,12 +157,13 @@ app.delete('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
});
app.get('/api/rooms/:roomId/messages', auth, (req, res) => {
const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY timestamp ASC').all(req.params.roomId);
const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY id ASC').all(req.params.roomId);
res.json(msgs);
});
app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
const { text, attachments } = req.body;
console.log('📨 收到消息, text:', (text||'').substring(0,30), 'attachments:', JSON.stringify(attachments));
const roomId = req.params.roomId;
const username = req.user.username;
const now = timestamp();
@@ -159,7 +173,7 @@ app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
);
const msg = { id: result.lastInsertRowid, room_id: roomId, user: username, text: text || '', attachments: attachments || [], timestamp: now };
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId);
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(roomId);
broadcastToRoomExcludeSelf(roomId, username, {
type: 'new_message', message: msg,
room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' }
@@ -195,7 +209,7 @@ app.get('/api/rooms/:roomId/purchases', auth, (req, res) => {
app.get('/api/purchases/:id', auth, (req, res) => {
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!pur) return res.status(404).json({ error: '未找到' });
const history = db.prepare('SELECT * FROM purchase_history WHERE purchase_id = ? ORDER BY timestamp ASC').all(req.params.id);
const history = db.prepare('SELECT * FROM purchase_history WHERE purchase_id = ? ORDER BY id ASC').all(req.params.id);
const attachments = db.prepare('SELECT * FROM purchase_attachments WHERE purchase_id = ?').all(req.params.id);
res.json({ ...pur, history, attachments });
});
@@ -273,7 +287,7 @@ function storeAndBroadcastText(roomId, user, text) {
const now = timestamp();
const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, now);
const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp: now };
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId);
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(roomId);
broadcastToRoom(roomId, { type: 'new_message', message: msg, room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' } });
return msg;
}
@@ -334,19 +348,37 @@ async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) {
- 删除所有金额为0的记录:delete_item="__AMOUNT_ZERO__"
- 删除物品名为空、null、undefined 的记录:delete_item="__NULL_NAME__"
- 不要再返回 action="ask" 来处理删除请求,只要确定是删除意图,就必须返回 action="delete" 并填好 delete_item。
- 若用户描述的是特定记录(如名字叫XXX的),则 delete_item 填那个物品名。
- 若用户描述的是特定记录(如"名字叫XXX的"),则 delete_item 填那个物品名。
⚠️ 删除规则补充(重要):
- 如果用户说"删除数量X的那条/那个记录",你必须同时返回 quantity 字段,值为 X。
- 如果用户提到"金额为Y的/价格Y的"来区分记录,你必须同时返回 amount 字段,值为 Y。
- 提供这些字段后,系统只会精确删除匹配的那一条,避免误删同名记录。
⚠️ 附件补充规则:
- 如果用户发送图片并明确说"这是XX的图片"或"作为附件存到XX采购里"
你只需返回 purchase_item 为 XX**绝对不要**返回 quantity、unit_price、amount、freight、payment_method 等任何字段。
- 当消息中只有图片且文本明确指出"这是XX的图片"时,提取 purchase_item 为 XX,其余字段全部省略。
- 如果用户要求"把刚才/之前的图片作为附件存到XX采购",你可以返回 {"action":"purchase","purchase_item":"XX","use_recent_image":true}
- 如果用户要求"把刚才/之前的图片作为附件存到XX采购",或说"上面/前面/刚刚那张是XX的图片""那张图片存到XX"等引用历史上传图片的表述,你必须返回 {"action":"purchase","purchase_item":"XX","use_recent_image":true}
不提供任何数量、价格字段,系统会自动查找最近一张图片并关联。
⚠️ 绝对禁止规则:
- 如果用户发送图片并明确说"这是XX的图片"或"作为附件存到XX",你**只能返回** purchase_item
**绝对不允许返回** is_new、quantity、unit_price、amount 等任何其他字段。
- 纯图片+指定物品名的请求不是新建采购,系统会自动关联附件到已有记录。
⚠️ 采购规则:
- 只有用户明确表示"购买/采购/下单/付款/买了/花了"等消费意图时才返回 action="purchase"。
- 如果用户只是陈述事实或描述物品(如"前天deep"),没有明确采购意图,必须返回 action="ignore"。
- 如果信息不完整、无法确定具体物品名称,必须返回 action="ask" 追问,绝不允许猜测创建记录。
- 新建 vs 更新(极其重要):
* 如果用户提到"昨天/今天/刚刚/又/再/新/另一批/另外"等时间或重复购买词汇,必须返回 "is_new": true,系统会强制创建新记录。
* "纯图片+物品名"的附件请求**不是**新建,不要返回 is_new。
* 如果用户明确说"改一下/更新/修改/调整/更正"等词汇,才不提供 is_new 或设为 false,系统会尝试匹配已有记录更新。
* 当无法确定是新建还是更新时,默认视为新建(返回 is_new: true)。
- 用户补充信息时更新已有记录。更新时请返回完整字段值,包括未变化的字段。如果用户要求修改时间,请生成正确的created_time字段。
- 总金额(amount)由系统自动按数量×单价+邮费计算,你无需提供该字段。
- 如果无法确定 purchase_item(物品名称),必须 action="ask" 并追问请提供物品名称,绝不允许将 purchase_item 设为空字符串、null 或 undefined。`;
- 总金额(amount)由系统自动按"数量×单价+邮费"计算,你无需提供该字段。
- 如果无法确定 purchase_item(物品名称),必须 action="ask" 并追问"请提供物品名称",绝不允许将 purchase_item 设为空字符串、null 或 undefined。`;
const messages = [
{ role: 'system', content: systemPrompt },
@@ -381,7 +413,13 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
} else if (itemName === '__NULL_NAME__') {
purchases = db.prepare("SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND (item IS NULL OR item = '' OR item = 'null' OR item = 'undefined')").all(roomId);
} else {
purchases = db.prepare('SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?').all(roomId, `%${itemName}%`);
// 动态构建查询:根据 AI 提供的 quantity/amount 精确匹配
let query = 'SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?';
const params = [roomId, `%${itemName}%`];
if (aiResult.quantity !== undefined) { query += ' AND quantity = ?'; params.push(aiResult.quantity); }
if (aiResult.amount !== undefined) { query += ' AND amount = ?'; params.push(aiResult.amount); }
console.log('🔍 删除查询:', query, params);
purchases = db.prepare(query).all(...params);
}
if (purchases.length === 0) {
const label = itemName === '__AMOUNT_ZERO__' ? '金额为0' : (itemName === '__NULL_NAME__' ? '名称为空' : `"${itemName}"`);
@@ -405,11 +443,20 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
return;
}
if (aiResult.action === 'purchase') {
const item = aiResult.purchase_item;
// 兜底拦截:物品名为空/null/undefined/过短(<2字符) → 拒绝创建
if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) {
console.log('🚫 拒绝无效 purchase_item:', JSON.stringify(item));
storeAndBroadcastText(roomId, '小财', '请提供具体的物品名称。');
addToHistory(roomId, 'assistant', '请提供具体的物品名称。');
return;
}
console.log('🛒 purchase 分支, item:', item, 'attachments:', JSON.stringify(attachments));
// 引用最近图片:用户要求把之前的图片关联到某采购
let usedRecentImage = false;
if (aiResult.use_recent_image) {
console.log('🔍 use_recent_image: 查找最近图片消息, roomId=', roomId);
const recentMsg = db.prepare("SELECT attachments FROM messages WHERE room_id = ? AND attachments IS NOT NULL AND attachments != '' AND attachments != '[]' ORDER BY timestamp DESC LIMIT 1").get(roomId);
const recentMsg = db.prepare("SELECT attachments FROM messages WHERE room_id = ? AND attachments IS NOT NULL AND attachments != '' AND attachments != '[]' ORDER BY id DESC LIMIT 1").get(roomId);
console.log('🔍 最近消息:', recentMsg ? recentMsg.attachments : '(无)');
if (recentMsg) {
try {
@@ -418,9 +465,23 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
} catch(e) { console.error('❌ 解析附件失败:', e); }
}
}
const createdTime = aiResult.created_time || now;
let purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? AND status != ?').get(roomId, `%${aiResult.purchase_item}%`, '已完成');
console.log('📦 purchase_item:', aiResult.purchase_item, purchase ? '(已有)' : '(新建)');
const createdTime = normalizeTime(aiResult.created_time);
// 新建 vs 更新:AI 标记 is_new 时强制新建,不尝试匹配已有记录
let isNew = aiResult.is_new === true;
let purchase;
// 兜底纠正:如果 AI 误将"纯附件请求"标记为 is_new,强制转为匹配已有记录
if (isNew && attachments?.length > 0) {
const existing = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? ORDER BY created_at DESC LIMIT 1').get(roomId, `%${aiResult.purchase_item}%`);
if (existing) {
console.log('🛡️ 兜底纠正: is_new 但附件非空且已有同名记录,转为更新模式');
isNew = false;
purchase = existing;
}
}
if (!isNew && !purchase) {
purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? AND status != ?').get(roomId, `%${aiResult.purchase_item}%`, '已完成');
}
console.log('📦 purchase_item:', aiResult.purchase_item, isNew ? '(强制新建)' : purchase ? '(匹配已有)' : '(未匹配-新建)');
let replyText = '';
const quantity = aiResult.quantity || 1;
const unitPrice = aiResult.unit_price || 0;
@@ -483,11 +544,12 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
console.log('📎 插入附件:', attachments.length, '个, purchase_id=', purchase.id);
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
attachments.forEach(fp => { console.log(' 📎', fp); insertAttach.run(purchase.id, fp, username, now); });
if (usedRecentImage) {
replyText = `📎 已为「${aiResult.purchase_item}」关联 ${attachments.length} 个附件`;
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `添加附件:${attachments.length}`, username, now);
console.log('✅ usedRecentImage 完成, replyText:', replyText);
// 如果字段没有变化,覆盖回复消息
if (replyText.includes('没有发生') || usedRecentImage) {
replyText = `📎 已为「${aiResult.purchase_item}」添加 ${attachments.length} 个附件`;
}
console.log('✅ 附件完成, replyText:', replyText);
} else if (usedRecentImage) {
replyText = `❌ 未找到最近的图片消息,请先发送图片再试。`;
console.log('⚠️ usedRecentImage 但附件为空');