Files
xianyu-hunter-hermes-skill/project/xianyu-hunter/lib/ai.mjs
T

292 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 : ['你好呀~'];
}
}