Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/chat/chat.ts
Neo caf179a5da feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复
涵盖(每条对应已存的审计记录):
- AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生)
  audit: 2026-04-20__ai-module-complete.md
- admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager
  audit: 2026-04-21__admin-web-ai-management-suite.md
- App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance)
  audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md
- App2 prewarm 全过滤器 + AI 触发器 cron reschedule
  audit: 2026-04-21__app2-finance-prewarm-all-filters.md
  migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql
- AppType 联合类型对齐 + adminAiAppTypes.test.ts
  audit: 2026-04-30__admin_web_ai_app_type_alignment.md
- DashScope tokens_used 提取修复
  audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md
- App3 线索完整详情 prompt
  audit: 2026-05-01__backend_app3_full_detail_prompt.md
- Runtime Context 沙箱(5-1~5-2 主线):
  - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router
  - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts
  - migration: 20260501__runtime_context_sandbox.sql
  - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py
  - database/changes: 7 份 sandbox_* 验证报告
- 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整
  + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py)

合规:
- .gitignore 启用 tmp/ 排除
- 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留)

待验证清单:
- docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md
  每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
2026-05-04 02:30:19 +08:00

563 lines
18 KiB
TypeScript
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_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
// pages/chat/chat.ts — AI 对话页
// 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 }> {
if (!data) return []
return Object.keys(data).map((k) => ({ key: k, value: data[k] }))
}
/** 为消息列表补充展示字段时间分割线、IM时间、引用卡片*/
function enrichMessages(msgs: ApiMessageItem[]) {
return msgs.map((m, i) => ({
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].createdAt,
m.createdAt,
),
referenceCard: m.referenceCard
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
: undefined,
}))
}
/** 从 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: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
/** 状态栏高度 */
statusBarHeight: 0,
/** 消息列表 */
messages: [] as DisplayMessage[],
/** 输入框内容 */
inputText: '',
/** AI 正在流式回复 */
isStreaming: false,
/** 流式输出中的内容 */
streamingContent: '',
/** 滚动锚点 */
scrollToId: '',
/** 页面顶部引用卡片(从其他页面跳转时) */
referenceCard: null as { title: string; summary: string } | null,
/** 当前对话 ID后端返回用于发送消息 */
chatId: '',
/** 客户 ID兼容旧逻辑 */
customerId: '',
/** 输入栏距底部距离(软键盘弹出时动态调整)*/
inputBarBottom: 0,
/** 来源页面标识(看板入口) */
sourcePage: '',
/** 看板筛选参数 */
pageFilters: {} as Record<string, string>,
},
/** 消息计数器,用于生成唯一 ID */
_msgCounter: 0,
/** 当前 SSE 请求任务(用于中断/重试) */
_sseTask: null as WechatMiniprogram.RequestTask | null,
/** 最后一次发送的用户消息内容(用于重试) */
_lastUserContent: '',
/** SSE 断线重试次数 */
_sseRetryCount: 0,
/** SSE 最大自动重试次数 */
_SSE_MAX_RETRY: 2,
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/chat/chat')
},
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由
// 优先级historyId → taskId → customerId → coachId → general
onLoad(options) {
const sysInfo = wx.getWindowInfo()
this.setData({
statusBarHeight: sysInfo.statusBarHeight || 44,
})
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 if (options?.sourcePage) {
// 看板类入口:保存来源页面和筛选参数
// Phase 2.3:优先解析 options.pageFiltersai-float-button 传入的 JSON 字符串),
// 回退到单独键旧入口兼容timeDimension / areaFilter 等)
const pageFilters: Record<string, string> = {}
if (options.pageFilters) {
try {
const parsed = JSON.parse(decodeURIComponent(options.pageFilters))
if (parsed && typeof parsed === 'object') {
for (const k of Object.keys(parsed)) {
const v = parsed[k]
if (v != null) pageFilters[k] = String(v)
}
}
} catch {
// JSON 解析失败忽略,回退到单键读取
}
}
if (Object.keys(pageFilters).length === 0) {
const filterKeys = ['timeDimension', 'areaFilter', 'dimension', 'typeFilter', 'projectFilter']
for (const key of filterKeys) {
if (options[key]) pageFilters[key] = options[key]
}
}
this.setData({ sourcePage: options.sourcePage, pageFilters })
this.loadMessagesByContext(options.sourcePage, '')
} else {
// 无参数入口:始终新建通用对话
this.loadMessagesByContext('general', '')
}
},
/** 通过 chatId 加载历史消息historyId 入口) */
async loadMessages(chatId: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
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' })
} finally {
wx.hideLoading()
}
},
/** 通过上下文类型加载消息task/customer/coach/general 入口) */
async loadMessagesByContext(contextType: string, contextId: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
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
// customerId 入口时显示引用卡片
const referenceCard = contextType === 'customer' && contextId
? { title: '客户详情', summary: `正在查看客户 ${contextId} 的相关信息` }
: null
const isEmpty = messages.length === 0 && !referenceCard
this.setData({
pageState: isEmpty ? 'empty' : 'normal',
messages,
referenceCard,
})
this.scrollToBottom()
} catch {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
/** 返回上一页 */
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() {
if (this.data.chatId) {
this.loadMessages(this.data.chatId)
} else {
this.loadMessagesByContext('general', '')
}
},
/** 软键盘弹出:输入栏上移至键盘顶部 */
onInputFocus(e: WechatMiniprogram.InputFocus) {
const keyboardHeight = e.detail.height || 0
this.setData({ inputBarBottom: keyboardHeight })
setTimeout(() => this.scrollToBottom(), 120)
},
/** 软键盘收起:输入栏归位 */
onInputBlur() {
this.setData({ inputBarBottom: 0 })
},
/** 输入框内容变化 */
onInputChange(e: WechatMiniprogram.Input) {
this.setData({ inputText: e.detail.value })
},
/** 发送消息 */
onSendMessage() {
const text = this.data.inputText.trim()
if (!text || this.data.isStreaming) return
this._msgCounter++
const now = new Date().toISOString()
const prevMsgs = this.data.messages
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
const userMsg: DisplayMessage = {
id: `msg-user-${this._msgCounter}`,
role: 'user',
content: text,
timestamp: now,
timeLabel: '刚刚',
imTimeLabel: formatIMTime(now),
showTimeDivider: shouldShowTimeDivider(prevTs, now),
}
const messages = [...this.data.messages, userMsg]
this.setData({
messages,
inputText: '',
pageState: 'normal',
})
this.scrollToBottom()
// 保存用户消息内容,用于 SSE 发送和重试
this._lastUserContent = text
// CHANGE 2026-03-20 | T8.2: 使用真实 SSE 替代 mock
setTimeout(() => {
this.triggerAIReply()
}, 300)
},
// 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 aiNow = new Date().toISOString()
const prevMsgs = this.data.messages
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
const aiMsg: DisplayMessage = {
id: aiMsgId,
role: 'assistant',
content: '',
timestamp: aiNow,
timeLabel: '刚刚',
imTimeLabel: formatIMTime(aiNow),
showTimeDivider: shouldShowTimeDivider(prevTs, aiNow),
}
const messages = [...this.data.messages, aiMsg]
this.setData({
messages,
isStreaming: true,
streamingContent: '',
})
this.scrollToBottom()
const aiIndex = messages.length - 1
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._sseRetryCount = 0
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,
...(this.data.sourcePage ? {
sourcePage: this.data.sourcePage,
pageContext: this.data.pageFilters,
} : {}),
},
header: headers,
enableChunked: true,
responseType: 'arraybuffer',
success: () => {
// enableChunked 模式下 success 在连接关闭时触发
// 如果此时仍在 streaming 状态,说明连接正常关闭但未收到 done 事件
if (this.data.isStreaming) {
this.setData({ isStreaming: false, streamingContent: '' })
this._sseTask = null
}
},
fail: () => {
// 网络错误或连接中断:无内容时指数退避重连
this._sseTask = null
if (!this.data.isStreaming) return
if (fullContent === '' && this._sseRetryCount < this._SSE_MAX_RETRY) {
this._sseRetryCount++
const delay = (2 ** this._sseRetryCount) * 1000
wx.showToast({ title: `重连中 ${this._sseRetryCount}/${this._SSE_MAX_RETRY}...`, icon: 'loading', duration: delay })
this.setData({
messages: this.data.messages.slice(0, aiIndex),
isStreaming: false,
streamingContent: '',
})
setTimeout(() => { this.triggerAIReply(chatId, content) }, delay)
} else {
const errorContent = fullContent || '连接中断,请重试'
this.setData({
[`messages[${aiIndex}].content`]: errorContent,
isStreaming: false,
streamingContent: '',
})
wx.showToast({ title: '连接中断', icon: 'none', duration: 3000 })
}
},
} as WechatMiniprogram.RequestOption)
// 监听 chunked 数据SSE 事件流)
// onChunkReceived 在 typings 中未定义,使用 as any
;(requestTask as any).onChunkReceived((res: { data: ArrayBuffer }) => {
parser.feed(res.data)
})
this._sseTask = requestTask
},
/** 滚动到底部 */
scrollToBottom() {
setTimeout(() => {
this.setData({ scrollToId: '' })
setTimeout(() => {
this.setData({ scrollToId: 'scroll-bottom' })
}, 50)
}, 50)
},
/** 点击引用卡片跳转到对应详情页Phase 2.1 */
onRefCardTap(e: WechatMiniprogram.BaseEvent & { currentTarget: { dataset: { link?: string } } }) {
const link = e.currentTarget?.dataset?.link
if (!link || typeof link !== 'string') {
return
}
wx.navigateTo({
url: link,
fail: (err) => {
console.error('跳转引用详情失败', err)
wx.showToast({ title: '跳转失败', icon: 'none' })
},
})
},
})