Compare commits

..
23 Commits
Author SHA1 Message Date
kicer 738ef1697c v3.0.1, 改成tools调用方式 2026-08-29 23:36:59 +08:00
kicer 45cf00bf14 update domain, 后续需实现function版本替代system prompt 2026-08-29 10:23:46 +08:00
kicer b41779c051 v2.8.5, 加入查看详情功能 2026-07-29 12:57:27 +08:00
kicer e08a17ffa0 fix prompt 2026-07-29 11:36:44 +08:00
kicer cd41676ee3 优化prmpt 2026-07-29 11:15:27 +08:00
kicer 905973d739 fix prompt 2026-07-29 10:58:29 +08:00
kicer 7cf008eac5 fix issues 2026-07-29 10:47:49 +08:00
kicer f3faa36e99 fix issues 2026-07-29 10:38:19 +08:00
kicer abf84e1081 fix issues 2026-07-29 10:24:28 +08:00
kicer 6a768ef146 优化缓存命中 2026-07-29 10:23:01 +08:00
kicer de2de85e58 优化缓存命中 2026-07-29 10:10:31 +08:00
kicer 886df10775 优化缓存命中 2026-07-29 09:48:44 +08:00
kicer 7247b1d832 支持批量处理 2026-07-29 08:20:38 +08:00
kicer b6f2c8dd42 fix issues 2026-07-29 08:18:11 +08:00
kicer 074e907cae 修改缓存命中逻辑 2026-07-29 08:16:57 +08:00
kicer 0e17abd653 对话时候严格要求json输出 2026-07-29 08:12:37 +08:00
kicer c90718c6de 关闭思考,温度0.1 2026-07-29 08:10:09 +08:00
kicer 467d879dc3 ds api加上错误时的回复 2026-07-29 08:03:15 +08:00
kicer 416b173183 ds api请求时加上user_id 2026-07-29 08:00:51 +08:00
kicer e08c0cdff0 修改prompt 2026-07-29 07:46:57 +08:00
kicer 1f6bc4789b v2.8.1, 基本版本功能完成 2026-07-29 07:27:20 +08:00
kicer 300930cebd 附件图片单击后打开 2026-07-29 07:11:31 +08:00
kicer 56cc1f6e2e fix css 2026-07-28 20:44:04 +08:00
11 changed files with 313 additions and 169 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- ADMINS=${ADMINS}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
- PORT=3000
- VIRTUAL_HOST=ai.foresh.com
- VIRTUAL_HOST=xiaocai.ai.foresh.com
- VIRTUAL_PORT=3000
- CERT_NAME=default
networks:
+51 -12
View File
@@ -1,19 +1,58 @@
// DeepSeek API 客户端
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '';
async function callDeepSeek(messages, temperature = 0.1) {
console.log('🤖 调用 DeepSeek...');
const res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` },
body: JSON.stringify({ model: 'deepseek-v4-flash', messages, temperature, stream: false })
});
const data = await res.json();
if (!data.choices || !data.choices[0]) {
console.error('🤖 DeepSeek 返回异常:', JSON.stringify(data));
throw new Error('DeepSeek API 返回异常: ' + (data.error?.message || JSON.stringify(data)));
function sanitizeUserId(username) {
return username.replace(/[^a-zA-Z0-9\-_]/g, '_').substring(0, 64) || 'anon';
}
function getErrorMsg(status, data) {
var msg = data && data.error && data.error.message ? data.error.message : '';
switch (status) {
case 400: return 'AI 请求格式错误,请联系管理员(400)' + (msg ? '' + msg : '');
case 401: return 'AI 认证失败,请检查 API Key(401)' + (msg ? '' + msg : '');
case 402: return 'AI 账户余额不足,请联系管理员充值(402)';
case 422: return 'AI 参数错误(422)' + (msg ? '' + msg : '');
case 429: return 'AI 请求太频繁,请稍后重试(429)';
case 500: return 'AI 服务器故障,请稍后重试(500)';
case 503: return 'AI 服务器繁忙,请稍后重试(503)';
default: return 'AI 响应异常(' + status + ')' + (msg ? '' + msg : '');
}
return data.choices[0].message.content;
}
async function callDeepSeek(messages, userId, temperature = 0.1, jsonMode = true, tools) {
console.log('调用 DeepSeek...');
var body = { model: 'deepseek-v4-flash', messages: messages, temperature: temperature, stream: false, thinking: { type: 'disabled' } };
if (tools && tools.length) body.tools = tools;
if (jsonMode && !(tools && tools.length)) body.response_format = { type: 'json_object' };
if (userId) body.user_id = sanitizeUserId(userId);
var res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + DEEPSEEK_API_KEY },
body: JSON.stringify(body)
});
var data = await res.json();
if (!res.ok) {
var errMsg = getErrorMsg(res.status, data);
console.error('DeepSeek API 错误:', errMsg);
throw new Error(errMsg);
}
if (!data.choices || !data.choices[0]) {
console.error('DeepSeek 返回异常:', JSON.stringify(data));
throw new Error('AI 返回数据异常');
}
// 缓存命中日志
if (data.usage) {
var hit = data.usage.prompt_cache_hit_tokens || 0;
var miss = data.usage.prompt_cache_miss_tokens || 0;
var total = data.usage.prompt_tokens || 0;
var rate = total > 0 ? (hit / total * 100).toFixed(1) : 0;
console.log('缓存: hit=' + hit + ' miss=' + miss + ' total=' + total + ' 命中率=' + rate + '%');
}
// 输入消息大小日志
//var inputChars = JSON.stringify(messages).length;
//console.log('输入大小: ' + inputChars + ' 字符, ' + messages.length + ' 条消息');
// 返回整个 message 对象,可能包含 content 或 tool_calls(供上层决定走工具还是 JSON)
return data.choices[0].message;
}
module.exports = { callDeepSeek };
+33 -67
View File
@@ -1,74 +1,40 @@
// AI Prompt 模板 — 模块化拼接
// AI Prompt 模板 — 工具模式(阶段一)
// 新建采购 / 查询 / 聊天 / 忽略 走 Function Tool 调用;
// 删除 / 清空 保留 JSON 输出路径(旧逻辑兜底,见 messages.js)。
// 图片关联由后端决定,不在 prompt 中引导模型输出 use_recent_image。
function buildSystemPrompt(username, isAdmin) {
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
const role = '你是智能财务助手"小财"。当前用户:' + username + ',管理员状态:' + isAdmin + 'true=管理员,false=普通用户)。';
const role = '你是智能财务助手"小财"。当前服务器时间:' + now + '。结合对话历史理解用户意图,只返回JSON。';
const intent = [
'意图分类',
'- 采购/付款/发票相关(新建)action="purchase",提取字段:purchase_item, quantity(默认1), unit_price(默认0), freight(默认0), payment_method(淘宝默认支付宝), invoice_type(默认无票), status, applicant, remarks, created_time',
'- 查询汇总:action="query"',
'- 聊天:action="chat"reply简短回复',
'- 删除指定物品:action="delete"delete_item为物品名',
'- 清空所有采购数据:action="clear_all"',
'- 忽略(仅当完全无关):action="ignore"'
const guidance = [
'请根据用户消息选择合适的方式回应:',
'',
'一、调用工具(适用于以下场景)',
'1. 新建采购记录 → 调用工具 create_purchase。',
' 触发条件:用户明确表达了购买/采购/下单/付款/买了/花了等消费意图,且不是在查询历史。',
' 物品名、数量、单价、运费、支付方式、发票类型、状态、备注等字段含义见工具参数定义。',
' 关键规则1:如果用户没有明确提供单价(价格),不要调用 create_purchase,而应调用 reply_chat 追问价格。',
' 关键规则2:用户如提及"交了""付了""已付款""已收货""已完成"等,必须把 status 填为对应状态,不要漏填。',
' 关键规则3:一次消息中包含多笔采购时,必须为每一笔分别调用一次 create_purchase,不要合并或遗漏。',
'2. 查询历史采购记录 → 调用工具 query_purchases,参数 keywords 为物品名或关键词(查全部则用空字符串)。',
' 触发条件:用户询问"买过什么/有没有买过/查一下/找找看/搜索/列表/我买过/看看"等查询意图。',
' 即使物品名听起来奇怪或可能不存在,也必须用 query_purchases,绝不能调 create_purchase。',
'3. 纯闲聊、引导手动修改、追问价格、权限不足拒绝 → 调用工具 reply_chat,参数 reply 为要显示给用户的回复。',
'4. 完全无关的消息(纯符号、纯表情、明显不是对助手说的话)→ 调用工具 ignore。',
'',
'二、直接输出 JSON(不要调用任何工具):',
'5. 删除采购记录 → 输出 {"action":"delete","delete_item":"物品名","quantity":数量,"amount":金额},其中 quantity/amount 可选。',
'6. 清空所有采购数据 → 输出 {"action":"clear_all"}。仅管理员(isAdmin=true)可执行;',
' 非管理员必须输出 {"action":"chat","reply":"仅管理员可执行此操作"}。',
'',
'三、通用规则:',
'- 用户要求修改、更正、调整、更新已有采购记录的任何意图(改数量、改价格、改状态、补发票、加备注等),一律用 reply_chat 引导用户手动操作,不要尝试更新记录。',
'- 修改意图的统一回复模板:"如需修改记录,请前往采购清单页面,找到对应条目后手动编辑。"',
'- 如果用户同时表达了购买和查询,优先判断为购买(create_purchase),除非提问明显是查询历史。',
'- 若无法确定用户意图,默认调用 reply_chat 并给出友好回复,绝不忽略。'
].join('\n');
const permission = [
'⚠️ 权限规则:',
'- 当前用户' + username + ',管理员状态:' + isAdmin + '。仅管理员可执行删除/清空操作。',
'- 如果用户要求删除或清空,且 isAdmin 为 false,你必须返回 action="chat" 并说明"仅管理员可操作"。',
'- 如果 isAdmin 为 true,正常返回 delete 或 clear_all。'
].join('\n');
const deleteRules = [
'⚠️ 删除规则:',
'- 特殊批量删除:delete_item="__AMOUNT_ZERO__"(金额为0)、"__NULL_NAME__"(空名称)',
'- 精确删除:用户指定数量/金额时同时返回 quantity/amount 字段,只匹配一条',
'- 不要返回 action="ask" 处理删除请求'
].join('\n');
const attachmentRules = [
'⚠️ 附件规则:',
'- 图片+物品名("这是XX的图片")→ 只返回 purchase_item,绝对不要 is_new/quantity/unit_price',
'- 引用历史图片("上面/前面/刚刚那张""存入附件/添加到附件/加到XX的附件/关联到")→ use_recent_image: true**同时必须提供** purchase_item',
'- 纯图片+物品名不是新建采购,系统自动关联附件'
].join('\n');
const prohibitionRules = [
'⚠️ 绝对禁止:',
'- 图片+物品名请求 → 只能返回 purchase_item,禁止返回 is_new、quantity、unit_price、amount'
].join('\n');
const modifyGuide = [
'⚠️ 修改引导规则(极其重要!):',
'- 聊天窗口仅用于**新建**采购记录和查询汇总。',
'- 如果用户要求**修改/更正/调整/改一下/更新/纠正**已有采购记录 → 你必须返回 action="chat"reply引导用户去采购清单页面手动修改。',
' 示例回复:"请在采购清单中找到对应记录,点击进入详情页面直接修改。修改后点击保存即可。"',
'- 如果用户要求修改,你绝不要尝试去匹配或更新已有记录,只能引导用户手动操作。'
].join('\n');
const purchaseRules = [
'⚠️ 采购规则:',
'- 聊天默认为**新建**采购记录。只要用户提到购买/采购/下单/付款/买了/花了,就创建新记录。',
'- 采购状态(status)只能从以下 4 个值中选择,绝不允许自创:',
' * "待付款" — 采购已记录,等待付款',
' * "已付款" — 已完成支付,等待收货',
' * "已收货" — 已收到货物',
' * "已完成" — 发票已收,全部结束',
'- 状态映射:',
' * "付了/已付/付款了" → "已付款"',
' * "到了/收到货了" → "已收货"',
' * "发票到/全搞定/完结" → "已完成"',
' * 新建不指定 → "待付款"',
'- 只有明确消费意图(购买/采购/下单/付款/买了/花了)才返回 action="purchase"',
'- 纯陈述 → action="ignore"',
'- 信息不完整 → action="ask" 追问',
'- amount 由系统自动计算(quantity × unit_price + freight),AI 无需提供',
'- 无法确定物品名 → action="ask" 追问'
].join('\n');
return [role, intent, permission, deleteRules, attachmentRules, prohibitionRules, modifyGuide, purchaseRules].join('\n');
return [role, guidance].join('\n\n');
}
module.exports = { buildSystemPrompt };
+85
View File
@@ -0,0 +1,85 @@
// DeepSeek Function Tools 定义
// 阶段一:迁移 purchase / chat / ignore 为工具调用;query 也提供工具入口(避免查询意图误判成新建),
// 但其汇总逻辑仍复用 handlers.js 的 query 分支(不做二次调用回填)。
// delete / clear_all 保留旧 JSON 输出路径(在 prompt 中引导走 JSON,见 prompt.js)。
// 图片关联不在工具参数中暴露,由后端根据消息文本/附件自行决定(见 handlers.js 的 create_purchase 分支)。
const STATUS_ENUM = ['待付款', '已付款', '已收货', '已完成'];
const INVOICE_ENUM = ['无票', '电子票'];
const createPurchaseTool = {
type: 'function',
function: {
name: 'create_purchase',
description:
'新建一条采购记录。仅在用户明确表达购买/采购/下单/付款/买了/花了等消费意图且不是查询历史时调用。' +
'注意:如果用户没有明确提供单价(价格),不要调用本工具,应改为调用 reply_chat 追问价格。',
parameters: {
type: 'object',
properties: {
purchase_item: { type: 'string', description: '物品名称,必填' },
quantity: { type: 'number', description: '数量,默认 1' },
unit_price: { type: 'number', description: '单价。必须大于 0,若用户未明确给出价格请不要调用本工具,改为 reply_chat 追问。' },
freight: { type: 'number', description: '运费,默认 0' },
payment_method: { type: 'string', description: '支付方式,如 支付宝/微信,未提及则默认未指定' },
invoice_type: { type: 'string', enum: INVOICE_ENUM, description: '发票类型,默认无票' },
status: { type: 'string', enum: STATUS_ENUM, description: '付款/收货状态。若用户明确说了已付款、交了钱、付了款、已收、已到货、已完成等,必须主动填对应值(如已付款),不要省略。仅当完全没提状态时才用默认待付款。' },
remarks: { type: 'string', description: '备注,如购买平台等' }
},
required: ['purchase_item']
}
}
};
const replyChatTool = {
type: 'function',
function: {
name: 'reply_chat',
description:
'直接回复一段文本给用户。用于以下情况:' +
'1) 纯闲聊(如打招呼、问天气等);' +
'2) 用户要求修改/更正/调整/更新已有采购记录时,引导其去采购清单页面手动编辑;' +
'3) 用户有购买意图但缺少价格信息时,追问价格;' +
'4) 权限不足需要拒绝(如非管理员要求清空记录);' +
'5) 其他需要回复但不属于新建采购或查询/删除/清空的情况。',
parameters: {
type: 'object',
properties: {
reply: { type: 'string', description: '显示给用户的回复内容' }
},
required: ['reply']
}
}
};
const queryPurchasesTool = {
type: 'function',
function: {
name: 'query_purchases',
description:
'查询历史采购记录。当用户询问"买过什么/有没有买过/查一下/找找看/搜索/列表/我买过/看看"等查询历史采购的意图时调用。' +
'即使物品名听起来奇怪或可能不存在,也必须调用本工具,绝不能调用 create_purchase 或 ignore。' +
'调用后系统会搜索本地数据库并生成回答,本工具不直接返回查询结果。',
parameters: {
type: 'object',
properties: {
keywords: { type: 'string', description: '用户想查找的物品名或关键词;若查询全部记录则设为空字符串 ""' }
},
required: ['keywords']
}
}
};
const ignoreTool = {
type: 'function',
function: {
name: 'ignore',
description:
'忽略当前消息,不产生任何回复。用于纯符号、纯表情、明显不是对助手说的话等无关消息。',
parameters: { type: 'object', properties: {} }
}
};
const TOOLS = [createPurchaseTool, replyChatTool, queryPurchasesTool, ignoreTool];
module.exports = { TOOLS };
+46 -50
View File
@@ -7,7 +7,7 @@ const { broadcastToRoom } = require('./ws');
// 对话历史(内存)
const conversationHistory = new Map();
function getHistory(roomId) { if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []); return conversationHistory.get(roomId); }
function addToHistory(roomId, role, content) { const h = getHistory(roomId); h.push({ role, content }); if (h.length > 60) conversationHistory.set(roomId, h.slice(-40)); }
function addToHistory(roomId, role, content) { var h = getHistory(roomId); h.push({ role: role, content: content }); if (h.length > 16) conversationHistory.set(roomId, h.slice(-8)); }
// 待处理操作
const pendingActions = new Map();
@@ -36,26 +36,26 @@ function executePendingAction(roomId, username) {
const delH = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
const delP = db.prepare('DELETE FROM purchases WHERE id = ?');
for (const id of purchaseIds) { delA.run(id); delH.run(id); delP.run(id); }
const reply = '\u2705 \u5df2\u5220\u9664\u91c7\u8d2d\u8bb0\u5f55\uff1a' + itemName + '\uff08\u5171 ' + purchaseIds.length + ' \u6761\uff09';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', reply); addToHistory(roomId, 'assistant', reply);
const reply = '✅ 已删除采购记录:' + itemName + '(共 ' + purchaseIds.length + ' 条)';
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
} else if (action.type === 'clear') {
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
const reply = '\u2705 \u5df2\u6e05\u7a7a\u5f53\u524d\u623f\u95f4\u7684\u6240\u6709\u91c7\u8d2d\u6570\u636e\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', reply); addToHistory(roomId, 'assistant', reply);
const reply = '✅ 已清空当前房间的所有采购数据。';
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
}
broadcastToRoom(roomId, { type: 'purchase_updated' });
return true;
} catch (e) { storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u274c \u64cd\u4f5c\u6267\u884c\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002'); return false; }
} catch (e) { storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; }
}
function handleAIResult(aiResult, roomId, username, originalText, attachments) {
const now = timestamp();
if (aiResult.action === 'ask') {
const q = aiResult.question || aiResult.reply || '\u8bf7\u63d0\u4f9b\u66f4\u591a\u4fe1\u606f\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', q); addToHistory(roomId, 'assistant', q); return;
const q = aiResult.question || aiResult.reply || '请提供更多信息。';
storeAndBroadcastText(roomId, '小财', q); addToHistory(roomId, 'assistant', q); return;
}
if (aiResult.action === 'delete') {
@@ -73,14 +73,14 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
purchases = db.prepare(query).all(...params);
}
if (purchases.length === 0) {
const label = itemName === '__AMOUNT_ZERO__' ? '\u91d1\u989d\u4e3a0' : (itemName === '__NULL_NAME__' ? '\u540d\u79f0\u4e3a\u7a7a' : '"' + itemName + '"');
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u6ca1\u6709\u627e\u5230\u4e0e"' + label + '"\u76f8\u5173\u7684\u91c7\u8d2d\u8bb0\u5f55\u3002');
const label = itemName === '__AMOUNT_ZERO__' ? '金额为0' : (itemName === '__NULL_NAME__' ? '名称为空' : '"' + itemName + '"');
storeAndBroadcastText(roomId, '小财', '没有找到与"' + label + '"相关的采购记录。');
return;
}
let confirmText = '\u26a0\ufe0f \u5373\u5c06\u5220\u9664\u4ee5\u4e0b ' + purchases.length + ' \u6761\u91c7\u8d2d\u8bb0\u5f55\uff0c\u8bf7\u56de\u590d"\u786e\u8ba4"\u7ee7\u7eed\uff1a\n\n';
purchases.forEach(p => confirmText += '\u2022 ' + p.item + ' | ' + p.amount + ' | ' + p.status + ' | ' + p.applicant + ' | ' + p.created_at + '\n');
confirmText += '\n\u5982\u679c\u4e0d\u5220\u9664\uff0c\u8bf7\u5ffd\u7565\u6b64\u6d88\u606f\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', confirmText);
let confirmText = '⚠️ 即将删除以下 ' + purchases.length + ' 条采购记录,请回复"确认"继续:\n\n';
purchases.forEach(p => confirmText += ' ' + p.item + ' | ¥' + p.amount + ' | ' + p.status + ' | ' + p.applicant + ' | ' + p.created_at + '\n');
confirmText += '\n如果不删除,请忽略此消息。';
storeAndBroadcastText(roomId, '小财', confirmText);
addToHistory(roomId, 'assistant', confirmText);
setPendingAction(roomId, username, { type: 'delete', data: { itemName, purchaseIds: purchases.map(p => p.id) } });
return;
@@ -88,34 +88,25 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
if (aiResult.action === 'clear_all') {
const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count;
if (count === 0) { storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u5f53\u524d\u623f\u95f4\u6ca1\u6709\u91c7\u8d2d\u8bb0\u5f55\uff0c\u65e0\u9700\u6e05\u7a7a\u3002'); return; }
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u26a0\ufe0f \u5373\u5c06\u6e05\u7a7a\u5f53\u524d\u623f\u95f4\u7684 ' + count + ' \u6761\u91c7\u8d2d\u6570\u636e\uff0c\u8bf7\u56de\u590d"\u786e\u8ba4"\u7ee7\u7eed\uff0c\u5426\u5219\u5ffd\u7565\u3002');
addToHistory(roomId, 'assistant', '\u8bf7\u6c42\u6e05\u7a7a' + count + '\u6761\u8bb0\u5f55');
if (count === 0) { storeAndBroadcastText(roomId, '小财', '当前房间没有采购记录,无需清空。'); return; }
storeAndBroadcastText(roomId, '小财', '⚠️ 即将清空当前房间的 ' + count + ' 条采购数据,请回复"确认"继续,否则忽略。');
addToHistory(roomId, 'assistant', '请求清空' + count + '条记录');
setPendingAction(roomId, username, { type: 'clear', data: {} });
return;
}
if (aiResult.action === 'purchase') {
// use_recent_image 但缺 purchase_item
if (aiResult.use_recent_image && (!aiResult.purchase_item || aiResult.purchase_item === 'null')) {
const lastPurchase = db.prepare("SELECT item FROM purchases WHERE room_id = ? AND item IS NOT NULL AND item != '' ORDER BY created_at DESC LIMIT 1").get(roomId);
if (lastPurchase) { aiResult.purchase_item = lastPurchase.item; }
}
const item = aiResult.purchase_item;
if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) {
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u8bf7\u63d0\u4f9b\u5177\u4f53\u7684\u7269\u54c1\u540d\u79f0\u3002');
addToHistory(roomId, 'assistant', '\u8bf7\u63d0\u4f9b\u5177\u4f53\u7684\u7269\u54c1\u540d\u79f0\u3002');
storeAndBroadcastText(roomId, '小财', '请提供具体的物品名称。');
addToHistory(roomId, 'assistant', '请提供具体的物品名称。');
return;
}
let usedRecentImage = false;
if (aiResult.use_recent_image) {
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);
if (recentMsg) {
try { const files = JSON.parse(recentMsg.attachments); if (files.length) { attachments = files; usedRecentImage = true; } } catch(e) {}
}
}
if (!usedRecentImage && !attachments?.length && /(\u56fe\u7247|\u9644\u4ef6|\u52a0\u5230|\u5b58\u5165|\u5173\u8054|\u4f5c\u4e3a\u9644\u4ef6)/.test(originalText)) {
// 图片关联:由后端根据消息文本判断,不再依赖模型返回 use_recent_image。
// 用户消息提及图片/附件等词且本次未带附件时,自动关联最近的图片消息。
if (!attachments?.length && /(图片|附件|加到|存入|关联|作为附件)/.test(originalText)) {
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);
if (recentMsg) {
try { const files = JSON.parse(recentMsg.attachments); if (files.length) { attachments = files; usedRecentImage = true; } } catch(e) {}
@@ -123,7 +114,6 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
}
const createdTime = normalizeTime(aiResult.created_time);
// AI 不负责更新 — 聊天永远新建,修改由用户从采购清单手动完成
let purchase = null;
let replyText = '';
@@ -132,15 +122,15 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
const freight = aiResult.freight || 0;
const amount = quantity * unitPrice + freight;
if (!purchase && amount === 0 && aiResult.quantity === undefined && aiResult.unit_price === undefined) {
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u65e0\u6cd5\u521b\u5efa\u300c' + aiResult.purchase_item + '\u300d\uff1a\u7f3a\u5c11\u6570\u91cf\u548c\u4ef7\u683c\u4fe1\u606f\uff0c\u8bf7\u8865\u5145\u3002');
addToHistory(roomId, 'assistant', '\u7f3a\u5c11\u6570\u91cf\u548c\u4ef7\u683c');
// 金额为 0 → AI 判断错误,拒绝创建
if (!purchase && amount === 0) {
storeAndBroadcastText(roomId, '小财', '无法创建「' + aiResult.purchase_item + '」:金额为 0,请补充价格信息。');
addToHistory(roomId, 'assistant', '金额为0,拒绝');
return;
}
// 永远新建
const id = uuidv4();
const paymentMethod = aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || '未指定'));
const paymentMethod = aiResult.payment_method || '未指定';
const invoiceType = aiResult.invoice_type || '无票';
const status = aiResult.status || '待付款';
const applicant = aiResult.applicant || username;
@@ -148,46 +138,52 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
db.prepare('INSERT INTO purchases (id, room_id, item, quantity, unit_price, amount, freight, payment_method, invoice_type, status, applicant, remarks, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)').run(
id, roomId, item, quantity, unitPrice, amount, freight, paymentMethod, invoiceType, status, applicant, remarks, createdTime
);
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, '创建采购:' + item + ',数量' + quantity + ',金额' + amount + ',状态' + status, '小财', now);
replyText = '✅ 已记录采购:' + item + ',数量 ' + quantity + ',金额 ' + amount + ',状态 ' + status;
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, '创建采购:' + item + ',数量' + quantity + ',金额¥' + amount + ',状态' + status, '小财', now);
replyText = '✅ 已记录采购:' + item + ',数量 ' + quantity + ',金额 ¥' + amount + ',状态 ' + status + ' <a href="#" onclick="openDetail(\'' + id + '\')">查看详情</a>';
purchase = { id };
if (attachments?.length) {
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
attachments.forEach(fp => insertAttach.run(purchase.id, fp, username, now));
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, '\u6dfb\u52a0\u9644\u4ef6\uff1a' + attachments.length + ' \u4e2a', username, now);
if (replyText.includes('\u6ca1\u6709\u53d1\u751f') || usedRecentImage) replyText = '\ud83d\udcce \u5df2\u4e3a\u300c' + aiResult.purchase_item + '\u300d\u6dfb\u52a0 ' + attachments.length + ' \u4e2a\u9644\u4ef6';
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, '添加附件:' + attachments.length + ' ', username, now);
if (usedRecentImage) replyText = '📎 已为「' + aiResult.purchase_item + '」添加 ' + attachments.length + ' 个附件';
} else if (usedRecentImage) {
replyText = '\u274c \u672a\u627e\u5230\u6700\u8fd1\u7684\u56fe\u7247\u6d88\u606f\uff0c\u8bf7\u5148\u53d1\u9001\u56fe\u7247\u518d\u8bd5\u3002';
replyText = '❌ 未找到最近的图片消息,请先发送图片再试。';
}
storeAndBroadcastText(roomId, '\u5c0f\u8d22', replyText);
storeAndBroadcastText(roomId, '小财', replyText);
addToHistory(roomId, 'assistant', replyText);
broadcastToRoom(roomId, { type: 'purchase_updated' });
return;
}
if (aiResult.action === 'query') {
const purchaseData = db.prepare('SELECT item, amount, payment_method, status FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId).map(p => p.item + ' ' + p.amount + ' ' + (p.payment_method||'') + ' ' + p.status).join('\n');
const summaryPrompt = '\u6839\u636e\u4ee5\u4e0b\u91c7\u8d2d\u8bb0\u5f55\uff0c\u7528\u81ea\u7136\u8bed\u8a00\u56de\u7b54\u7528\u6237\u67e5\u8be2"' + originalText + '"' + '\u3002\u91c7\u8d2d\u8bb0\u5f55\uff1a\n' + (purchaseData || '\u6682\u65e0\u8bb0\u5f55');
callDeepSeekForSummary(summaryPrompt).then(reply => { storeAndBroadcastText(roomId, '\u5c0f\u8d22', reply); addToHistory(roomId, 'assistant', reply); });
broadcastToRoom(roomId, { type: 'query_pending', text: '🔍 正在查询采购数据,请稍候...' });
const purchaseData = db.prepare('SELECT item, amount, payment_method, status, created_at FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId).map(p => p.created_at + ' ' + p.item + ' ¥' + p.amount + ' ' + (p.payment_method||'') + ' ' + p.status).join('\n');
const summaryPrompt = '根据以下采购记录,用自然语言回答用户查询"' + originalText + '"。采购记录:\n' + (purchaseData || '暂无记录');
callDeepSeekForSummary(summaryPrompt, username).then(function(reply) {
console.log('Query汇总回复:', reply.substring(0, 200));
storeAndBroadcastText(roomId, '小财', reply);
addToHistory(roomId, 'assistant', reply);
});
return;
}
if (aiResult.action === 'chat') {
storeAndBroadcastText(roomId, '\u5c0f\u8d22', aiResult.reply || '\u597d\u7684\u3002');
storeAndBroadcastText(roomId, '小财', aiResult.reply || '好的。');
addToHistory(roomId, 'assistant', aiResult.reply);
return;
}
}
async function callDeepSeekForSummary(prompt) {
async function callDeepSeekForSummary(prompt, userId) {
const { callDeepSeek } = require('./ai/client');
const messages = [
{ role: 'system', content: '\u4f60\u662f\u4e00\u4e2a\u8d22\u52a1\u52a9\u624b\uff0c\u8bf7\u6839\u636e\u91c7\u8d2d\u8bb0\u5f55\u751f\u6210\u7b80\u6d01\u56de\u590d\u3002' },
{ role: 'system', content: '你是一个财务助手,请根据采购记录生成简洁回复。' },
{ role: 'user', content: prompt }
];
return callDeepSeek(messages, 0.3);
const msg = await callDeepSeek(messages, userId, 0.3, false);
return msg && typeof msg === 'object' ? (msg.content || '') : (msg || '');
}
module.exports = {
+6 -11
View File
@@ -1,20 +1,18 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; height: 100vh; display: flex; justify-content: center; align-items: center; }
.app { width: 100%; max-width: 420px; height: 100vh; background: #fff; display: flex; flex-direction: column; box-shadow: 0 0 20px rgba(0,0,0,0.1); position: relative; }
.app { width: 100%; height: 100vh; background: #fff; display: flex; flex-direction: column; box-shadow: 0 0 20px rgba(0,0,0,0.1); position: relative; }
.hidden { display: none !important; }
.header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
.header { background: #2a5298; color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
.header-left { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.header-left h2 { font-size: 18px; font-weight: 500; }
.icon-btn { background: none; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; border-radius: 50%; }
.icon-btn svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.chat-item:nth-child(even) {background-color: #e8f0fe;}
.chat-list { flex: 1; overflow-y: auto; padding-bottom: 8px; }
.chat-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; cursor: pointer; }
.chat-item:active { background: #f9f9f9; }
.avatar { width: 44px; height: 44px; border-radius: 50%; background: #e0e0e0; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #555; flex-shrink: 0; }
.avatar { width: 44px; height: 44px; border-radius: 50%; background: #2a5298; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #fff; flex-shrink: 0; }
.chat-info { flex: 1; min-width: 0; }
.chat-name { font-size: 16px; font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.last-msg { font-size: 14px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -23,13 +21,11 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
.room-actions { display: flex; gap: 4px; margin-top: 4px; }
.edit-room-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; padding: 2px; }
.edit-room-btn svg { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; }
.summary-card { background: linear-gradient(135deg, #e8f0fe 0%, #d4e4fc 100%); margin: 8px; border-radius: 12px; padding: 14px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border: 1px solid #b8d4f8; }
.summary-card h4 { font-size: 16px; font-weight: 500; }
.summary-card .summary-preview { font-size: 14px; color: #555; }
.chat-window { display: flex; flex-direction: column; height: 100%; }
.chat-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; align-items: center; min-height: 48px; }
.chat-header { background: #2a5298; color: #fff; padding: 0 16px; display: flex; align-items: center; min-height: 48px; }
.header .back-btn { margin-right: 12px; }
.chat-header .back-btn { margin-right: 12px; }
.chat-title { font-size: 17px; font-weight: 500; flex: 1; }
@@ -48,7 +44,6 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
.msg-bubble th { background: #f0f0f0; }
.msg-bubble pre { background: #f0f0f0; padding: 8px; border-radius: 4px; overflow-x: auto; }
.msg-bubble code { background: #f0f0f0; padding: 2px 4px; border-radius: 3px; font-size: 14px; }
.input-area { padding: 8px 12px; border-top: 1px solid #eee; display: flex; align-items: center; background: #fff; gap: 8px; }
.input-area textarea { flex: 1; border: 1px solid #ddd; border-radius: 12px; padding: 10px 12px; font-size: 16px; outline: none; resize: none; min-height: 40px; max-height: 120px; overflow-y: hidden; line-height: 1.4; }
.file-upload-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; }
@@ -120,7 +115,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
/* 可编辑详情弹窗 */
.detail-modal { max-height: 90vh; overflow-y: auto; padding: 0; }
.detail-modal input, .detail-modal select, .detail-modal textarea { margin: 0; }
.detail-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 8px 12px 8px 16px; display: flex; align-items: center; gap: 8px; position: sticky; top: 0; z-index: 1; }
.detail-header { background: #2a5298; color: #fff; padding: 8px 12px 8px 16px; display: flex; align-items: center; gap: 8px; position: sticky; top: 0; z-index: 1; }
.detail-header .icon-btn { padding:0; margin-right:0; flex-shrink: 0; margin-left: auto; }
.detail-header .icon-btn svg { stroke: #fff; }
.modal .detail-title-input { flex: 1; font-size: 18px; font-weight: 600; color: #fff; background: transparent; border: none; outline: none; padding: 4px 0; min-width: 0; }
+1 -1
View File
@@ -237,7 +237,7 @@
<script>
// 版本号
(function() {
document.getElementById('version-text').textContent = 'v2.8-260728';
document.getElementById('version-text').textContent = 'v3.0.1-260829';
})();
document.addEventListener('input', function(e) {
if (e.target.id === 'msg-input' || e.target.id === 'purchase-msg-input') {
+15 -6
View File
@@ -88,15 +88,15 @@ function replaceMessage(msgId, newMsg) {
function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }
// 图片/气泡
// 图片单击 / 气泡
function bindMessageEvents() {
document.querySelectorAll('.msg-img').forEach(img => {
document.querySelectorAll('.msg-img').forEach(function(img) {
img.removeEventListener('click', onImageClick);
img.addEventListener('click', onImageClick);
});
document.querySelectorAll('.msg-bubble').forEach(bubble => {
bubble.removeEventListener('click', onBubbleClick);
bubble.addEventListener('click', onBubbleClick);
document.querySelectorAll('.msg-bubble').forEach(function(bubble) {
bubble.removeEventListener('dblclick', onBubbleClick);
bubble.addEventListener('dblclick', onBubbleClick);
});
}
@@ -110,7 +110,7 @@ function onImageClick(e) {
initPinchZoom(overlay.querySelector('img'));
}
function onImgThumbDblClick(img) {
function onImgThumbClick(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;transition: transform 0.1s; transform-origin: 0 0;" alt="">';
@@ -170,6 +170,15 @@ function clearTempBubble() {
tempEls.forEach(function(el) { el.remove(); });
}
function showTempBubble(text) {
clearTempBubble();
var tempId = 'temp_' + Date.now();
var tempMsg = { id: tempId, user: '小财', text: text, attachments: [], timestamp: '', isTemp: true };
Store.messageCache.push(tempMsg);
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(tempMsg));
scrollToBottom();
}
// 双指缩放 + 拖拽
function initPinchZoom(img) {
var scale = 1, startDist = 0, startScale = 1;
+1 -1
View File
@@ -93,7 +93,7 @@ function renderEditAttachments(attachments) {
}
container.innerHTML = attachments.map(function(a) {
return '<div class="attach-thumb-wrap">' +
'<img class="attach-thumb" src="' + a.file_path + '" ondblclick="onImgThumbDblClick(this)">' +
'<img class="attach-thumb" src="' + a.file_path + '" onclick="onImgThumbClick(this)">' +
'<button class="attach-del-btn" onclick="deleteAttachment(event, \'' + encodeURIComponent(a.file_path) + '\')">' +
'<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>' +
'</button>' +
+4
View File
@@ -19,6 +19,10 @@ function initWebSocket() {
const room = Store.rooms.find(r => r.id === data.room_preview.room_id);
if (room) { room.last_message = data.room_preview.last_message; room.last_time = data.room_preview.last_time; renderChatList(); }
}
} else if (data.type === 'query_pending') {
showTempBubble(data.text);
} else if (data.type === 'clear_temp') {
clearTempBubble();
} else if (data.type === 'purchase_updated') {
if (Store.currentRoom) loadPurchases();
updateSummaryPreview();
+70 -20
View File
@@ -1,10 +1,11 @@
// 消息路由 + AI 分析
const express = require('express');
const { authMiddleware } = require('../middleware/auth');
const { broadcastToRoomExcludeSelf } = require('../ws');
const { broadcastToRoomExcludeSelf, broadcastToRoom } = require('../ws');
const { buildSystemPrompt } = require('../ai/prompt');
const { validate, normalize } = require('../ai/validator');
const { callDeepSeek } = require('../ai/client');
const { TOOLS } = require('../ai/tools');
const { getHistory, addToHistory, hasPendingAction, handleAIResult, executePendingAction } = require('../handlers');
const db = require('../db');
const { timestamp } = require('../utils');
@@ -45,31 +46,80 @@ router.post('/:roomId/messages', authMiddleware, async (req, res) => {
const history = getHistory(roomId);
const historyMessages = history.map(e => ({ role: e.role, content: e.content }));
const aiResponse = await analyzeWithDeepSeek(text || '', historyMessages, username, req.user.isAdmin, roomId);
if (aiResponse && aiResponse.action !== 'ignore') {
handleAIResult(aiResponse, roomId, username, text || '', attachments || []);
if (aiResponse) {
var responses = Array.isArray(aiResponse) ? aiResponse : [aiResponse];
var allIgnore = responses.every(function(r) { return r.action === 'ignore'; });
responses.forEach(function(r) {
if (r.action !== 'ignore') handleAIResult(r, roomId, username, text || '', attachments || []);
});
if (allIgnore) broadcastToRoom(roomId, { type: 'clear_temp' });
}
} catch (e) { console.error('AI 分析失败:', e); }
} catch (e) {
console.error('AI 分析失败:', e.message);
var errText = '❌ ' + (e.message || 'AI 处理失败,请稍后重试');
var errResult = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, '小财', errText, timestamp());
var errMsg = { id: errResult.lastInsertRowid, room_id: roomId, user: '小财', text: errText, attachments: [], timestamp: timestamp() };
broadcastToRoom(roomId, { type: 'new_message', message: errMsg });
}
});
async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin, roomId) {
// 注入当前房间采购清单上下文
const purchases = db.prepare("SELECT item, quantity, amount, status, created_at FROM purchases WHERE room_id = ? ORDER BY created_at DESC LIMIT 10").all(roomId);
const purchaseContext = purchases.length
? `\n📋 当前房间采购清单(最近10条):\n${purchases.map(p => `- ${p.item} | ×${p.quantity} | ¥${p.amount} | ${p.status} | ${p.created_at}`).join('\n')}`
: '';
const systemPrompt = buildSystemPrompt(username, isAdmin) + purchaseContext;
const messages = [
{ role: 'system', content: systemPrompt },
...historyMessages.slice(-30),
{ role: 'user', content: `${username}: ${text}` }
var systemPrompt = buildSystemPrompt(username, isAdmin);
var messages = [
{ role: 'system', content: systemPrompt }
];
const content = await callDeepSeek(messages, 0.1);
console.log('🤖 AI 返回:', content);
historyMessages.forEach(function(m) { messages.push(m); });
messages.push({ role: 'system', content: '当前服务器时间:' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) });
const message = await callDeepSeek(messages, username, 0.1, true, TOOLS);
// 1) 工具调用路径:模型调用了 create_purchase / query_purchases / reply_chat / ignore
if (message && Array.isArray(message.tool_calls) && message.tool_calls.length) {
message.tool_calls.forEach(function(tc, i) {
const fn = tc.function || {};
console.log('🤖 AI 工具调用[' + i + ']: ' + (fn.name || '?') + ' args=' + (fn.arguments || '').substring(0, 300));
});
const results = [];
message.tool_calls.forEach(function(tc) {
const fn = tc.function || {};
const name = fn.name || '';
let args = {};
try { args = JSON.parse(fn.arguments || '{}'); } catch(e) { console.warn('⚠️ 工具参数解析失败:', name, (fn.arguments || '').substring(0, 100)); args = {}; }
if (name === 'create_purchase') {
// 工具参数已是结构化对象,补上 action 字段以复用现有 handleAIResult 的 purchase 分支
results.push(Object.assign({ action: 'purchase' }, args));
} else if (name === 'query_purchases') {
// 复用现有 query 分支(本地查库 + 二次汇总),不依赖模型返回结果
results.push({ action: 'query', keywords: args.keywords || '' });
} else if (name === 'reply_chat') {
results.push({ action: 'chat', reply: args.reply || '好的。' });
} else {
// ignore 或未知工具 → 忽略
console.warn('⚠️ 未识别的工具名:', name);
results.push({ action: 'ignore' });
}
});
return results;
}
// 2) 纯文本/JSON 路径:query / delete / clear_all 等按旧逻辑解析
const content = (message && typeof message === 'object') ? (message.content || '') : (message || '');
console.log('🤖 AI 返回:', content.substring(0, 200));
try {
const parsed = JSON.parse(content.replace(/```json|```/g, '').trim());
normalize(parsed);
const v = validate(parsed);
if (!v.valid) console.warn('⚠️ AI 返回校验失败:', v.error);
var cleaned = content.replace(/```json|```/g, '').trim();
if (!cleaned) { console.warn('⚠️ AI 返回空内容'); return { action: 'ignore' }; }
var parsed = JSON.parse(cleaned);
// 支持批量:AI 可能返回数组
if (Array.isArray(parsed)) {
parsed.forEach(function(item, i) {
normalize(item);
var v = validate(item);
if (!v.valid) console.warn('⚠️ AI 返回[' + i + ']校验失败:', v.error);
});
} else {
normalize(parsed);
var v = validate(parsed);
if (!v.valid) console.warn('⚠️ AI 返回校验失败:', v.error);
}
return parsed;
} catch (e) {
// AI 返回非 JSON(如纯文本回复图片消息)→ 降级为 chat