diff --git a/server/ai/client.js b/server/ai/client.js index c91786a..823dbd7 100644 --- a/server/ai/client.js +++ b/server/ai/client.js @@ -19,10 +19,11 @@ function getErrorMsg(status, data) { } } -async function callDeepSeek(messages, userId, temperature = 0.1, jsonMode = true) { +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 (jsonMode) body.response_format = { type: 'json_object' }; + 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', @@ -50,7 +51,8 @@ async function callDeepSeek(messages, userId, temperature = 0.1, jsonMode = true // 输入消息大小日志 //var inputChars = JSON.stringify(messages).length; //console.log('输入大小: ' + inputChars + ' 字符, ' + messages.length + ' 条消息'); - return data.choices[0].message.content; + // 返回整个 message 对象,可能包含 content 或 tool_calls(供上层决定走工具还是 JSON) + return data.choices[0].message; } module.exports = { callDeepSeek }; diff --git a/server/ai/prompt.js b/server/ai/prompt.js index 333bf12..65821e2 100644 --- a/server/ai/prompt.js +++ b/server/ai/prompt.js @@ -1,75 +1,40 @@ -// AI Prompt 模板 — 简化版(增 / 查 / 清空 / 聊天 / 忽略) -// 增加:价格必须存在,缺失时自动追问 +// AI Prompt 模板 — 工具模式(阶段一) +// 新建采购 / 查询 / 聊天 / 忽略 走 Function Tool 调用; +// 删除 / 清空 保留 JSON 输出路径(旧逻辑兜底,见 messages.js)。 +// 图片关联由后端决定,不在 prompt 中引导模型输出 use_recent_image。 + function buildSystemPrompt(username, isAdmin) { - const role = '你是智能财务助手“小财”。你只输出一个合法的 JSON 对象,且必须包含 "action" 字段。'; + const role = '你是智能财务助手"小财"。当前用户:' + username + ',管理员状态:' + isAdmin + '(true=管理员,false=普通用户)。'; - const actions = [ - '动作定义(action 字段必须为以下之一):', - '1. purchase:新建一条采购记录。', + const guidance = [ + '请根据用户消息选择合适的方式回应:', + '', + '一、调用工具(适用于以下场景):', + '1. 新建采购记录 → 调用工具 create_purchase。', ' 触发条件:用户明确表达了购买/采购/下单/付款/买了/花了等消费意图,且不是在查询历史。', - ' 需要提取的字段(能提取多少就提取多少,不确定的用默认值):', - ' - purchase_item (必填, 字符串, 物品名)', - ' - quantity (数字, 默认1)', - ' - unit_price (必填, 数字, 单价) ← 必须明确或能从语句中推断出大于0的数值', - ' - freight (数字, 默认0)', - ' - payment_method (字符串, 如"支付宝""微信",未提及则默认"未指定")', - ' - invoice_type (字符串, 可选值"无票"/"电子票",默认"无票")', - ' - status (字符串, 只能从"待付款"/"已付款"/"已收货"/"已完成"中选择,默认"待付款"。如果用户明确说已付款、已收货、已完成等,请映射到对应状态)', - ' - applicant (字符串, 默认"' + username + '")', - ' - remarks (字符串, 能从语句中提取的备注信息,如购买平台等)', - ' ⚠️ 关键规则:如果用户没有提供价格(unit_price 为 0 或无法提取),你必须返回 action="chat",reply 内容礼貌地询问价格,例如:“好的,请问这个 ‘插排’ 是多少钱呢?”', - ' 示例输出(价格齐全):{"action":"purchase","purchase_item":"扩展坞","quantity":1,"unit_price":89,"freight":0,"payment_method":"未指定","invoice_type":"无票","status":"已付款","applicant":"' + username + '","remarks":"淘宝"}', + ' 物品名、数量、单价、运费、支付方式、发票类型、状态、备注等字段含义见工具参数定义。', + ' 关键规则1:如果用户没有明确提供单价(价格),不要调用 create_purchase,而应调用 reply_chat 追问价格。', + ' 关键规则2:用户如提及"交了""付了""已付款""已收货""已完成"等,必须把 status 填为对应状态,不要漏填。', + ' 关键规则3:一次消息中包含多笔采购时,必须为每一笔分别调用一次 create_purchase,不要合并或遗漏。', + '2. 查询历史采购记录 → 调用工具 query_purchases,参数 keywords 为物品名或关键词(查全部则用空字符串)。', + ' 触发条件:用户询问"买过什么/有没有买过/查一下/找找看/搜索/列表/我买过/看看"等查询意图。', + ' 即使物品名听起来奇怪或可能不存在,也必须用 query_purchases,绝不能调 create_purchase。', + '3. 纯闲聊、引导手动修改、追问价格、权限不足拒绝 → 调用工具 reply_chat,参数 reply 为要显示给用户的回复。', + '4. 完全无关的消息(纯符号、纯表情、明显不是对助手说的话)→ 调用工具 ignore。', '', - '2. query:查询历史采购记录。', - ' 触发条件:用户询问“买过什么/有没有买过/查一下/找找看/搜索/列表/我买过/看看”等查询历史采购的意图。', - ' 即使物品名听起来奇怪或可能不存在,也必须用 query,绝不能返回 ignore。', - ' 字段:', - ' - keywords (字符串, 提取用户想查找的物品名或关键词;如果问的是全部记录则设为空字符串 "")', - ' 示例:{"action":"query","keywords":"记账本"}', - ' 注意:query 会触发系统在本地数据库中搜索,然后再次调用 AI 生成回答,你无需直接给出查询结果。', + '二、直接输出 JSON(不要调用任何工具):', + '5. 删除采购记录 → 输出 {"action":"delete","delete_item":"物品名","quantity":数量,"amount":金额},其中 quantity/amount 可选。', + '6. 清空所有采购数据 → 输出 {"action":"clear_all"}。仅管理员(isAdmin=true)可执行;', + ' 非管理员必须输出 {"action":"chat","reply":"仅管理员可执行此操作"}。', '', - '3. clear_all:清空所有采购数据。', - ' 触发条件:用户要求“清空所有记录”“全部删掉”“重置采购数据”等。', - ' 权限:如果当前用户是管理员(isAdmin=true),你可以返回 {"action":"clear_all"}。', - ' 如果当前用户不是管理员(isAdmin=false),你必须返回 action="chat",reply 说明“仅管理员可执行此操作”。', - '', - '4. chat:直接回复文本。', - ' 适用于以下情况:', - ' - 纯闲聊(如“你好”“今天天气如何”)', - ' - 用户要求修改、更正、调整、更新已有采购记录的任何意图(如“把扩展坞价格改成90”“已付款”“已收货”等),你必须引导用户手动操作,不得尝试更新记录。', - ' - 权限不足时的拒绝提示(如非管理员要求清空)', - ' - 采购意图但缺少价格信息时,追问价格', - ' - 其他无法归入上述动作但需要回复的情况', - ' 字段:reply (字符串,显示给用户)', - ' 修改意图的统一回复模板:"如需修改记录,请前往采购清单页面,找到对应条目后手动编辑。"', - '', - '5. ignore:完全无关的消息。', - ' 触发条件:纯符号、纯表情、明显不是对助手说的话等。', - ' 不需要额外字段,仅返回 {"action":"ignore"} 即可。' + '三、通用规则:', + '- 用户要求修改、更正、调整、更新已有采购记录的任何意图(改数量、改价格、改状态、补发票、加备注等),一律用 reply_chat 引导用户手动操作,不要尝试更新记录。', + '- 修改意图的统一回复模板:"如需修改记录,请前往采购清单页面,找到对应条目后手动编辑。"', + '- 如果用户同时表达了购买和查询,优先判断为购买(create_purchase),除非提问明显是查询历史。', + '- 若无法确定用户意图,默认调用 reply_chat 并给出友好回复,绝不忽略。' ].join('\n'); - const rules = [ - '⚠️ 重要规则:', - '- 所有输出必须是严格的 JSON,且顶层必须包含 "action" 字段。', - '- 不要输出除 JSON 外的任何文字。', - '- 当前用户:' + username + ',管理员状态:' + isAdmin + '(true=管理员,false=普通用户)。', - '- 查询意图(包含“查”“找”“看”“搜索”“有没有”“买过”等)必须走 query,绝不可判为 ignore 或 chat。', - '- 任何希望修改已有记录的说法(改数量、改价格、改状态、补发票、加备注等),一律作为 chat 引导手动操作,不要试图更新记录。', - '- 如果用户同时表达了购买和查询,优先判断为购买(purchase),除非提问明显是查询历史。', - '- 当用户说出购买意图但缺少价格时,必须返回 chat 追问价格,绝不允许带着 unit_price=0 直接创建记录。', - '- 如果无法确定 action,默认走 chat 并给出友好回复。' - ].join('\n'); - - const forceOutputRule = [ - '', - '🔴 防呆底线(最高优先级):', - '- 你必须始终返回一个合法 JSON 对象,且必须包含 "action" 字段。', - '- 即使你完全不确定用户意图,也要返回 {"action":"ignore"}。', - '- 绝不允许返回空字符串、纯文本、只有大括号无内容或任何非 JSON 格式的输出。', - '- 如果你的推理碰到矛盾,优先选择 chat 并给出引导,而不是放弃输出。' - ].join('\n'); - - return [role, actions, rules, forceOutputRule].join('\n\n'); + return [role, guidance].join('\n\n'); } module.exports = { buildSystemPrompt }; diff --git a/server/ai/tools.js b/server/ai/tools.js new file mode 100644 index 0000000..bc1478c --- /dev/null +++ b/server/ai/tools.js @@ -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 }; \ No newline at end of file diff --git a/server/handlers.js b/server/handlers.js index d332f18..205ba34 100644 --- a/server/handlers.js +++ b/server/handlers.js @@ -96,10 +96,6 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) { } if (aiResult.action === 'purchase') { - 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, '小财', '请提供具体的物品名称。'); @@ -108,13 +104,9 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) { } 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 && /(图片|附件|加到|存入|关联|作为附件)/.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) {} @@ -190,7 +182,8 @@ async function callDeepSeekForSummary(prompt, userId) { { role: 'system', content: '你是一个财务助手,请根据采购记录生成简洁回复。' }, { role: 'user', content: prompt } ]; - return callDeepSeek(messages, userId, 0.3, false); + const msg = await callDeepSeek(messages, userId, 0.3, false); + return msg && typeof msg === 'object' ? (msg.content || '') : (msg || ''); } module.exports = { diff --git a/server/public/index.html b/server/public/index.html index fa3b10b..3ae6277 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -237,7 +237,7 @@