diff --git a/src/oc-community-frontend/src/components/ChatWindow.vue b/src/oc-community-frontend/src/components/ChatWindow.vue index e010b96..7adb494 100644 --- a/src/oc-community-frontend/src/components/ChatWindow.vue +++ b/src/oc-community-frontend/src/components/ChatWindow.vue @@ -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) } }