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:
Neo
2026-03-20 09:02:10 +08:00
parent 3d2e5f8165
commit beb88d5bea
388 changed files with 6436 additions and 25458 deletions

View File

@@ -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 {
// 微信基础库支持 TextDecoder2.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
},
/** 滚动到底部 */