|
|
|
|
@ -90,6 +90,9 @@ const statusText = ref('在线')
|
|
|
|
|
const statusClass = ref('online')
|
|
|
|
|
const currentActivity = ref('')
|
|
|
|
|
let statusTimer = null
|
|
|
|
|
const currentScheduleEnd = ref(null) // ms timestamp
|
|
|
|
|
let pendingReplyTimer = null
|
|
|
|
|
let pendingPlaceholderTs = null
|
|
|
|
|
|
|
|
|
|
function computeStatusFromSchedule(oc) {
|
|
|
|
|
try {
|
|
|
|
|
@ -132,6 +135,8 @@ function computeStatusFromSchedule(oc) {
|
|
|
|
|
statusClass.value = 'busy'
|
|
|
|
|
currentActivity.value = s.event || '忙碌'
|
|
|
|
|
}
|
|
|
|
|
// store end time for scheduling reply when busy
|
|
|
|
|
currentScheduleEnd.value = end.getTime()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -143,15 +148,18 @@ function computeStatusFromSchedule(oc) {
|
|
|
|
|
statusText.value = `稍后:${upcoming.time || ''} ${upcoming.event || ''}`
|
|
|
|
|
statusClass.value = 'soon'
|
|
|
|
|
currentActivity.value = ''
|
|
|
|
|
currentScheduleEnd.value = null
|
|
|
|
|
} else {
|
|
|
|
|
statusText.value = '空闲'
|
|
|
|
|
statusClass.value = 'idle'
|
|
|
|
|
currentActivity.value = ''
|
|
|
|
|
currentScheduleEnd.value = null
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
statusText.value = '在线'
|
|
|
|
|
statusClass.value = 'online'
|
|
|
|
|
currentActivity.value = ''
|
|
|
|
|
currentScheduleEnd.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -161,8 +169,44 @@ watch(() => props.activeContact, (c) => {
|
|
|
|
|
statusTimer = setInterval(() => computeStatusFromSchedule(props.activeContact || {}), 60 * 1000)
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
|
|
|
// 格式化时间为人类可读的字符串(若为当天则只显示 HH:MM,否则显示 YYYY-MM-DD HH:MM)
|
|
|
|
|
function formatDateTime(ms) {
|
|
|
|
|
try {
|
|
|
|
|
const d = new Date(ms)
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
|
|
|
|
const hh = String(d.getHours()).padStart(2, '0')
|
|
|
|
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
|
|
|
if (sameDay) return `${hh}:${mm}`
|
|
|
|
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${hh}:${mm}`
|
|
|
|
|
} catch (e) { return '' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 当 activeContact 或其日程变更时,如果有待处理的占位回复,重新计算并重设定时器
|
|
|
|
|
watch(() => [props.activeContact && props.activeContact.schedule ? props.activeContact.schedule.length : 0, currentScheduleEnd.value], () => {
|
|
|
|
|
if (pendingPlaceholderTs && pendingReplyTimer) {
|
|
|
|
|
// 重新计算等待时间
|
|
|
|
|
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
|
|
|
|
|
let waitMs = 0
|
|
|
|
|
if (currentScheduleEnd.value) {
|
|
|
|
|
waitMs = Math.max(currentScheduleEnd.value - Date.now(), 0)
|
|
|
|
|
} else {
|
|
|
|
|
waitMs = 5 * 60 * 1000
|
|
|
|
|
}
|
|
|
|
|
pendingReplyTimer = setTimeout(() => {
|
|
|
|
|
const ph = pendingPlaceholderTs
|
|
|
|
|
pendingReplyTimer = null
|
|
|
|
|
pendingPlaceholderTs = null
|
|
|
|
|
// perform reply for this placeholder
|
|
|
|
|
// performAIReply is in scope
|
|
|
|
|
try { performAIReply(ph) } catch (e) { /* ignore */ }
|
|
|
|
|
}, waitMs)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (statusTimer) clearInterval(statusTimer)
|
|
|
|
|
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
|
|
|
|
|
})
|
|
|
|
|
const handleSend = async () => {
|
|
|
|
|
if (!canSend.value) return
|
|
|
|
|
@ -178,8 +222,8 @@ const handleSend = async () => {
|
|
|
|
|
const userText = draft.value.trim()
|
|
|
|
|
draft.value = ''
|
|
|
|
|
|
|
|
|
|
// AI回复
|
|
|
|
|
const API_KEY = import.meta.env.VITE_ZHIPU_API_KEY
|
|
|
|
|
// prepare placeholder message
|
|
|
|
|
const aiMsg = {
|
|
|
|
|
senderId: props.activeContact?.id || 'ai',
|
|
|
|
|
text: 'AI思考中...',
|
|
|
|
|
@ -188,137 +232,105 @@ const handleSend = async () => {
|
|
|
|
|
nextMessages = [...messages.value, aiMsg]
|
|
|
|
|
messages.value = nextMessages
|
|
|
|
|
emit('update:modelValue', nextMessages)
|
|
|
|
|
const isBusy = statusClass.value === 'busy' || statusText.value.startsWith('正在')
|
|
|
|
|
const delayMs = isBusy ? 5 * 60 * 1000 : 0 // 忙碌时延时 5 分钟
|
|
|
|
|
// 如果忙碌,先把占位消息替换为“我正在...,请稍等”格式,真实回复延时替换
|
|
|
|
|
if (isBusy) {
|
|
|
|
|
const placeholder = {
|
|
|
|
|
...aiMsg,
|
|
|
|
|
text: `我正在${currentActivity.value || '忙碌'},请稍等`,
|
|
|
|
|
timestamp: Date.now()
|
|
|
|
|
}
|
|
|
|
|
// 替换占位消息
|
|
|
|
|
messages.value = [...messages.value.slice(0, -1), placeholder]
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
// 在 respond() 中会替换为真实回复
|
|
|
|
|
}
|
|
|
|
|
if (!API_KEY) {
|
|
|
|
|
aiMsg.text = 'API_KEY未配置,请检查.env文件和重启项目'
|
|
|
|
|
messages.value = [...messages.value.slice(0, -1), aiMsg]
|
|
|
|
|
emit('update:modelValue', messages.value)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
// 构造人设 prompt(与 OC 问卷字段一一对应,确保 AI 能获取完整人设)
|
|
|
|
|
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.race],
|
|
|
|
|
['职业', c.occupation],
|
|
|
|
|
['发型与发色', c.hair],
|
|
|
|
|
['瞳色', c.eyeColor],
|
|
|
|
|
['身高', c.height],
|
|
|
|
|
['着装风格', c.clothes],
|
|
|
|
|
['标志性特征', c.feature],
|
|
|
|
|
['核心性格', fmtList(c.personality) || c.personalityOther],
|
|
|
|
|
['MBTI', c.mbti],
|
|
|
|
|
['优点', c.advantage],
|
|
|
|
|
['缺点', c.shortcoming],
|
|
|
|
|
['习惯性小动作', c.habit],
|
|
|
|
|
['口头禅', c.catchphrase],
|
|
|
|
|
['成长环境', c.growup],
|
|
|
|
|
['背景故事', c.story],
|
|
|
|
|
['秘密', c.secret],
|
|
|
|
|
['兴趣爱好', fmtList(c.hobbies)],
|
|
|
|
|
['喜欢的食物', c.foodLike],
|
|
|
|
|
['讨厌的东西', c.hate],
|
|
|
|
|
['理想生活方式', c.life],
|
|
|
|
|
['周末日常', c.weekend],
|
|
|
|
|
['社区主要活动', c.communityActivity],
|
|
|
|
|
['关系期待', fmtList(c.relation) || c.relationOther],
|
|
|
|
|
['头像URL', c.avatar]
|
|
|
|
|
]
|
|
|
|
|
const isBusy = statusClass.value === 'busy' || statusText.value.startsWith('正在')
|
|
|
|
|
|
|
|
|
|
fields.forEach(([label, value]) => {
|
|
|
|
|
if (value || value === 0) {
|
|
|
|
|
persona += `${label}:${safe(value)};`
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 社交圈与成就(作为附加信息)
|
|
|
|
|
if (c.socialCircle && c.socialCircle.length) persona += `社交圈:${c.socialCircle.slice(0,5).map(x => x.name || x).join('、')};`;
|
|
|
|
|
if (c.achievements && c.achievements.length) persona += `成就:${c.achievements.slice(0,5).join('、')};`;
|
|
|
|
|
|
|
|
|
|
// 今日行程简要(取当天日程并格式化为 时间-活动@地点)
|
|
|
|
|
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(';')};`;
|
|
|
|
|
// 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) {
|
|
|
|
|
// ignore
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 共享用户信息给AI
|
|
|
|
|
if (currentUser.value && currentUser.value.nickname) {
|
|
|
|
|
persona += `对方昵称:${currentUser.value.nickname},`;
|
|
|
|
|
if (currentUser.value.occupation) persona += `对方职业:${currentUser.value.occupation},`;
|
|
|
|
|
if (currentUser.value.hobbies) persona += `对方爱好:${currentUser.value.hobbies},`;
|
|
|
|
|
}
|
|
|
|
|
persona += '请用简洁、贴近生活的语气,只回复一句符合自己人设和性格的聊天内容,如果对方没说就不要刻意强调自己知晓对方的爱好,不要混淆对方和自己的爱好,回复的文本长度根据对方的语句进行调整,比如对方只是简单问候就可以简短回应,对于问题则可以以自身人设风格进行较为详细的解答。';
|
|
|
|
|
const prompt = `${persona}\n对方说:${userText}`;
|
|
|
|
|
const res = await axios.post(
|
|
|
|
|
'https://open.bigmodel.cn/api/paas/v4/chat/completions',
|
|
|
|
|
{
|
|
|
|
|
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: 64,
|
|
|
|
|
max_tokens: 128,
|
|
|
|
|
temperature: 0.7
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
headers: {
|
|
|
|
|
'Authorization': `Bearer ${API_KEY}`,
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
let reply = res.data.choices?.[0]?.message?.content?.trim()
|
|
|
|
|
if (!reply) {
|
|
|
|
|
reply = '(AI未能生成回复,请稍后重试)'
|
|
|
|
|
}, { 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)
|
|
|
|
|
}
|
|
|
|
|
aiMsg.text = reply
|
|
|
|
|
aiMsg.timestamp = Date.now()
|
|
|
|
|
messages.value = [...messages.value.slice(0, -1), aiMsg]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// create placeholder and determine scheduling
|
|
|
|
|
const placeholderTs = Date.now()
|
|
|
|
|
pendingPlaceholderTs = placeholderTs
|
|
|
|
|
// replace last AI placeholder with activity placeholder if busy
|
|
|
|
|
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)
|
|
|
|
|
} 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}`
|
|
|
|
|
// compute wait ms until schedule end
|
|
|
|
|
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)
|
|
|
|
|
} else {
|
|
|
|
|
// fallback to 5 minutes
|
|
|
|
|
waitMs = 5 * 60 * 1000
|
|
|
|
|
}
|
|
|
|
|
aiMsg.text = errMsg
|
|
|
|
|
messages.value = [...messages.value.slice(0, -1), aiMsg]
|
|
|
|
|
// clear previous timer if any
|
|
|
|
|
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
|
|
|
|
|
pendingReplyTimer = setTimeout(() => {
|
|
|
|
|
pendingReplyTimer = null
|
|
|
|
|
pendingPlaceholderTs = null
|
|
|
|
|
performAIReply(placeholderTs)
|
|
|
|
|
}, 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)
|
|
|
|
|
// 可选:console详细错误
|
|
|
|
|
// console.error('AI接口调用失败', err)
|
|
|
|
|
await performAIReply(placeholderTs)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|