feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs
This commit is contained in:
@@ -1,8 +1,23 @@
|
||||
// pages/chat/chat.ts — AI 对话页
|
||||
import { mockChatMessages } from '../../utils/mock-data'
|
||||
import type { ChatMessage } from '../../utils/mock-data'
|
||||
import { simulateStreamOutput } from '../../utils/chat'
|
||||
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由,替换 mock 为真实 API
|
||||
// CHANGE 2026-03-20 | RNS1.4 T8.2: 替换 simulateStreamOutput 为真实 SSE 连接
|
||||
import { fetchChatMessages, fetchChatMessagesByContext } from '../../services/api'
|
||||
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
|
||||
import { API_BASE } from '../../utils/config'
|
||||
|
||||
/** API 返回的消息项类型 */
|
||||
interface ApiMessageItem {
|
||||
id: number
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
createdAt: string
|
||||
referenceCard?: {
|
||||
type: 'customer' | 'record'
|
||||
title: string
|
||||
summary: string
|
||||
data: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 referenceCard.data (Record<string,string>) 转为数组供 WXML wx:for 渲染 */
|
||||
function toDataList(data?: Record<string, string>): Array<{ key: string; value: string }> {
|
||||
@@ -11,14 +26,17 @@ function toDataList(data?: Record<string, string>): Array<{ key: string; value:
|
||||
}
|
||||
|
||||
/** 为消息列表补充展示字段(时间分割线、IM时间、引用卡片)*/
|
||||
function enrichMessages(msgs: ChatMessage[]) {
|
||||
function enrichMessages(msgs: ApiMessageItem[]) {
|
||||
return msgs.map((m, i) => ({
|
||||
...m,
|
||||
timeLabel: formatRelativeTime(m.timestamp),
|
||||
imTimeLabel: formatIMTime(m.timestamp),
|
||||
id: String(m.id),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.createdAt,
|
||||
timeLabel: formatRelativeTime(m.createdAt),
|
||||
imTimeLabel: formatIMTime(m.createdAt),
|
||||
showTimeDivider: shouldShowTimeDivider(
|
||||
i === 0 ? null : msgs[i - 1].timestamp,
|
||||
m.timestamp,
|
||||
i === 0 ? null : msgs[i - 1].createdAt,
|
||||
m.createdAt,
|
||||
),
|
||||
referenceCard: m.referenceCard
|
||||
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
|
||||
@@ -26,12 +44,114 @@ function enrichMessages(msgs: ChatMessage[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
/** Mock AI 回复模板 */
|
||||
const mockAIReplies = [
|
||||
'根据数据分析,这位客户近期消费频次有所下降,建议安排一次主动回访,了解具体原因。',
|
||||
'好的,我已经记录了你的需求。下次回访时我会提醒你。',
|
||||
'这位客户偏好中式台球,最近对斯诺克也产生了兴趣,可以推荐相关课程。',
|
||||
]
|
||||
/** 从 Storage 获取 access_token(与 request.ts 保持一致) */
|
||||
function getAccessToken(): string | undefined {
|
||||
const app = getApp<IAppOption>()
|
||||
return app.globalData.token || wx.getStorageSync('token') || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 行缓冲解析器
|
||||
* 微信 onChunkReceived 返回的 ArrayBuffer 可能跨事件边界切割,
|
||||
* 需要缓冲不完整的行,只处理完整的 SSE 事件。
|
||||
*/
|
||||
class SSEParser {
|
||||
private buffer = ''
|
||||
private currentEvent = ''
|
||||
private currentData = ''
|
||||
|
||||
constructor(
|
||||
private onMessage: (token: string) => void,
|
||||
private onDone: (messageId: number, createdAt: string) => void,
|
||||
private onError: (message: string) => void,
|
||||
) {}
|
||||
|
||||
/** 将 ArrayBuffer 解码为 UTF-8 字符串(兼容微信环境) */
|
||||
private decodeChunk(chunk: ArrayBuffer): string {
|
||||
// 微信基础库支持 TextDecoder(2.19.2+)
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
return new TextDecoder('utf-8').decode(chunk)
|
||||
}
|
||||
// 降级:手动解码 UTF-8 字节
|
||||
const bytes = new Uint8Array(chunk)
|
||||
let str = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
str += String.fromCharCode(bytes[i])
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(escape(str))
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
/** 接收一个 chunk 并解析完整的 SSE 事件 */
|
||||
feed(chunk: ArrayBuffer): void {
|
||||
this.buffer += this.decodeChunk(chunk)
|
||||
const lines = this.buffer.split('\n')
|
||||
// 最后一个元素可能是不完整的行,保留在 buffer 中
|
||||
this.buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.replace(/\r$/, '')
|
||||
if (trimmed === '') {
|
||||
// 空行 = 事件分隔符,派发当前事件
|
||||
this.dispatch()
|
||||
} else if (trimmed.startsWith('event:')) {
|
||||
this.currentEvent = trimmed.slice(6).trim()
|
||||
} else if (trimmed.startsWith('data:')) {
|
||||
this.currentData = trimmed.slice(5).trim()
|
||||
}
|
||||
// 忽略其他行(如 id:、retry:、注释等)
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(): void {
|
||||
if (!this.currentEvent || !this.currentData) {
|
||||
this.currentEvent = ''
|
||||
this.currentData = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(this.currentData)
|
||||
switch (this.currentEvent) {
|
||||
case 'message':
|
||||
if (data.token != null) this.onMessage(data.token)
|
||||
break
|
||||
case 'done':
|
||||
this.onDone(data.messageId, data.createdAt)
|
||||
break
|
||||
case 'error':
|
||||
this.onError(data.message || 'AI 服务暂时不可用')
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,忽略此事件
|
||||
}
|
||||
|
||||
this.currentEvent = ''
|
||||
this.currentData = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 展示用消息类型 */
|
||||
type DisplayMessage = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
timeLabel?: string
|
||||
imTimeLabel?: string
|
||||
showTimeDivider?: boolean
|
||||
referenceCard?: {
|
||||
type: string
|
||||
title: string
|
||||
summary: string
|
||||
data: Record<string, string>
|
||||
dataList?: Array<{ key: string; value: string }>
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -40,12 +160,7 @@ Page({
|
||||
/** 状态栏高度 */
|
||||
statusBarHeight: 0,
|
||||
/** 消息列表 */
|
||||
messages: [] as Array<ChatMessage & {
|
||||
timeLabel?: string
|
||||
imTimeLabel?: string
|
||||
showTimeDivider?: boolean
|
||||
referenceCard?: ChatMessage['referenceCard'] & { dataList?: Array<{ key: string; value: string }> }
|
||||
}>,
|
||||
messages: [] as DisplayMessage[],
|
||||
/** 输入框内容 */
|
||||
inputText: '',
|
||||
/** AI 正在流式回复 */
|
||||
@@ -56,7 +171,9 @@ Page({
|
||||
scrollToId: '',
|
||||
/** 页面顶部引用卡片(从其他页面跳转时) */
|
||||
referenceCard: null as { title: string; summary: string } | null,
|
||||
/** 客户 ID */
|
||||
/** 当前对话 ID(后端返回,用于发送消息) */
|
||||
chatId: '',
|
||||
/** 客户 ID(兼容旧逻辑) */
|
||||
customerId: '',
|
||||
/** 输入栏距底部距离(软键盘弹出时动态调整)*/
|
||||
inputBarBottom: 0,
|
||||
@@ -65,41 +182,80 @@ Page({
|
||||
/** 消息计数器,用于生成唯一 ID */
|
||||
_msgCounter: 0,
|
||||
|
||||
/** 当前 SSE 请求任务(用于中断/重试) */
|
||||
_sseTask: null as WechatMiniprogram.RequestTask | null,
|
||||
|
||||
/** 最后一次发送的用户消息内容(用于重试) */
|
||||
_lastUserContent: '',
|
||||
|
||||
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由
|
||||
// 优先级:historyId → taskId → customerId → coachId → general
|
||||
onLoad(options) {
|
||||
const sysInfo = wx.getWindowInfo()
|
||||
const customerId = options?.customerId || ''
|
||||
this.setData({
|
||||
customerId,
|
||||
statusBarHeight: sysInfo.statusBarHeight || 44,
|
||||
})
|
||||
this.loadMessages(customerId)
|
||||
|
||||
if (options?.historyId) {
|
||||
// 从 chat-history 跳转:直接用 historyId 作为 chatId 加载历史消息
|
||||
this.setData({ chatId: options.historyId })
|
||||
this.loadMessages(options.historyId)
|
||||
} else if (options?.taskId) {
|
||||
// 从 task-detail 跳转:同一 taskId 始终复用同一对话(无时限)
|
||||
this.loadMessagesByContext('task', options.taskId)
|
||||
} else if (options?.customerId) {
|
||||
// 从 customer-detail 跳转:≤ 3 天复用、> 3 天新建
|
||||
this.setData({ customerId: options.customerId })
|
||||
this.loadMessagesByContext('customer', options.customerId)
|
||||
} else if (options?.coachId) {
|
||||
// 从 coach-detail 跳转:≤ 3 天复用、> 3 天新建
|
||||
this.loadMessagesByContext('coach', options.coachId)
|
||||
} else {
|
||||
// 无参数入口:始终新建通用对话
|
||||
this.loadMessagesByContext('general', '')
|
||||
}
|
||||
},
|
||||
|
||||
/** 加载消息(Mock) */
|
||||
loadMessages(customerId: string) {
|
||||
/** 通过 chatId 加载历史消息(historyId 入口) */
|
||||
async loadMessages(chatId: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
try {
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用
|
||||
const messages = enrichMessages(mockChatMessages)
|
||||
this._msgCounter = messages.length
|
||||
const res = await fetchChatMessages(chatId)
|
||||
this.setData({ chatId: String(res.chatId) })
|
||||
const messages = enrichMessages(res.items as ApiMessageItem[])
|
||||
this._msgCounter = messages.length
|
||||
this.setData({
|
||||
pageState: messages.length === 0 ? 'empty' : 'normal',
|
||||
messages,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
// 如果携带 customerId,显示引用卡片
|
||||
const referenceCard = customerId
|
||||
? { title: '客户详情', summary: `正在查看客户 ${customerId} 的相关信息` }
|
||||
: null
|
||||
/** 通过上下文类型加载消息(task/customer/coach/general 入口) */
|
||||
async loadMessagesByContext(contextType: string, contextId: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
try {
|
||||
const res = await fetchChatMessagesByContext(contextType, contextId)
|
||||
// 缓存后端返回的 chatId,后续发送消息使用
|
||||
this.setData({ chatId: String(res.chatId) })
|
||||
const messages = enrichMessages(res.items as ApiMessageItem[])
|
||||
this._msgCounter = messages.length
|
||||
|
||||
const isEmpty = messages.length === 0 && !referenceCard
|
||||
// customerId 入口时显示引用卡片
|
||||
const referenceCard = contextType === 'customer' && contextId
|
||||
? { title: '客户详情', summary: `正在查看客户 ${contextId} 的相关信息` }
|
||||
: null
|
||||
|
||||
this.setData({
|
||||
pageState: isEmpty ? 'empty' : 'normal',
|
||||
messages,
|
||||
referenceCard,
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
}, 500)
|
||||
const isEmpty = messages.length === 0 && !referenceCard
|
||||
this.setData({
|
||||
pageState: isEmpty ? 'empty' : 'normal',
|
||||
messages,
|
||||
referenceCard,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
@@ -107,12 +263,29 @@ Page({
|
||||
|
||||
/** 返回上一页 */
|
||||
onBack() {
|
||||
// 中断进行中的 SSE 连接
|
||||
if (this._sseTask) {
|
||||
this._sseTask.abort()
|
||||
this._sseTask = null
|
||||
}
|
||||
wx.navigateBack()
|
||||
},
|
||||
|
||||
/** 页面卸载时清理 SSE 连接 */
|
||||
onUnload() {
|
||||
if (this._sseTask) {
|
||||
this._sseTask.abort()
|
||||
this._sseTask = null
|
||||
}
|
||||
},
|
||||
|
||||
/** 重试加载 */
|
||||
onRetry() {
|
||||
this.loadMessages(this.data.customerId)
|
||||
if (this.data.chatId) {
|
||||
this.loadMessages(this.data.chatId)
|
||||
} else {
|
||||
this.loadMessagesByContext('general', '')
|
||||
}
|
||||
},
|
||||
|
||||
/** 软键盘弹出:输入栏上移至键盘顶部 */
|
||||
@@ -141,9 +314,9 @@ Page({
|
||||
const now = new Date().toISOString()
|
||||
const prevMsgs = this.data.messages
|
||||
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
|
||||
const userMsg = {
|
||||
const userMsg: DisplayMessage = {
|
||||
id: `msg-user-${this._msgCounter}`,
|
||||
role: 'user' as const,
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: now,
|
||||
timeLabel: '刚刚',
|
||||
@@ -160,23 +333,30 @@ Page({
|
||||
|
||||
this.scrollToBottom()
|
||||
|
||||
// 保存用户消息内容,用于 SSE 发送和重试
|
||||
this._lastUserContent = text
|
||||
|
||||
// CHANGE 2026-03-20 | T8.2: 使用真实 SSE 替代 mock
|
||||
setTimeout(() => {
|
||||
this.triggerAIReply()
|
||||
}, 300)
|
||||
},
|
||||
|
||||
/** 触发 AI 流式回复 */
|
||||
// CHANGE 2026-03-20 | T8.2: 真实 SSE 流式连接替代 mock
|
||||
/** 触发 AI 流式回复(通过 SSE 连接后端 /api/xcx/chat/stream) */
|
||||
triggerAIReply() {
|
||||
const chatId = this.data.chatId
|
||||
const content = this._lastUserContent
|
||||
if (!chatId || !content) return
|
||||
|
||||
this._msgCounter++
|
||||
const aiMsgId = `msg-ai-${this._msgCounter}`
|
||||
const replyText = mockAIReplies[this._msgCounter % mockAIReplies.length]
|
||||
|
||||
const aiNow = new Date().toISOString()
|
||||
const prevMsgs = this.data.messages
|
||||
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
|
||||
const aiMsg = {
|
||||
const aiMsg: DisplayMessage = {
|
||||
id: aiMsgId,
|
||||
role: 'assistant' as const,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: aiNow,
|
||||
timeLabel: '刚刚',
|
||||
@@ -190,23 +370,97 @@ Page({
|
||||
isStreaming: true,
|
||||
streamingContent: '',
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
|
||||
const aiIndex = messages.length - 1
|
||||
simulateStreamOutput(replyText, (partial: string) => {
|
||||
const key = `messages[${aiIndex}].content`
|
||||
this.setData({
|
||||
[key]: partial,
|
||||
streamingContent: partial,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
}).then(() => {
|
||||
this.setData({
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
let fullContent = ''
|
||||
|
||||
const parser = new SSEParser(
|
||||
// onMessage: 逐 token 追加
|
||||
(token: string) => {
|
||||
fullContent += token
|
||||
const key = `messages[${aiIndex}].content`
|
||||
this.setData({
|
||||
[key]: fullContent,
|
||||
streamingContent: fullContent,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
},
|
||||
// onDone: 流结束,更新消息 ID 和时间
|
||||
(messageId: number, createdAt: string) => {
|
||||
this.setData({
|
||||
[`messages[${aiIndex}].id`]: String(messageId),
|
||||
[`messages[${aiIndex}].timestamp`]: createdAt,
|
||||
[`messages[${aiIndex}].timeLabel`]: formatRelativeTime(createdAt),
|
||||
[`messages[${aiIndex}].imTimeLabel`]: formatIMTime(createdAt),
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
this._sseTask = null
|
||||
},
|
||||
// onError: 显示错误
|
||||
(message: string) => {
|
||||
// 将错误信息作为 AI 回复内容展示
|
||||
const errorContent = fullContent || message
|
||||
this.setData({
|
||||
[`messages[${aiIndex}].content`]: errorContent,
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
this._sseTask = null
|
||||
wx.showToast({ title: message, icon: 'none', duration: 3000 })
|
||||
},
|
||||
)
|
||||
|
||||
// 构建认证 header
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 使用 wx.request + enableChunked 接收 SSE
|
||||
// enableChunked 和 onChunkReceived 在基础库 2.20.2+ 可用,
|
||||
// 当前 typings 未包含,使用 as any 绕过类型检查
|
||||
const requestTask = wx.request({
|
||||
url: `${API_BASE}/api/xcx/chat/stream`,
|
||||
method: 'POST',
|
||||
data: { chatId: Number(chatId), content },
|
||||
header: headers,
|
||||
enableChunked: true,
|
||||
responseType: 'arraybuffer',
|
||||
success: () => {
|
||||
// enableChunked 模式下 success 在连接关闭时触发
|
||||
// 如果此时仍在 streaming 状态,说明连接正常关闭但未收到 done 事件
|
||||
if (this.data.isStreaming) {
|
||||
this.setData({ isStreaming: false, streamingContent: '' })
|
||||
this._sseTask = null
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 网络错误或连接中断
|
||||
if (this.data.isStreaming) {
|
||||
const errorContent = fullContent || '连接中断,请重试'
|
||||
this.setData({
|
||||
[`messages[${aiIndex}].content`]: errorContent,
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
wx.showToast({ title: '连接中断', icon: 'none', duration: 3000 })
|
||||
}
|
||||
this._sseTask = null
|
||||
},
|
||||
} as WechatMiniprogram.RequestOption)
|
||||
|
||||
// 监听 chunked 数据(SSE 事件流)
|
||||
// onChunkReceived 在 typings 中未定义,使用 as any
|
||||
;(requestTask as any).onChunkReceived((res: { data: ArrayBuffer }) => {
|
||||
parser.feed(res.data)
|
||||
})
|
||||
|
||||
this._sseTask = requestTask
|
||||
},
|
||||
|
||||
/** 滚动到底部 */
|
||||
|
||||
Reference in New Issue
Block a user