import { getApiKey, getBaseUrl, getCurrentModel, isConfigured } from './config.mjs'; export { getCurrentModel, setCurrentModel, getAvailableModels } from './config.mjs'; export async function chatCompletion(messages, { temperature = 0.7, maxTokens = 1024, timeoutMs = 45000 } = {}) { if (!isConfigured()) { throw new Error('AI 未配置:请先在控制台「设置」中配置 API 密钥和模型'); } const apiKey = getApiKey(); const baseUrl = getBaseUrl(); const model = getCurrentModel(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', signal: controller.signal, headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model, messages, temperature, max_tokens: maxTokens, }), }); if (!res.ok) { const err = await res.text(); throw new Error(`AI API error ${res.status}: ${err}`); } const data = await res.json(); const content = data.choices?.[0]?.message?.content ?? ''; if (!content) { const diag = JSON.stringify({ choicesLen: data.choices?.length, finishReason: data.choices?.[0]?.finish_reason, error: data.error, }); console.warn(`[AI] 模型返回空内容: ${diag}`); } return content; } finally { clearTimeout(timer); } } export const DEFAULT_COARSE_PROMPT = `你是一个二手商品标题初筛助手。你只看标题,判断该商品是否与用户需求存在【明确冲突】。 判定规则: - 只有标题内容和用户需求直接矛盾时才排除(例如:用户要笔记本,标题明确是手机壳/配件/维修/求购/租赁等完全不同的东西) - 标题中没有提及的细节(成色、电池、价格、地区等)绝对不能作为排除理由 - 任何拿不准的情况一律保留,宁可多留不能误删`; export const DEFAULT_FINE_PROMPT = `你是一个二手商品细筛助手。根据用户需求和商品详情页信息,判断该商品是否值得进一步沟通。 判定规则: - 只有当商品信息与用户需求存在【明确冲突】时才不通过(例如:用户要求电池90%以上,详情明确写了电池80%) - 商品描述中未提及的信息不算冲突,可以通过后续聊天了解 - 价格偏高不算冲突(可以砍价),只有远超合理范围才排除 - 拿不准的一律通过,宁可多聊不能错过`; export async function judgeByTitle(titles, requirements, customPromptHead, emitLog) { const head = (customPromptHead && customPromptHead.trim()) ? customPromptHead.trim() : DEFAULT_COARSE_PROMPT; const prompt = `${head} 用户需求:${requirements} 商品列表(JSON数组): ${JSON.stringify(titles.map((t, i) => ({ index: i, title: t })))} 请返回纯JSON数组,包含所有【没有明确冲突】的商品index(即应该保留的)。 只有标题和需求直接矛盾才排除,其余全部保留。 示例:[0, 1, 2, 3, 5, 7] 只输出JSON数组,不要有任何其他文字。`; if (emitLog) emitLog(` [AI请求·粗筛] 需求="${requirements.slice(0, 60)}..." ${titles.length}个标题`); const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1 }); if (emitLog) emitLog(` [AI返回·粗筛] ${reply.slice(0, 200)}`); if (!reply || !reply.trim()) { if (emitLog) emitLog(` ⚠️ AI返回为空,保留本批全部`); return titles.map((_, i) => i); } try { const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim(); return JSON.parse(cleaned); } catch { const matches = reply.match(/\d+/g); return matches ? matches.map(Number) : titles.map((_, i) => i); } } export async function judgeByDetail(product, requirements, customPromptHead, emitLog) { const head = (customPromptHead && customPromptHead.trim()) ? customPromptHead.trim() : DEFAULT_FINE_PROMPT; const prompt = `${head} 用户需求:${requirements} 商品信息: - 标题:${product.title} - 价格:${product.price} - 描述:${product.description} - 卖家:${product.sellerName}(${product.sellerLocation}) - 卖家好评率:${product.sellerRating || '未知'} - 想要人数:${product.wantCount || '未知'} 请返回JSON格式:{"pass": true/false, "reason": "简短理由"} 判定标准:只有商品信息与需求存在明确冲突时pass为false,拿不准则pass为true。 不要输出任何额外文字。`; if (emitLog) emitLog(` [AI请求·细筛] "${product.title.slice(0, 30)}..." 需求="${requirements.slice(0, 50)}..."`); const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1 }); if (emitLog) emitLog(` [AI返回·细筛] ${reply.slice(0, 200)}`); try { const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim(); return JSON.parse(cleaned); } catch { return { pass: false, reason: '解析失败' }; } } // ========== 聊天 prompt 三要素构建 ========== export const DEFAULT_PERSONA = `你是一位活跃在闲鱼平台的买家。性格开朗、礼貌、真诚,说话自带"元气感"和"氛围感"的女大学生买家。你的核心目标是通过提供极高的情绪价值,让卖家产生"卖给你很开心"的心理,从而以更优惠的价格(谈价)或更优的条件(包邮/送小礼物)达成交易。 ## Communication Style: 1. **语气词丰富:** 多用"哒、呀、呢、喔、呜呜、滴、哈罗"。 2. **表情包达人:** 灵活使用 Emoji(✨, 🥺, 🎈, 🥳, 💡, 🤏)。 3. **高情绪价值:** 进场先夸宝贝,中场卖萌示弱,收尾爽快礼貌。 4. **拒绝冷冰冰:** 禁止直接发数字(如"100?"),必须包装成有温度的请求。 核心沟通规则: 1. 每条消息严格不超过30个字 2. 一次回复可以发2-4条短消息,分条发送。而且除了信息交流外必须要能体现出礼貌可爱的人设 3. 语气活泼自然,像真人聊天,绝对不能像AI 4. 大量使用口语化表达和语气词(呀、哦、呢、嘻嘻、哈哈、嗯嗯) 5. 善用~代替句号,偶尔用表情(但不要每条都有) 6. 禁止一次性发送超过30字的长段落,会被识别为机器人 7. 回复节奏要像真人:先回应对方说的,再追问新问题 沟通流程(按优先级推进): - 先确认商品是否还在售 - 了解描述中未提及的信息和用户需求匹配度 - 进入谈价环节(根据下方谈价策略执行) - 确认发货方式和时间 禁止行为: - 不要自报家门说"我是XXX" - 不要用书面语、不要太客气太正式 - 不要连续问多个问题,一次最多问两个个点`; function buildChatStrategy(userStrategy, customPersona) { const persona = (customPersona && customPersona.trim()) ? customPersona.trim() : DEFAULT_PERSONA; const base = `【聊单策略 —— 你的人设与沟通方式】 ${persona}`; if (!userStrategy) return base; return base + ` 【本任务谈价策略 —— 议价时必须遵守】 ${userStrategy} 执行要点:按上述策略灵活谈价,但不要生硬照搬策略原文。把策略内化为你自己的沟通节奏。`; } function buildProductContext(product) { const lines = [`【卖家发布的商品信息 —— 你正在咨询的这件商品】`]; lines.push(''); if (product.title) lines.push(`商品标题:${product.title}`); if (product.price || product.detailPrice) lines.push(`标价:${product.detailPrice || product.price}`); if (product.description) lines.push(`商品描述:${product.description}`); if (product.sellerName) lines.push(`卖家昵称:${product.sellerName}`); if (product.sellerLocation) lines.push(`卖家所在地:${product.sellerLocation}`); if (product.sellerRating) lines.push(`卖家好评率:${product.sellerRating}`); if (product.wantCount) lines.push(`想要人数:${product.wantCount}`); if (product.images?.length > 0) lines.push(`商品图片数量:${product.images.length}张`); return lines.join('\n'); } function buildBuyerRequirements(requirements) { return `【采购需求 —— 你的真实购买意图,不要直接告诉卖家】 ${requirements} 注意:以上是你内心的筛选标准,用于判断是否值得继续沟通。 不要把这些需求原封不动地告诉卖家,而是通过自然的提问来获取信息。`; } export function buildSessionPrompt(product, { requirements, chatStrategy, persona } = {}) { return [ buildChatStrategy(chatStrategy, persona), '', buildProductContext(product), '', buildBuyerRequirements(requirements || ''), '', '【输出格式要求】', '返回纯JSON数组,每个元素是一条短消息字符串(≤15字)。', '例如:["你好呀~", "这个还在吗", "成色怎么样呢"]', '不要输出任何JSON以外的内容。', ].join('\n'); } export async function judgeGoalReached(product, chatHistory, { requirements, chatStrategy, emitLog } = {}) { const recentHistory = chatHistory.slice(-20); const historyText = recentHistory.map(m => { const label = m.role === 'assistant' ? '我' : '卖家'; return `${label}: ${m.content}`; }).join('\n'); const goalContext = []; if (chatStrategy) goalContext.push(`谈价策略:${chatStrategy}`); if (requirements) goalContext.push(`采购需求:${requirements}`); const prompt = `你是一个聊天目标判定助手。根据以下信息判断这段买卖对话是否已经达成聊天目标。 达成目标的标准(满足任一即可): 1. 卖家已同意在买家心理价位范围内成交(明确报出了可接受的价格或同意了买家的出价) 2. 买卖双方已确认交易细节(价格+发货方式),对话接近可以下单的状态 3. 卖家明确拒绝降价且态度坚决,继续谈判已无意义 4. 商品已售出/下架,卖家明确表示没货了 商品信息: - 标题:${product.title} - 标价:${product.detailPrice || product.price} ${goalContext.length > 0 ? goalContext.join('\n') : '(无特定策略)'} 对话记录: ${historyText} 请返回JSON格式:{"reached": true/false, "reason": "简短说明结论(20字以内)"} 不要输出任何额外文字。`; if (emitLog) emitLog(` [AI请求·目标判定] "${product.title.slice(0, 25)}..." 近${recentHistory.length}条对话`); const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1, maxTokens: 256 }); if (emitLog) emitLog(` [AI返回·目标判定] ${reply.slice(0, 200)}`); try { const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim(); return JSON.parse(cleaned); } catch { return { reached: false, reason: '判断失败' }; } } export async function generateChatMessages(product, chatHistory, { requirements, chatStrategy, persona, emitLog } = {}) { const systemPrompt = buildSessionPrompt(product, { requirements, chatStrategy, persona }); const messages = [ { role: 'system', content: systemPrompt }, ...chatHistory, ]; if (chatHistory.length === 0) { messages.push({ role: 'user', content: '现在你刚点进这个商品的聊天页面,请生成开场白。2-3条短消息,先打招呼再自然地问一个关于商品的问题。', }); } else { messages.push({ role: 'user', content: '卖家刚刚回复了(见上方对话),请根据对方的回复自然地继续聊,1-3条短消息。', }); } if (emitLog) emitLog(` [AI请求·聊天] 历史${chatHistory.length}条 "${product.title.slice(0, 25)}..."`); const reply = await chatCompletion(messages, { temperature: 0.8, maxTokens: 512 }); if (emitLog) emitLog(` [AI返回·聊天] ${reply.slice(0, 200)}`); try { const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim(); const parsed = JSON.parse(cleaned); if (Array.isArray(parsed)) { return parsed.filter(m => typeof m === 'string' && m.length > 0 && m.length <= 20); } return [String(parsed)]; } catch { const lines = reply.split('\n') .map(l => l.replace(/^[\s"'\d.、\-\[\]]+/, '').replace(/["\],]+$/, '').trim()) .filter(l => l.length > 0 && l.length <= 20); return lines.length > 0 ? lines : ['你好呀~']; } }