Files
MBTI_wang/miniprogram/pages/ai-chat/index.js
2026-04-27 14:36:52 +08:00

1072 lines
36 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.

// 神仙 AI · 对话页
const { request } = require('../../utils/request.js')
const analytics = require('../../utils/analytics.js')
const { buildRecoMiniProgramPath } = require('../../utils/recoMiniProgram.js')
const aiChatIntent = require('../../utils/aiChatIntent.js')
/** 功能类推荐卡「阅读数」按发布日起算时的锚点日期 */
const FEATURE_RECO_PUBLISHED_AT = '2026-01-01'
/** 底部「深度画像报告」条:用户发言超过该条数才可能展示 */
const REPORT_CTA_MIN_USER_TURNS = 10
/** 报告条仅展示一次(点过或离开带条页面后不再出现) */
const STORAGE_REPORT_CTA_DISMISSED = 'ai_chat_deep_report_cta_dismissed'
// 兜底封面base64 SVG紫色渐变 + sparkle
const DEFAULT_COVER = 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 220">' +
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">' +
'<stop offset="0%" stop-color="#a78bfa"/><stop offset="100%" stop-color="#7c3aed"/>' +
'</linearGradient></defs>' +
'<rect width="440" height="220" fill="url(%23g)"/>' +
'<text x="50%" y="55%" font-size="42" fill="white" text-anchor="middle" font-family="PingFang SC" font-weight="700">MBTI</text>' +
'<text x="50%" y="80%" font-size="20" fill="rgba(255,255,255,0.9)" text-anchor="middle">精选推荐阅读</text>' +
'</svg>'
)
/** FisherYates生成 0..n-1 的随机排列,用于多篇文章轮播时均衡曝光 */
function shuffleIndexOrder(n) {
const arr = []
for (let i = 0; i < n; i++) arr.push(i)
for (let i = n - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const t = arr[i]
arr[i] = arr[j]
arr[j] = t
}
return arr
}
Page({
data: {
articles: [],
/** 与 articles 等长的下标排列,第 k 次内嵌推荐取 articles[order[k % n]] */
articleRecoPickOrder: [],
defaultCover: DEFAULT_COVER,
/** 后台「神仙 AI 页展示」:为 true 时允许在第 2 轮及以后助手气泡内嵌精选GET /api/ai/articles/recommended */
articlesDisplayEnabled: false,
/** 后台配置的跳转小程序(有 appId 时优先 navigateToMiniProgram */
articlesRecoJump: null,
articlesLoaded: false,
mbtiType: '',
nickname: '',
quickQuestions: [],
messages: [],
conversationId: 0,
draft: '',
sending: false,
scrollTarget: '',
showReportCta: false,
myReportStatus: '',
/** 对话内精选推荐:与后台 ai_chat_articles 同源(缺省 35 轮量级) */
inlineRecoMinUserTurns: 2,
inlineRecoInterval: 3,
inlineRecoRoll: 0.5,
inlineRecoIconCount: 3,
inlineRecoIconsPool: ['✨', '💬', '📌']
},
onLoad(options) {
const { ensureRuntimeThenGate } = require('../../utils/miniprogramAuditGate.js')
ensureRuntimeThenGate(() => {
const cid = parseInt(options && options.cid, 10)
if (cid > 0) {
this.setData({ conversationId: cid })
this.loadHistory(cid)
}
this.loadArticles()
this.tryBindReferral(options)
analytics.track('page_view', { pagePath: 'pages/ai-chat/index' })
const app = getApp()
const afterRuntime = () => {
const rt = (app.globalData && app.globalData.aiChatRuntime) || {}
this.applyInlineRecoDisplay(rt)
this.loadQuickQuestions()
this.loadMyReportStatus()
}
const pullRuntime = () => {
if (app && typeof app.getRuntimeConfig === 'function') {
app.getRuntimeConfig().then(afterRuntime).catch(afterRuntime)
} else {
afterRuntime()
}
}
// 先进 ensureLogin含从 storage 回补 token再拉 runtime避免首进对话页发消息时仍无 Bearer
if (app && typeof app.ensureLogin === 'function') {
app.ensureLogin().finally(pullRuntime)
} else {
pullRuntime()
}
})
},
/** 分享链接里带 inviterId 时,绑定分销关系(复用现有分销系统) */
tryBindReferral(options) {
const inviterId = parseInt(options && options.inviterId, 10)
if (!inviterId || inviterId <= 0) return
request({
url: '/api/distribution/bind',
method: 'POST',
data: { inviterId },
success() {}, fail() {}
})
},
/** 查询我最近的 AI 报告状态(决定是否展示 CTA */
loadMyReportStatus() {
const token =
(getApp().globalData && getApp().globalData.token) || wx.getStorageSync('token') || ''
if (!token) return
const applyOk = (res) => {
const body = (res && res.data) || {}
const d = body.data || {}
this.setData({ myReportStatus: d.status || '' }, () => this._refreshReportCta())
}
const tryUrl = (url, next) => {
request({
url,
method: 'GET',
allow401: false,
success: (res) => {
const body = (res && res.data) || {}
if (res.statusCode === 200 && body.code === 200) {
applyOk(res)
return
}
if (typeof next === 'function') next()
},
fail: () => {
if (typeof next === 'function') next()
}
})
}
tryUrl('/api/ai/my-report/latest', () => tryUrl('/api/ai/report/my-latest'))
},
onShow() {
const { redirectIfMiniprogramAudit } = require('../../utils/miniprogramAuditGate.js')
if (redirectIfMiniprogramAudit()) return
try {
const tb = typeof this.getTabBar === 'function' ? this.getTabBar() : null
if (tb && typeof tb.updateSelected === 'function') {
tb.updateSelected()
} else {
const c = typeof this.selectComponent === 'function' ? this.selectComponent('#custom-tab-bar') : null
if (c && typeof c.refreshFromConfig === 'function') c.refreshFromConfig()
else if (c && typeof c.updateSelected === 'function') c.updateSelected()
}
} catch (e) {}
const app = getApp()
try {
const st = wx.getStorageSync('token')
if (st && app && app.globalData && !app.globalData.token) {
app.globalData.token = st
}
} catch (e) {}
this.loadQuickQuestions()
this.loadMyReportStatus()
this._refreshReportCta()
},
/** 离开对话页时若报告条正在展示,记为已曝光,避免反复出现 */
onHide() {
if (this.data.showReportCta) {
this._dismissReportCtaPermanently()
}
},
onInput(e) {
this.setData({ draft: e.detail.value })
},
// ---------- 数据加载 ----------
loadArticles() {
const t = Date.now()
request({
url: `/api/ai/articles/recommended?_t=${t}`,
method: 'GET',
needAuth: false,
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) return
const rawList = (body.data && body.data.list) || []
const disp = (body.data && body.data.display) || {}
const enabled = !!disp.enabled
const maxShow = Math.max(1, Math.min(3, parseInt(disp.maxShow, 10) || 1))
const list = rawList.slice(0, maxShow)
const appId = ((disp.recoJumpMiniAppId || '') + '').trim()
const recoJump =
appId && /^wx[0-9a-f]{16}$/i.test(appId)
? {
appId: appId.toLowerCase(),
path: ((disp.recoJumpMiniPath || 'pages/index/index') + '').trim().replace(/^\//, '') || 'pages/index/index',
envVersion: ((disp.recoJumpMiniEnvVersion || 'release') + '').trim().toLowerCase() || 'release'
}
: null
const order = list.length ? shuffleIndexOrder(list.length) : []
this.applyInlineRecoDisplay(disp)
this.setData({
articles: list,
articleRecoPickOrder: order,
articlesDisplayEnabled: enabled,
articlesRecoJump: recoJump,
articlesLoaded: true
})
}
})
},
/** 合并后台下发的内嵌推荐参数articles/display 或 runtime.aiChat */
applyInlineRecoDisplay(disp) {
if (!disp || typeof disp !== 'object') return
const minTurns = Math.max(1, Math.min(10, parseInt(disp.inlineRecoMinUserTurns, 10) || 2))
const interval = Math.max(2, Math.min(10, parseInt(disp.inlineRecoInterval, 10) || 3))
let roll = parseFloat(disp.inlineRecoRoll)
if (Number.isNaN(roll)) roll = 0.5
roll = Math.max(0.05, Math.min(1, roll))
const iconCount = Math.max(1, Math.min(3, parseInt(disp.inlineRecoIconCount, 10) || 3))
let pool = ['✨', '💬', '📌']
if (Array.isArray(disp.inlineRecoIcons) && disp.inlineRecoIcons.length) {
pool = disp.inlineRecoIcons.map((x) => String(x || '').trim()).filter(Boolean)
if (!pool.length) pool = ['✨', '💬', '📌']
}
this.setData({
inlineRecoMinUserTurns: minTurns,
inlineRecoInterval: interval,
inlineRecoRoll: roll,
inlineRecoIconCount: iconCount,
inlineRecoIconsPool: pool
})
},
/** 每条推荐卡角标 emoji总数不超过 3可按意图前置一个相关符号 */
_pickIconsForReco(intent) {
const max = Math.max(
1,
Math.min(3, parseInt(this.data.inlineRecoIconCount, 10) || 3)
)
const base =
(this.data.inlineRecoIconsPool && this.data.inlineRecoIconsPool.length
? this.data.inlineRecoIconsPool.map(String)
: ['✨', '💬', '📌'])
const lead =
intent === 'job' ? '💼' : intent === 'test' ? '📝' : intent === 'self' ? '💎' : ''
const out = []
if (lead && base.indexOf(lead) < 0) out.push(lead)
for (let i = 0; i < base.length && out.length < max; i++) {
if (out.indexOf(base[i]) < 0) out.push(base[i])
}
const pad = ['✨', '💬', '📌', '💼', '📝', '💎']
for (let i = 0; i < pad.length && out.length < max; i++) {
if (out.indexOf(pad[i]) < 0) out.push(pad[i])
}
return out.slice(0, max)
},
/**
* 内嵌精选:后台 13 条在拉取时打乱为 articleRecoPickOrder之后按对话轮次循环取
* 保证多条时曝光均衡、又不会连续两条总盯同一篇(比每轮纯 Math.random 更稳)。
* 返回 { inlineArticle, articleRecoPickOrder? },后者仅在补全顺序时带上,供与 messages 同次 setData。
*/
_buildInlineRecoPayload(userTurns, showInlineReco) {
const out = { inlineArticle: null }
if (!showInlineReco) return out
const list = this.data.articles || []
if (!list.length) return out
let order = this.data.articleRecoPickOrder || []
if (!Array.isArray(order) || order.length !== list.length) {
order = shuffleIndexOrder(list.length)
out.articleRecoPickOrder = order
}
const n = list.length
const recoRound = Math.max(0, (userTurns || 0) - 2)
const listIdx = order[recoRound % n]
const a = list[listIdx]
if (!a || !a.id) return out
out.inlineArticle = {
id: a.id,
title: a.title || '',
url: a.url || '',
cover: a.cover || '',
tag: a.tag || '',
publishedAt: a.publishedAt || '',
sourceId: a.sourceId || '',
readCountLabel: aiChatIntent.formatInlineReadLabel(a.publishedAt || ''),
icons: this._pickIconsForReco('')
}
return out
},
/** 与「我的」同源的功能快捷推荐(仅展示卡片,点击再跳转) */
_buildFeatureInlineReco(intent, hasMbti) {
const readCountLabel = aiChatIntent.formatInlineReadLabel(FEATURE_RECO_PUBLISHED_AT)
if (intent === 'job') {
return {
kind: 'feature',
id: 'feat_match_job',
title: '匹配工作:按性格看岗位与发展方向',
url: '',
cover: '',
tag: '匹配工作',
publishedAt: '',
sourceId: '',
navPath: '/pages/match-job/index',
readCountLabel,
icons: this._pickIconsForReco('job')
}
}
if (intent === 'test') {
return {
kind: 'feature',
id: 'feat_test_select',
title: 'MBTI 性格测评 · 进入测试列表',
url: '',
cover: '',
tag: '性格测试',
publishedAt: '',
sourceId: '',
navPath: '/pages/test-select/index',
readCountLabel,
icons: this._pickIconsForReco('test')
}
}
if (intent === 'self') {
const path = aiChatIntent.pickSelfServicePath(!!hasMbti)
const toTest = path.indexOf('test-select') >= 0
return {
kind: 'feature',
id: toTest ? 'feat_self_test' : 'feat_self_know',
title: toTest ? '性格测试 · 更系统地认识自己' : '深度了解自己 · 专属服务入口',
url: '',
cover: '',
tag: toTest ? '性格测试' : '了解自己',
publishedAt: '',
sourceId: '',
navPath: path,
readCountLabel,
icons: this._pickIconsForReco('self')
}
}
return null
},
/** 去测 MBTI → 详细性格测试列表 */
onGoMbtiTest() {
try {
analytics.track('ai_chat_go_test_select', {})
} catch (e) {}
wx.navigateTo({
url: '/pages/test-select/index',
fail: () => {
wx.showToast({ title: '暂时无法打开测试列表', icon: 'none' })
}
})
},
loadQuickQuestions() {
const apply = (body) => {
if (!body || body.code !== 200) return false
const d = body.data || {}
this.setData({
mbtiType: d.mbtiType || '',
nickname: d.nickname || '',
quickQuestions: this.buildQuickQuestions(d.questions || [])
})
return true
}
const applyDirect = (d) => {
if (!d || !Array.isArray(d.questions) || d.questions.length === 0) return false
this.setData({
mbtiType: d.mbtiType || '',
nickname: d.nickname || '',
quickQuestions: this.buildQuickQuestions(d.questions)
})
return true
}
const fallbackLocal = () => {
this.setData({ quickQuestions: this.buildQuickQuestions([]) })
}
const tryLegacy = () => {
request({
url: '/api/ai/quick-questions',
method: 'GET',
needAuth: false,
optionalAuth: true,
allow401: false,
success: (res2) => {
if (!apply((res2 && res2.data) || {})) fallbackLocal()
},
fail: fallbackLocal
})
}
const tryConfigUrl = (path, next) => {
request({
url: path,
method: 'GET',
needAuth: false,
optionalAuth: true,
allow401: false,
success: (res) => {
const body = (res && res.data) || {}
if (apply(body)) return
next()
},
fail: next
})
}
// 线上网关常只放行 /api/config/runtime快捷问句已内嵌其中
try {
const run = getApp().globalData && getApp().globalData.runtimeAiQuickQuestions
if (applyDirect(run)) return
} catch (e) {}
// 先试短路径 config/quick-questions再试 ai-quick-questions最后 /api/ai/quick-questions
tryConfigUrl('/api/config/quick-questions', () => {
tryConfigUrl('/api/config/ai-quick-questions', tryLegacy)
})
},
buildQuickQuestions(serverList) {
const preferred = [
'帮我了解并分析一下我现在的状态',
'我适合什么样的伴侣关系?',
'我应该找什么样的工作?',
'我下一步的职业发展方向是什么?',
'我的性格优势该怎么变现?',
'我现在最该改掉的一个习惯是什么?',
'我更适合独立做事还是团队协作?',
'给我一句今天就能做的小行动建议'
]
const all = []
;(Array.isArray(serverList) ? serverList : []).forEach((q) => {
if (typeof q === 'string' && q.trim()) all.push(q.trim())
})
preferred.forEach((q) => all.push(q))
const uniq = []
const seen = {}
all.forEach((q) => {
if (!seen[q]) {
seen[q] = true
uniq.push(q)
}
})
return uniq.slice(0, 8)
},
loadHistory(cid) {
const token =
(getApp().globalData && getApp().globalData.token) || wx.getStorageSync('token') || ''
if (!token) return
request({
url: `/api/ai/conversations/${cid}/messages`,
method: 'GET',
allow401: false,
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) return
const d = body.data || {}
const msgs = (d.messages || []).filter(m => m.role !== 'system')
this.setData({ messages: msgs }, () => {
this.scrollToBottom()
this._refreshReportCta()
})
}
})
},
// ---------- 交互 ----------
onTapQuick(e) {
const q = (e.currentTarget.dataset.q || '').trim()
if (!q || this.data.sending) return
analytics.track('ai_quick_question_click', { question: q })
this.setData({ draft: q }, () => this.onSend())
},
_openArticleWebview(url) {
const enc = encodeURIComponent(url)
wx.navigateTo({
url: `/pages/webview/index?url=${enc}`,
fail: () => {
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: '已复制链接', icon: 'none' })
})
}
})
},
onTapArticle(e) {
const ds = e.currentTarget.dataset || {}
const navPath = String(ds.navpath || ds.navPath || '').trim()
if (navPath) {
try {
analytics.track('ai_chat_feature_reco_tap', { path: navPath })
} catch (err) {}
const urlPath = navPath.startsWith('/') ? navPath : '/' + navPath
wx.navigateTo({
url: urlPath,
fail: () => wx.showToast({ title: '暂时无法打开该功能', icon: 'none' })
})
return
}
const id = ds.id
const url = ds.url
const title = ds.title
const sourceId = ds.sourceId
analytics.track('tap_ai_article', { articleId: id, url, title, sourceId })
// 轻量回传点击(异步);功能卡 id 非数字,跳过
if (id && String(id).indexOf('feat_') !== 0) {
request({
url: `/api/ai/articles/${id}/click`,
method: 'POST',
needAuth: false,
success() {}, fail() {}
})
}
const jump = this.data.articlesRecoJump
if (jump && jump.appId) {
const path = buildRecoMiniProgramPath(jump.path, {
id,
title,
sourceId,
fromTag: 'mbti_ai_reco'
})
const env = jump.envVersion === 'trial' || jump.envVersion === 'develop' ? jump.envVersion : 'release'
wx.navigateToMiniProgram({
appId: jump.appId,
path,
envVersion: env,
fail: (err) => {
if (url) {
this._openArticleWebview(url)
} else {
wx.showToast({
title: (err && err.errMsg) || '无法打开目标小程序',
icon: 'none'
})
}
}
})
return
}
if (!url) return
this._openArticleWebview(url)
},
/**
* @param {{ bubbleText: string, apiMessage: string, intentText: string, rollbackDraft: string, resumeFileUrl?: string, resumeFileName?: string }} ctx
*/
_beginChatOutgoing(ctx) {
const bubbleText = (ctx.bubbleText || '').trim()
const apiMessage = ctx.apiMessage != null ? String(ctx.apiMessage) : ''
const intentText = (ctx.intentText || bubbleText || '').trim()
const rollbackDraft = ctx.rollbackDraft != null ? String(ctx.rollbackDraft) : ''
const resumeFileUrl = (ctx.resumeFileUrl || '').trim()
const resumeFileName = (ctx.resumeFileName || '').trim()
if (this.data.sending) return
if (!bubbleText && !resumeFileUrl) return
const prevMessages = this.data.messages.slice()
const now = Date.now()
const userMsg = {
id: 'u_' + now,
role: 'user',
content: bubbleText || '📎 已上传简历,请神仙 AI 分析',
createdAt: Math.floor(now / 1000)
}
const messagesWithUser = this.data.messages.concat([userMsg])
const clearDraft = !resumeFileUrl
this.setData(
{
messages: messagesWithUser,
draft: clearDraft ? '' : this.data.draft,
sending: true
},
() => {
this.setData({ scrollTarget: 'msg-typing' })
}
)
try {
analytics.track('ai_chat_send', {
length: (apiMessage || bubbleText).length,
resume: resumeFileUrl ? 1 : 0
})
} catch (e) {}
const stripPending = (list) => (list || []).filter((m) => !m.isPending)
const applyAssistantPayload = (d) => {
clearSendWatchdog()
const aiMsg = (d && d.message) || {}
const base = stripPending(this.data.messages)
const userTurns = base.filter((m) => m.role === 'user').length
const assistantText = (aiMsg.content || '').trim()
const isDegraded = !!aiMsg.isDegraded
const minT = Math.max(1, Math.min(10, parseInt(this.data.inlineRecoMinUserTurns, 10) || 2))
const recoN = Math.max(2, Math.min(10, parseInt(this.data.inlineRecoInterval, 10) || 3))
let recoRoll = parseFloat(this.data.inlineRecoRoll)
if (Number.isNaN(recoRoll)) recoRoll = 0.5
recoRoll = Math.max(0.05, Math.min(1, recoRoll))
const gateSlot =
userTurns >= minT &&
(userTurns - minT) % recoN === 0 &&
Math.random() < recoRoll
let inlineArticle = null
let recoPackExtra = null
if (gateSlot && !isDegraded) {
const hasMbti = !!(this.data.mbtiType && String(this.data.mbtiType).trim())
const featIntent = aiChatIntent.resolveChatFeatureIntent(intentText, hasMbti)
if (featIntent && aiChatIntent.replyMatchesFeatureIntent(featIntent, assistantText)) {
inlineArticle = this._buildFeatureInlineReco(featIntent, hasMbti)
} else if (
!featIntent &&
this.data.articlesDisplayEnabled &&
(this.data.articles || []).length > 0
) {
recoPackExtra = this._buildInlineRecoPayload(userTurns, true)
inlineArticle = recoPackExtra.inlineArticle
}
}
const next = base.concat([
{
id: aiMsg.id || 'a_' + Date.now(),
role: 'assistant',
content: aiMsg.content || '',
isDegraded: !!aiMsg.isDegraded,
providerId: aiMsg.providerId || '',
createdAt: aiMsg.createdAt || Math.floor(Date.now() / 1000),
inlineArticle
}
])
const setPayload = {
messages: next,
conversationId: (d && d.conversationId) || this.data.conversationId,
sending: false
}
if (recoPackExtra && recoPackExtra.articleRecoPickOrder) {
setPayload.articleRecoPickOrder = recoPackExtra.articleRecoPickOrder
}
this.setData(
setPayload,
() => {
this.scrollToBottom()
this._refreshReportCta()
}
)
analytics.track('ai_chat_receive', {
providerId: aiMsg.providerId || '',
length: (aiMsg.content || '').length,
inlineReco: inlineArticle ? 1 : 0
})
if (inlineArticle) {
try {
analytics.track('ai_chat_inline_reco_show', {
articleId: inlineArticle.id,
kind: inlineArticle.kind || 'article',
navPath: inlineArticle.navPath || ''
})
} catch (e) {}
}
if (aiMsg.isDegraded) {
analytics.track('ai_chat_degrade', { providerId: aiMsg.providerId || '' })
wx.showToast({
title: '当前为备用说明,非模型完整回复',
icon: 'none',
duration: 2800
})
}
}
let sendWatchdog = null
const clearSendWatchdog = () => {
if (sendWatchdog) {
clearTimeout(sendWatchdog)
sendWatchdog = null
}
}
const failSend = (toastTitle) => {
clearSendWatchdog()
if (toastTitle) {
wx.showToast({ title: toastTitle, icon: 'none', duration: 2800 })
}
this.setData({
messages: prevMessages,
draft: rollbackDraft,
sending: false
})
}
const pollChatJob = (jobId) => {
let attempts = 0
const maxAttempts = 150
const delayMs = 1200
let netFailStreak = 0
const tick = () => {
if (attempts++ >= maxAttempts) {
failSend('等待回复超时,请稍后在历史记录中查看或重发')
return
}
request({
url: '/api/ai/chat/job?jobId=' + encodeURIComponent(jobId),
method: 'GET',
timeout: 60000,
success: (res) => {
const sc = res && res.statusCode
if (sc && (sc < 200 || sc >= 300)) {
netFailStreak++
if (netFailStreak <= 5) {
setTimeout(tick, 500 * netFailStreak)
} else {
failSend('网络异常,稍后再试')
}
return
}
netFailStreak = 0
const raw = res && res.data
const body = raw && typeof raw === 'object' ? raw : {}
const c = body.code
const isBizOk = c === 200 || c === '200' || Number(c) === 200
if (!isBizOk) {
const msg =
(body.message && String(body.message).trim()) || '获取回复失败'
const codeNum = Number(body.code)
const maybeRace =
(codeNum === 404 || /任务不存在|已过期/i.test(String(msg))) &&
attempts <= 15
if (maybeRace) {
setTimeout(tick, 400)
return
}
failSend(msg.length > 40 ? msg.slice(0, 39) + '…' : msg)
return
}
const d = body.data || {}
if (d.pending) {
setTimeout(tick, delayMs)
return
}
applyAssistantPayload(d)
},
fail: () => {
netFailStreak++
if (netFailStreak <= 5) {
setTimeout(tick, 600 * netFailStreak)
} else {
failSend('网络异常,稍后再试')
}
}
})
}
tick()
}
const postChat = (isTimeoutRetry) => {
request({
url: '/api/ai/chat',
method: 'POST',
// 微信客户端对 wx.request 超时上限多为 60s首包应极快后端已改为轻量上下文
timeout: 60000,
data: {
conversationId: this.data.conversationId || 0,
message: apiMessage,
resumeFileUrl: resumeFileUrl || undefined,
resumeFileName: resumeFileName || undefined
},
success: (res) => {
const raw = res && res.data
const body = raw && typeof raw === 'object' ? raw : {}
const c = body.code
const isBizOk = c === 200 || c === '200' || Number(c) === 200
if (!isBizOk) {
const bizCode = Number(body.code)
let msg = (body.message && String(body.message).trim()) || '发送失败,请稍后再试'
if (bizCode === 401 || /未登录|Token无效|token无效|请先登录/i.test(String(msg))) {
msg = '需要先微信登录才能对话,请到「我的」授权后再试'
} else {
const legacyDaily =
bizCode === 429 ||
/今日对话次数已用完|今日对话次数|明天再来找我|次数已用完.*明天|对话次数已用完/i.test(
String(msg)
)
if (legacyDaily) {
const rt = (getApp().globalData && getApp().globalData.aiChatRuntime) || {}
const runtimeNew = rt.unlimited === true && Number(rt.dailyMessageLimit) === 0
msg = runtimeNew
? '对话接口仍是旧版,请在服务器部署最新 api/AiChat.php'
: '对话功能需更新后端;请部署最新代码并确认超管已启用 AI 服务商'
}
}
if (msg.length > 36) msg = msg.slice(0, 35) + '…'
failSend(msg)
return
}
const d = body.data || {}
if (d.async && d.jobId) {
const cid = d.conversationId || this.data.conversationId || 0
if (cid > 0 && this.data.conversationId !== cid) {
this.setData({ conversationId: cid })
}
pollChatJob(String(d.jobId))
return
}
// 兼容旧版同步响应(无 async
applyAssistantPayload(d)
},
fail: (err) => {
const em = (err && err.errMsg) ? String(err.errMsg) : ''
const isTimeout = /timeout|超时/i.test(em)
if (isTimeout && !isTimeoutRetry) {
postChat(true)
return
}
failSend(
isTimeout ? '提交对话超时,请检查网络与合法域名后重试' : '网络异常,稍后再试'
)
}
})
}
const app = getApp()
let hasToken = !!(app.globalData && app.globalData.token)
try {
const st = wx.getStorageSync('token')
if (st && app && app.globalData && !app.globalData.token) {
app.globalData.token = st
}
hasToken = !!(app.globalData && app.globalData.token) || !!st
} catch (e) {
hasToken = !!(app.globalData && app.globalData.token)
}
sendWatchdog = setTimeout(() => {
sendWatchdog = null
if (!this.data.sending) return
failSend('对话请求过久,请检查网络、关闭代理或到开发者工具「不校验合法域名」后重试')
}, 125000)
if (hasToken) {
postChat(false)
return
}
app
.ensureLogin()
.then((ok) => {
if (ok) {
postChat(false)
return
}
clearSendWatchdog()
this.setData({ messages: prevMessages, draft: rollbackDraft, sending: false })
wx.showModal({
title: '需要登录',
content: '神仙 AI 对话需要先完成微信登录,是否前往「我的」?',
confirmText: '去登录',
cancelText: '取消',
success: (r) => {
if (r.confirm) wx.switchTab({ url: '/pages/profile/index' })
}
})
})
.catch(() => {
clearSendWatchdog()
this.setData({ messages: prevMessages, draft: rollbackDraft, sending: false })
wx.showToast({ title: '登录失败,请稍后再试', icon: 'none' })
})
},
onSend() {
const content = (this.data.draft || '').trim()
if (!content || this.data.sending) return
this._beginChatOutgoing({
bubbleText: content,
apiMessage: content,
intentText: content,
rollbackDraft: content
})
},
scrollToBottom() {
if (this.data.sending) {
this.setData({ scrollTarget: 'msg-typing' })
return
}
const msgs = this.data.messages
const last = msgs.length ? msgs[msgs.length - 1] : null
this.setData({ scrollTarget: last ? ('msg-' + last.id) : 'bottom' })
},
/** 对话内上传简历:直传 OSS 后走 /api/ai/chat由神仙 AI 结合测评与正文回复(不跳转匹配工作页) */
onTapUploadResume() {
if (this.data.sending) return
try {
analytics.track('ai_chat_resume_entry_tap', {})
} catch (e) {}
const app = getApp()
const goChoose = () => {
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['pdf', 'doc', 'docx'],
success: (fileRes) => {
const files = fileRes.tempFiles || []
if (!files.length) return
const file = files[0]
const filePath = file.path || file.tempFilePath
const fileName = file.name || '简历.pdf'
this._uploadResumeAndChat(filePath, fileName)
},
fail: (err) => {
const msg = (err && err.errMsg) || ''
if (!/cancel/i.test(msg)) {
wx.showToast({ title: '请选择 pdf/doc/docx 简历', icon: 'none' })
}
}
})
}
if (app && typeof app.ensureLogin === 'function') {
app.ensureLogin().then((ok) => {
if (ok) goChoose()
else wx.showToast({ title: '请先登录后再上传简历', icon: 'none' })
})
return
}
goChoose()
},
_uploadResumeAndChat(filePath, fileName) {
const app = getApp()
wx.showLoading({ title: '上传简历…', mask: true })
const apiBase =
app.globalData && app.globalData.apiBase ? app.globalData.apiBase.replace(/\/$/, '') : ''
const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || ''
wx.uploadFile({
url: apiBase + '/api/upload/file',
filePath,
name: 'file',
header: token ? { Authorization: 'Bearer ' + token } : {},
success: (res) => {
wx.hideLoading()
let url = ''
try {
const data = JSON.parse(res.data || '{}')
if (data.code === 200 && data.data && data.data.url) url = data.data.url
} catch (e) {}
if (!url) {
wx.showToast({ title: '上传失败,请重试', icon: 'none' })
return
}
request({
url: '/api/enterprise/resume-uploads',
method: 'POST',
needAuth: true,
data: { url, fileName },
success: () => {},
fail: () => {}
})
const bubble =
'📎 已上传「' +
fileName +
'」,请结合我的性格测评,直接在这份对话里帮我分析简历与职业匹配。'
this._beginChatOutgoing({
bubbleText: bubble,
apiMessage: '',
intentText: bubble,
rollbackDraft: '',
resumeFileUrl: url,
resumeFileName: fileName
})
},
fail: () => {
wx.hideLoading()
wx.showToast({ title: '上传失败', icon: 'none' })
}
})
},
/** 点击"邀请赚佣金"快捷方式:触发原生分享 */
onTapShare() {
analytics.track('ai_chat_share_invite_tap', {})
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
wx.showToast({ title: '点右上角 ··· 分享给好友', icon: 'none' })
},
/** 点击"生成深度报告 CTA" */
onTapReportCta() {
analytics.track('ai_report_cta_tap', { messageCount: this.data.messages.length })
const cid = this.data.conversationId || 0
const go = () => {
wx.navigateTo({
url: `/pages/ai-chat/report?cid=${cid}`,
success: () => this._dismissReportCtaPermanently(),
fail: () => {
wx.showToast({ title: '报告页暂时无法打开', icon: 'none' })
}
})
}
const app = getApp()
if (app && typeof app.ensureLogin === 'function') {
app.ensureLogin().then((ok) => {
if (ok) go()
else wx.showToast({ title: '请先登录后再购买报告', icon: 'none' })
})
return
}
go()
},
_dismissReportCtaPermanently() {
try {
wx.setStorageSync(STORAGE_REPORT_CTA_DISMISSED, 1)
} catch (e) {}
if (this.data.showReportCta) {
this.setData({ showReportCta: false })
}
},
/** 根据消息数刷新 CTA用户发言 >10、未买过、且未点过/未离开曝光过 */
_refreshReportCta() {
let dismissed = false
try {
dismissed = !!wx.getStorageSync(STORAGE_REPORT_CTA_DISMISSED)
} catch (e) {}
const userTurns = (this.data.messages || []).filter((m) => m.role === 'user').length
const st = this.data.myReportStatus || ''
const paidBlock = st === 'done' || st === 'paid'
const show =
userTurns > REPORT_CTA_MIN_USER_TURNS && !paidBlock && !dismissed
if (show !== this.data.showReportCta) {
this.setData({ showReportCta: show })
}
},
onShareAppMessage() {
const app = getApp() || {}
const gd = app.globalData || {}
const inviterId = (gd.userInfo && (gd.userInfo.id || gd.userInfo.user_id)) || 0
const mbti = this.data.mbtiType || ''
const title = mbti
? `神仙 AI 帮我看懂了 ${mbti},你也来试试?`
: '和神仙 AI 聊聊你的 MBTI超准'
const path = `/pages/ai-chat/index?inviterId=${inviterId}&src=ai_chat`
analytics.track('ai_chat_share', { mbti })
return { title, path }
},
onShareTimeline() {
return {
title: '神仙 AI · 帮你看懂自己的 MBTI',
query: 'src=ai_chat_timeline'
}
}
})