|
|
|
|
@ -49,7 +49,7 @@ const props = defineProps({
|
|
|
|
|
default: null
|
|
|
|
|
},
|
|
|
|
|
modelValue: {
|
|
|
|
|
type: Array,
|
|
|
|
|
type: [String, Number, Array],
|
|
|
|
|
default: () => []
|
|
|
|
|
},
|
|
|
|
|
selfId: {
|
|
|
|
|
@ -184,37 +184,144 @@ function formatDateTime(ms) {
|
|
|
|
|
|
|
|
|
|
// 当 activeContact 或其日程变更时,如果有待处理的占位回复,重新计算并重设定时器
|
|
|
|
|
watch(() => [props.activeContact && props.activeContact.schedule ? props.activeContact.schedule.length : 0, currentScheduleEnd.value], () => {
|
|
|
|
|
if (pendingPlaceholderTs && pendingReplyTimer) {
|
|
|
|
|
// 重新计算等待时间
|
|
|
|
|
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
|
|
|
|
|
// 当 schedule 或 currentScheduleEnd 变化时,如果存在一个待处理的占位回复,重新安排定时器
|
|
|
|
|
if (pendingPlaceholderTs) {
|
|
|
|
|
if (pendingReplyTimer) {
|
|
|
|
|
clearTimeout(pendingReplyTimer)
|
|
|
|
|
pendingReplyTimer = null
|
|
|
|
|
}
|
|
|
|
|
let waitMs = 0
|
|
|
|
|
if (currentScheduleEnd.value) {
|
|
|
|
|
waitMs = Math.max(currentScheduleEnd.value - Date.now(), 0)
|
|
|
|
|
} else {
|
|
|
|
|
waitMs = 5 * 60 * 1000
|
|
|
|
|
}
|
|
|
|
|
pendingReplyTimer = setTimeout(() => {
|
|
|
|
|
const ph = pendingPlaceholderTs
|
|
|
|
|
// 安排新的定时器,在 schedule 结束或超时后触发 AI 回复
|
|
|
|
|
pendingReplyTimer = setTimeout(async () => {
|
|
|
|
|
pendingReplyTimer = null
|
|
|
|
|
const batchId = pendingPlaceholderTs
|
|
|
|
|
pendingPlaceholderTs = null
|
|
|
|
|
// perform reply for this placeholder
|
|
|
|
|
// performAIReply is in scope
|
|
|
|
|
try { performAIReply(ph) } catch (e) { /* ignore */ }
|
|
|
|
|
// 尝试找回该批次的用户消息文本
|
|
|
|
|
const lastUser = [...messages.value].reverse().find(m => m.batchId === batchId && m.senderId === props.selfId)
|
|
|
|
|
const userText = lastUser ? lastUser.text : ''
|
|
|
|
|
await performAIReply(batchId, userText)
|
|
|
|
|
}, waitMs)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
|
|
|
// 执行 AI 回复的独立函数,给定 batchId 和对应的用户文本
|
|
|
|
|
const performAIReply = async (batchId, userText) => {
|
|
|
|
|
const API_KEY = import.meta.env.VITE_ZHIPU_API_KEY
|
|
|
|
|
if (!API_KEY) {
|
|
|
|
|
messages.value = messages.value.map(m =>
|
|
|
|
|
m.batchId === batchId && m.isPlaceholder
|
|
|
|
|
? { ...m, text: 'API_KEY 未配置,无法调用 AI', timestamp: Date.now(), isPlaceholder: false }
|
|
|
|
|
: m
|
|
|
|
|
)
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const recent = messages.value.slice(Math.max(messages.value.length - 6, 0))
|
|
|
|
|
const conversationContext = recent.map(m => `${m.senderId === props.selfId ? '用户' : 'AI'}: ${m.text}`).join('\n')
|
|
|
|
|
|
|
|
|
|
let persona = ''
|
|
|
|
|
if (props.activeContact) {
|
|
|
|
|
const c = props.activeContact
|
|
|
|
|
const safe = (v) => (v === null || v === undefined) ? '' : String(v).replace(/\s+/g, ' ').trim()
|
|
|
|
|
const fmtList = (v) => Array.isArray(v) ? v.join('、') : (v ? safe(v) : '')
|
|
|
|
|
persona += `你是一个真人网友,名字是${safe(c.name) || '匿名'},回复风格友好、简短。`;
|
|
|
|
|
const fields = [
|
|
|
|
|
['性别', c.gender || (c.genderOther ? c.genderOther : '')],
|
|
|
|
|
['生日', c.birthday],
|
|
|
|
|
['年龄', c.age],
|
|
|
|
|
['职业', c.occupation],
|
|
|
|
|
['兴趣', fmtList(c.hobbies)]
|
|
|
|
|
]
|
|
|
|
|
fields.forEach(([label, value]) => {
|
|
|
|
|
if (value || value === 0) persona += `${label}:${safe(value)};`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prompt = `${persona}\n\n以下是最近的聊天记录:\n${conversationContext}\n\n请用轻松的口吻、最多20字回复最后一条用户消息(不要重复用户的话)。用户最后一句话:\n${userText}`
|
|
|
|
|
|
|
|
|
|
const res = await axios.post('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
|
|
|
|
|
model: 'glm-4-flash',
|
|
|
|
|
messages: [{ role: 'user', content: prompt }],
|
|
|
|
|
max_tokens: 50,
|
|
|
|
|
temperature: 0.9,
|
|
|
|
|
top_p: 0.8
|
|
|
|
|
}, {
|
|
|
|
|
headers: {
|
|
|
|
|
'Authorization': `Bearer ${API_KEY}`,
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Accept': 'application/json'
|
|
|
|
|
},
|
|
|
|
|
timeout: 10000
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let reply = res.data.choices?.[0]?.message?.content || ''
|
|
|
|
|
if (Array.isArray(reply)) reply = reply.join('\n')
|
|
|
|
|
reply = String(reply).trim()
|
|
|
|
|
if (!reply) reply = '(刚在忙,你说啥?)'
|
|
|
|
|
|
|
|
|
|
messages.value = messages.value.map(m =>
|
|
|
|
|
m.batchId === batchId && m.isPlaceholder
|
|
|
|
|
? { ...m, text: reply, timestamp: Date.now(), isPlaceholder: false }
|
|
|
|
|
: m
|
|
|
|
|
)
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('AI回复错误:', err)
|
|
|
|
|
let errMsg = '网络开小差了~'
|
|
|
|
|
if (err.response) {
|
|
|
|
|
console.error('API响应异常:', err.response.status)
|
|
|
|
|
errMsg += ` (状态码: ${err.response.status})`
|
|
|
|
|
} else if (err.request) {
|
|
|
|
|
console.error('服务器无响应:', err.request)
|
|
|
|
|
errMsg = '服务失联,请检查网络'
|
|
|
|
|
}
|
|
|
|
|
messages.value = messages.value.map(m =>
|
|
|
|
|
m.batchId === batchId && m.isPlaceholder
|
|
|
|
|
? { ...m, text: errMsg, timestamp: Date.now(), isPlaceholder: false }
|
|
|
|
|
: m
|
|
|
|
|
)
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (statusTimer) clearInterval(statusTimer)
|
|
|
|
|
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
|
|
|
|
|
})
|
|
|
|
|
const handleSend = async () => {
|
|
|
|
|
// ✅ 新增调试代码(发送消息时触发)
|
|
|
|
|
console.debug('[Send]', {
|
|
|
|
|
draft: draft.value,
|
|
|
|
|
activeContact: props.activeContact?.id,
|
|
|
|
|
canSend: canSend.value,
|
|
|
|
|
msgsCount: messages.value.length
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!canSend.value) return
|
|
|
|
|
|
|
|
|
|
// 添加发送前检查,防止重复发送
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
if (messages.value.some(m =>
|
|
|
|
|
m.senderId === props.selfId &&
|
|
|
|
|
m.text === draft.value.trim() &&
|
|
|
|
|
now - m.timestamp < 5000
|
|
|
|
|
)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 创建唯一ID用于防止消息混淆
|
|
|
|
|
const messageBatchId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
|
|
|
|
|
|
|
|
const userMsg = {
|
|
|
|
|
senderId: props.selfId,
|
|
|
|
|
text: draft.value.trim(),
|
|
|
|
|
timestamp: Date.now()
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
batchId: messageBatchId // 添加批次ID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let nextMessages = [...messages.value, userMsg]
|
|
|
|
|
messages.value = nextMessages
|
|
|
|
|
emit('update:modelValue', nextMessages)
|
|
|
|
|
@ -223,114 +330,42 @@ const handleSend = async () => {
|
|
|
|
|
draft.value = ''
|
|
|
|
|
|
|
|
|
|
const API_KEY = import.meta.env.VITE_ZHIPU_API_KEY
|
|
|
|
|
// prepare placeholder message
|
|
|
|
|
const aiMsg = {
|
|
|
|
|
|
|
|
|
|
// 创建AI占位消息 - 添加相同的batchId
|
|
|
|
|
const aiPlaceholderMsg = {
|
|
|
|
|
senderId: props.activeContact?.id || 'ai',
|
|
|
|
|
text: '对方正在输入中...',
|
|
|
|
|
timestamp: Date.now() + 1
|
|
|
|
|
timestamp: Date.now() + 1,
|
|
|
|
|
batchId: messageBatchId,
|
|
|
|
|
isPlaceholder: true // 明确标记这是占位消息
|
|
|
|
|
}
|
|
|
|
|
nextMessages = [...messages.value, aiMsg]
|
|
|
|
|
|
|
|
|
|
nextMessages = [...messages.value, aiPlaceholderMsg]
|
|
|
|
|
messages.value = nextMessages
|
|
|
|
|
emit('update:modelValue', nextMessages)
|
|
|
|
|
|
|
|
|
|
const isBusy = statusClass.value === 'busy' || statusText.value.startsWith('正在')
|
|
|
|
|
|
|
|
|
|
// helper that performs the AI call and replaces the placeholder (matched by ts)
|
|
|
|
|
const performAIReply = async (placeholderTs) => {
|
|
|
|
|
if (!API_KEY) {
|
|
|
|
|
const failMsg = 'API_KEY未配置,请检查.env文件和重启项目'
|
|
|
|
|
// replace placeholder
|
|
|
|
|
messages.value = messages.value.map(m => (m.timestamp === placeholderTs ? { ...m, text: failMsg } : m))
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
// build persona
|
|
|
|
|
let persona = ''
|
|
|
|
|
if (props.activeContact) {
|
|
|
|
|
const c = props.activeContact
|
|
|
|
|
const safe = (v) => (v === null || v === undefined) ? '' : String(v).replace(/\s+/g, ' ').trim()
|
|
|
|
|
const fmtList = (v) => Array.isArray(v) ? v.join('、') : (v ? safe(v) : '')
|
|
|
|
|
persona += `你是${safe(c.name) || '该角色'}。`;
|
|
|
|
|
const fields = [
|
|
|
|
|
['性别', c.gender || (c.genderOther ? c.genderOther : '')],
|
|
|
|
|
['生日', c.birthday],
|
|
|
|
|
['年龄', c.age],
|
|
|
|
|
['职业', c.occupation],
|
|
|
|
|
['兴趣', fmtList(c.hobbies)]
|
|
|
|
|
]
|
|
|
|
|
fields.forEach(([label, value]) => {
|
|
|
|
|
if (value || value === 0) persona += `${label}:${safe(value)};`
|
|
|
|
|
})
|
|
|
|
|
// include brief todays
|
|
|
|
|
try {
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const todayStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
|
|
|
|
|
if (Array.isArray(c.schedule)) {
|
|
|
|
|
const todays = c.schedule.filter(s => String(s.date).startsWith(todayStr))
|
|
|
|
|
if (todays.length) {
|
|
|
|
|
const brief = todays.slice(0,6).map(s => `${s.time || '--:--'}-${(s.event||'').slice(0,20)}@${s.location||''}`)
|
|
|
|
|
persona += `今日日程(简要):${brief.join(';')};`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
if (currentUser.value && currentUser.value.nickname) persona += `对方昵称:${currentUser.value.nickname},`;
|
|
|
|
|
const prompt = `${persona}\n对方说:${userText}`
|
|
|
|
|
const res = await axios.post('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
|
|
|
|
|
model: 'glm-4-flash',
|
|
|
|
|
messages: [{ role: 'user', content: prompt }],
|
|
|
|
|
max_tokens: 128,
|
|
|
|
|
temperature: 0.7
|
|
|
|
|
}, { headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' } })
|
|
|
|
|
let reply = res.data.choices?.[0]?.message?.content?.trim() || '(AI未能生成回复,请稍后重试)'
|
|
|
|
|
// replace placeholder message with real reply (match by timestamp)
|
|
|
|
|
messages.value = messages.value.map(m => (m.timestamp === placeholderTs ? { ...m, text: reply, timestamp: Date.now() } : m))
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
let errMsg = 'AI接口调用失败'
|
|
|
|
|
if (err?.response?.data?.msg) errMsg += `: ${err.response.data.msg}`
|
|
|
|
|
else if (err?.response?.data?.message) errMsg += `: ${err.response.data.message}`
|
|
|
|
|
else if (err?.message) errMsg += `: ${err.message}`
|
|
|
|
|
messages.value = messages.value.map(m => (m.timestamp === placeholderTs ? { ...m, text: errMsg } : m))
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// create placeholder and determine scheduling
|
|
|
|
|
const placeholderTs = Date.now()
|
|
|
|
|
pendingPlaceholderTs = placeholderTs
|
|
|
|
|
// replace last AI placeholder with activity placeholder if busy
|
|
|
|
|
// 修改调度逻辑,传递batchId而非timestamp
|
|
|
|
|
pendingPlaceholderTs = messageBatchId
|
|
|
|
|
|
|
|
|
|
if (isBusy) {
|
|
|
|
|
const freeAt = currentScheduleEnd.value ? formatDateTime(currentScheduleEnd.value) : ''
|
|
|
|
|
const placeholderText = freeAt ? `我正在${currentActivity.value || '忙碌'},请稍等,预计空闲时间为 ${freeAt},等会再来找我玩吧` : `我正在${currentActivity.value || '忙碌'},请稍等`
|
|
|
|
|
const placeholder = { senderId: props.activeContact?.id || 'ai', text: placeholderText, timestamp: placeholderTs }
|
|
|
|
|
messages.value = [...messages.value.slice(0, -1), placeholder]
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
// compute wait ms until schedule end
|
|
|
|
|
// 计算等待时间(若有日程结束时间则等待至结束,否则默认 5 分钟)
|
|
|
|
|
let waitMs = 0
|
|
|
|
|
if (currentScheduleEnd.value) {
|
|
|
|
|
const buf = 0 // no extra buffer; you can set 60000 for 1min buffer
|
|
|
|
|
waitMs = Math.max(currentScheduleEnd.value + buf - Date.now(), 0)
|
|
|
|
|
waitMs = Math.max(currentScheduleEnd.value - Date.now(), 0)
|
|
|
|
|
} else {
|
|
|
|
|
// fallback to 5 minutes
|
|
|
|
|
waitMs = 5 * 60 * 1000
|
|
|
|
|
}
|
|
|
|
|
// clear previous timer if any
|
|
|
|
|
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
|
|
|
|
|
pendingReplyTimer = setTimeout(() => {
|
|
|
|
|
pendingReplyTimer = null
|
|
|
|
|
pendingPlaceholderTs = null
|
|
|
|
|
performAIReply(placeholderTs)
|
|
|
|
|
// 传入用户原始文本以便 AI 参考
|
|
|
|
|
performAIReply(messageBatchId, userText)
|
|
|
|
|
}, waitMs)
|
|
|
|
|
// also watch schedule changes: if schedule changes, reschedule by clearing timer and recreating in watcher above
|
|
|
|
|
return
|
|
|
|
|
} else {
|
|
|
|
|
// not busy -> immediate perform and replace
|
|
|
|
|
// replace the placeholder we created earlier (the simple 'AI思考中...') with a real-running placeholder timestamp
|
|
|
|
|
messages.value = [...messages.value.slice(0, -1), { ...aiMsg, timestamp: placeholderTs }]
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
await performAIReply(placeholderTs)
|
|
|
|
|
await performAIReply(messageBatchId, userText)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|