Files
banban/banban-mini/src/services/chat.ts
2026-05-28 18:15:10 +08:00

799 lines
25 KiB
TypeScript

import Taro from '@tarojs/taro'
import { getCurrentUserId } from './auth'
import { ApiError, BASE_URL, getToken, request } from './api'
import { loadCurrentChildBindingContext } from './binding'
import { handleUnauthorized } from './session'
export type ConversationSource = 'im' | 'ai'
export type PeerKind = 'child' | 'parent' | 'ai'
const PARENT_CHILD_CONVERSATION_TYPE_NAME = 'parent_child'
export interface ChatConversation {
id: number
key: string
source: ConversationSource
peerKind: PeerKind
name: string
avatar: string
typeLabel: string
description: string
lastMessage: string
time: string
sortAt: string
roleKey?: string
conversationTypeName?: string
peerId?: string
childId?: number | null
childName?: string
parentUserId?: number | null
deviceId?: string
channelLabel: string
canSend: boolean
isSynthetic?: boolean
}
export interface ChatMessage {
id: number
type: 'user' | 'peer'
content: string
time: string
conversationId: number
contentType?: number
mediaUrl?: string | null
mediaDurationMs?: number | null
mediaMimeType?: string | null
mediaTranscriptText?: string | null
senderType?: string
senderId?: string
receiverType?: string
receiverId?: string
senderName?: string
receiverName?: string
channelLabel?: string
extJson?: Record<string, any> | null
}
export interface Character {
id: number
name: string
description: string
icon: string
peerKind: PeerKind
source: ConversationSource
conversationTypeName?: string
childId?: number | null
childName?: string
peerId?: string
parentUserId?: number | null
deviceId?: string
channelLabel: string
canSend: boolean
}
interface DeviceAiMessageItem {
id: number
conversation_id: number
role_key: string
is_user: boolean
speaker: 'user' | 'assistant'
content: string
timestamp: number
created_at: string
}
interface DeviceAiMessageListResponse {
items: DeviceAiMessageItem[]
total: number
next_cursor?: number | null
}
interface DeviceAiConversationItem {
conversation_id: number
role_key: string
role_name: string
role_description?: string | null
message_count: number
last_message_preview?: string | null
last_message_at?: string | null
created_at: string
updated_at: string
}
interface DeviceAiConversationListResponse {
items: DeviceAiConversationItem[]
total: number
next_cursor?: number | null
}
interface DeviceRoleSummary {
role_key: string
name: string
description?: string | null
}
interface ChildConversationItem {
conversation_id: number
conversation_type: number
conversation_type_name: string
peer_type: 'child' | 'parent'
peer_id: string
peer_name?: string | null
last_message_preview?: string | null
last_message_at?: string | null
message_count: number
}
interface ChildConversationListResponse {
items: ChildConversationItem[]
total: number
next_cursor?: number | null
}
interface ChildConversationMessageItem {
id: number
conversation_id: number
seq: number
sender_type: 'child' | 'parent'
sender_id: string
receiver_type: 'child' | 'parent'
receiver_id: string
content_type: number
content_text?: string | null
content_json?: Record<string, any> | null
media_file_key?: string | null
media_duration_ms?: number | null
media_mime_type?: string | null
media_size_bytes?: number | null
media_transcript_text?: string | null
client_msg_id?: string | null
sender_name_snapshot?: string | null
receiver_name_snapshot?: string | null
ext_json?: Record<string, any> | null
created_at: string
}
interface ChildConversationMessageListResponse {
conversation_id: number
has_more: boolean
next_cursor_seq?: number | null
items: ChildConversationMessageItem[]
}
interface ConversationMessageCreateResponse {
idempotent: boolean
conversation_id: number
conversation_type: number
conversation_type_name: string
message: ChildConversationMessageItem
}
interface BindingContext {
deviceId: string
childId: number | null
childName: string
currentUserId: number
}
const PEER_META: Record<PeerKind, { icon: string; label: string; description: string }> = {
child: { icon: '🧒', label: '儿童', description: '孩子正在交流的儿童玩伴' },
parent: { icon: '👨', label: '家长', description: '家长通过微信小程序和孩子沟通' },
ai: { icon: '🤖', label: 'AI', description: '孩子与 AI 的历史交流' },
}
const AI_ROLE_META: Record<string, { name: string; icon: string; description: string }> = {
assistant: { name: 'AI', icon: '🤖', description: '孩子与 AI 的历史交流' },
}
type AiMeta = { name: string; icon: string; description: string }
export function isParentChildConversation(conversationTypeName?: string | null): boolean {
return conversationTypeName === PARENT_CHILD_CONVERSATION_TYPE_NAME
}
function parseTime(value?: string | null): number {
if (!value) return 0
const timestamp = new Date(value).getTime()
return Number.isNaN(timestamp) ? 0 : timestamp
}
function parseNumericId(value?: string | number | null): number | null {
const normalizedValue = Number(value)
if (!Number.isFinite(normalizedValue) || normalizedValue <= 0) return null
return normalizedValue
}
function formatListTime(value?: string | null): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const now = new Date()
const isSameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isSameDay) {
return `${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}`
}
return `${date.getMonth() + 1}/${date.getDate()}`
}
function formatDetailTime(value?: string | null): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
function formatImPreview(
item: Pick<
ChildConversationMessageItem,
'content_type' | 'content_text' | 'media_transcript_text' | 'content_json' | 'ext_json'
>
): string {
if (item.content_type === 1) return item.content_text || '文本消息'
if (item.content_type === 2) {
return ''
}
if (item.content_type === 3) return '[图片]'
if (item.content_type === 4) {
const title = String(item.content_json?.title || item.content_json?.type || '').trim()
return title ? `[卡片] ${title}` : '[卡片]'
}
return '[消息]'
}
function createClientMessageId(): string {
return `parent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function getAiMeta(roleKey?: string, roleName?: string | null, roleDescription?: string | null): AiMeta {
const normalizedRoleName = String(roleName || '').trim()
const normalizedRoleDescription = String(roleDescription || '').trim()
if (normalizedRoleName) {
return {
name: normalizedRoleName,
icon: '🤖',
description: normalizedRoleDescription || `孩子与 ${normalizedRoleName} 的历史交流`,
}
}
if (roleKey && AI_ROLE_META[roleKey]) return AI_ROLE_META[roleKey]
return {
name: roleKey || 'AI',
icon: '🤖',
description: `孩子与 ${roleKey || 'AI'} 的历史交流`,
}
}
async function getRoleMetaMap(): Promise<Record<string, AiMeta>> {
try {
const roles = await request<DeviceRoleSummary[]>('/banban/roles')
return roles.reduce<Record<string, AiMeta>>((result, role) => {
const roleKey = String(role.role_key || '').trim()
if (!roleKey) return result
result[roleKey] = getAiMeta(roleKey, role.name, role.description)
return result
}, {})
} catch (error: any) {
if (error?.status === 404) return {}
throw error
}
}
function getParticipantDisplayName(type: string, id?: string, name?: string | null): string {
if (name && name.trim()) return name.trim()
if (type === 'child') return id ? `儿童 ${id}` : '儿童'
if (type === 'parent') return id ? `家长 ${id}` : '家长'
return id || 'AI'
}
function getPeerDisplayName(peerKind: PeerKind, peerId?: string, peerName?: string | null, roleKey?: string): string {
if (peerKind === 'ai') return getAiMeta(roleKey).name
return getParticipantDisplayName(peerKind, peerId, peerName)
}
function getParentConversationName(parentUserId: number | null, peerId: string, peerName: string | null | undefined, context: BindingContext): string {
if (parentUserId && parentUserId === context.currentUserId) return '家长沟通'
const displayName = getParticipantDisplayName('parent', peerId, peerName)
return `${displayName}的留言记录`
}
function canSendParentConversation(parentUserId: number | null, context: BindingContext): boolean {
return Boolean(parentUserId && parentUserId === context.currentUserId)
}
function getConversationRank(conversation: ChatConversation): number {
if (isParentChildConversation(conversation.conversationTypeName)) return 0
return 1
}
async function getBindingContext(): Promise<BindingContext> {
const context = await loadCurrentChildBindingContext()
const childName = String(context.currentChild?.child_name || context.currentBinding?.child_name || '').trim()
return {
deviceId: context.currentBinding?.device_id || '',
childId: context.currentChild?.child_id || null,
childName,
currentUserId: getCurrentUserId(),
}
}
async function getDeviceMessages(context?: BindingContext): Promise<DeviceAiMessageItem[]> {
const { deviceId } = context || (await getBindingContext())
if (!deviceId) return []
try {
const response = await request<DeviceAiMessageListResponse>(`/banban/devices/${deviceId}/messages?limit=100`)
return response.items || []
} catch (error: any) {
if (error?.status === 404) return []
throw error
}
}
async function getDeviceAiConversations(context?: BindingContext): Promise<DeviceAiConversationItem[]> {
const { deviceId } = context || (await getBindingContext())
if (!deviceId) return []
try {
const response = await request<DeviceAiConversationListResponse>(
`/banban/devices/${deviceId}/ai-conversations?limit=100`
)
return response.items || []
} catch (error: any) {
if (error?.status === 404) return []
throw error
}
}
async function getChildConversations(context?: BindingContext): Promise<ChildConversationItem[]> {
const { childId } = context || (await getBindingContext())
if (!childId) return []
try {
const response = await request<ChildConversationListResponse>(`/banban/children/${childId}/conversations?limit=100`)
return response.items || []
} catch (error: any) {
if (error?.status === 404) return []
throw error
}
}
async function getChildConversationMessages(
conversationId: number,
context?: BindingContext
): Promise<ChildConversationMessageItem[]> {
const { childId } = context || (await getBindingContext())
if (!childId || conversationId <= 0) return []
const response = await request<ChildConversationMessageListResponse>(
`/banban/children/${childId}/conversations/${conversationId}/messages?limit=100`
)
return response.items || []
}
function toImConversation(item: ChildConversationItem, context: BindingContext): ChatConversation {
const isParentConversation = isParentChildConversation(item.conversation_type_name)
const peerKind: PeerKind = item.peer_type === 'parent' ? 'parent' : 'child'
const meta = PEER_META[peerKind]
const parentUserId = isParentConversation ? parseNumericId(item.peer_id) : null
const canSend = isParentConversation ? canSendParentConversation(parentUserId, context) : false
const parentConversationName = getParentConversationName(parentUserId, item.peer_id, item.peer_name, context)
return {
id: item.conversation_id,
key: `im-${item.conversation_id}`,
source: 'im',
peerKind,
name: isParentConversation ? parentConversationName : getPeerDisplayName(peerKind, item.peer_id, item.peer_name),
avatar: meta.icon,
typeLabel: isParentConversation ? (canSend ? '家长沟通' : '留言记录') : meta.label,
description: isParentConversation
? canSend
? '家长通过微信小程序和孩子直接沟通'
: '其他家长和孩子的留言记录'
: meta.description,
lastMessage: item.last_message_preview || (isParentConversation ? (canSend ? '还没有消息,点进去发送第一条' : '暂无留言记录') : '暂无消息'),
time: formatListTime(item.last_message_at),
sortAt: item.last_message_at || '',
conversationTypeName: item.conversation_type_name,
peerId: item.peer_id,
childId: context.childId,
childName: context.childName,
parentUserId,
deviceId: context.deviceId,
channelLabel: isParentConversation ? '微信小程序' : '设备端',
canSend,
}
}
function toAiConversation(item: DeviceAiConversationItem, context: BindingContext, roleMetaMap: Record<string, AiMeta>): ChatConversation {
const meta = roleMetaMap[item.role_key] || getAiMeta(item.role_key, item.role_name, item.role_description)
const lastMessage = item.last_message_preview || (item.message_count > 0 ? '暂无内容' : '这个会话还没有消息')
const sortAt = item.last_message_at || item.updated_at || item.created_at
return {
id: item.conversation_id,
key: `ai-${item.conversation_id}`,
source: 'ai',
peerKind: 'ai',
name: meta.name,
avatar: meta.icon,
typeLabel: PEER_META.ai.label,
description: meta.description,
lastMessage,
time: formatListTime(sortAt),
sortAt,
roleKey: item.role_key,
conversationTypeName: 'ai',
peerId: item.role_key,
childId: context.childId,
childName: context.childName,
parentUserId: null,
deviceId: context.deviceId,
channelLabel: 'AI',
canSend: false,
}
}
function createSyntheticParentConversation(context: BindingContext): ChatConversation | null {
if (!context.childId || !context.currentUserId) return null
return {
id: 0,
key: `im-parent-child-${context.childId}-${context.currentUserId}`,
source: 'im',
peerKind: 'parent',
name: '家长沟通',
avatar: PEER_META.parent.icon,
typeLabel: '家长沟通',
description: '家长通过微信小程序和孩子直接沟通',
lastMessage: '还没有留言,点进去发送第一条',
time: '',
sortAt: '',
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
peerId: String(context.currentUserId),
childId: context.childId,
childName: context.childName,
parentUserId: context.currentUserId,
deviceId: context.deviceId,
channelLabel: '微信小程序',
canSend: true,
isSynthetic: true,
}
}
export async function getConversations(): Promise<ChatConversation[]> {
const context = await getBindingContext()
const [imConversationItems, aiConversations, roleMetaMap] = await Promise.all([
getChildConversations(context),
getDeviceAiConversations(context),
getRoleMetaMap(),
])
const imConversations = imConversationItems.map((item) => toImConversation(item, context))
const conversations = [
...imConversations,
...aiConversations.map((item) => toAiConversation(item, context, roleMetaMap)),
]
const hasParentConversation = conversations.some(
(item) =>
isParentChildConversation(item.conversationTypeName) &&
item.parentUserId === context.currentUserId
)
if (!hasParentConversation) {
const syntheticConversation = createSyntheticParentConversation(context)
if (syntheticConversation) {
conversations.push(syntheticConversation)
}
}
return conversations.sort((left, right) => {
const rankDiff = getConversationRank(left) - getConversationRank(right)
if (rankDiff !== 0) return rankDiff
return parseTime(right.sortAt) - parseTime(left.sortAt)
})
}
export async function getMessages(
conversationId: number,
source: ConversationSource,
conversationTypeName?: string
): Promise<ChatMessage[]> {
const context = await getBindingContext()
if (source === 'ai') {
const [items, roleMetaMap] = await Promise.all([getDeviceMessages(context), getRoleMetaMap()])
return items
.filter((item) => item.conversation_id === conversationId)
.slice()
.reverse()
.map((item) => {
const meta = roleMetaMap[item.role_key] || getAiMeta(item.role_key)
return {
id: item.id,
type: item.is_user ? 'user' : 'peer',
content: item.content || '暂无内容',
time: formatDetailTime(item.created_at),
conversationId: item.conversation_id,
contentType: 1,
mediaUrl: null,
mediaDurationMs: null,
mediaMimeType: null,
mediaTranscriptText: null,
senderType: item.is_user ? 'device' : 'ai',
senderId: item.is_user ? context.deviceId : item.role_key,
receiverType: item.is_user ? 'ai' : 'device',
receiverId: item.is_user ? item.role_key : context.deviceId,
senderName: item.is_user ? context.childName || '孩子设备' : meta.name,
receiverName: item.is_user ? meta.name : context.childName || '孩子设备',
channelLabel: 'AI',
}
})
}
const items = await getChildConversationMessages(conversationId, context)
const isParentConversation = isParentChildConversation(conversationTypeName)
return items.map((item) => ({
id: item.id,
type: isParentConversation
? item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
? 'user'
: 'peer'
: item.sender_type === 'child' && item.sender_id === String(context.childId || '')
? 'user'
: 'peer',
content: formatImPreview(item),
time: formatDetailTime(item.created_at),
conversationId: item.conversation_id,
contentType: item.content_type,
mediaUrl: item.media_file_key || null,
mediaDurationMs: item.media_duration_ms || null,
mediaMimeType: item.media_mime_type || null,
mediaTranscriptText: item.media_transcript_text || null,
senderType: item.sender_type,
senderId: item.sender_id,
receiverType: item.receiver_type,
receiverId: item.receiver_id,
extJson: item.ext_json || null,
senderName:
item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
? '我'
: item.sender_type === 'child' && item.sender_id === String(context.childId || '')
? context.childName || item.sender_name_snapshot || '孩子'
: getParticipantDisplayName(item.sender_type, item.sender_id, item.sender_name_snapshot),
receiverName:
item.receiver_type === 'parent' && item.receiver_id === String(context.currentUserId)
? '我'
: item.receiver_type === 'child' && item.receiver_id === String(context.childId || '')
? context.childName || item.receiver_name_snapshot || '孩子'
: getParticipantDisplayName(item.receiver_type, item.receiver_id, item.receiver_name_snapshot),
channelLabel:
isParentConversation && (item.sender_type === 'parent' || item.receiver_type === 'parent')
? '微信小程序'
: '设备端',
}))
}
export async function getCharacter(options: {
conversationId: number
source: ConversationSource
peerKind?: PeerKind
name?: string
roleKey?: string
conversationTypeName?: string
childId?: number | null
childName?: string
peerId?: string
parentUserId?: number | null
deviceId?: string
channelLabel?: string
canSend?: boolean
}): Promise<Character | null> {
const {
conversationId,
source,
peerKind = 'ai',
name,
roleKey,
conversationTypeName,
childId,
childName,
peerId,
parentUserId,
deviceId,
channelLabel,
canSend = false,
} = options
const context = await getBindingContext()
if (source === 'ai') {
const meta = getAiMeta(roleKey)
return {
id: conversationId,
name: name || meta.name,
description: 'AI 对话通道',
icon: meta.icon,
peerKind: 'ai',
source,
conversationTypeName: 'ai',
childId,
childName,
peerId: peerId || roleKey,
parentUserId: null,
deviceId,
channelLabel: channelLabel || 'AI',
canSend: false,
}
}
if (isParentChildConversation(conversationTypeName)) {
const normalizedParentUserId = parentUserId || parseNumericId(peerId)
const canSendParentMessage = Boolean(canSend && normalizedParentUserId === context.currentUserId)
const normalizedName = name || getParentConversationName(normalizedParentUserId, peerId || '', null, context)
return {
id: conversationId,
name: normalizedName,
description: canSendParentMessage ? '微信小程序 <-> 儿童设备' : '其他家长和孩子的留言记录',
icon: PEER_META.parent.icon,
peerKind: 'parent',
source,
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
childId,
childName,
peerId,
parentUserId: normalizedParentUserId,
deviceId,
channelLabel: channelLabel || '微信小程序',
canSend: canSendParentMessage,
}
}
const meta = PEER_META[peerKind]
return {
id: conversationId,
name: getPeerDisplayName(peerKind, peerId, name),
description: peerKind === 'child' ? '儿童设备间的聊天记录' : meta.description,
icon: meta.icon,
peerKind,
source,
conversationTypeName,
childId,
childName,
peerId,
parentUserId,
deviceId,
channelLabel: channelLabel || '设备端',
canSend,
}
}
export async function sendParentMessage(childId: number, content: string): Promise<ConversationMessageCreateResponse> {
const normalizedContent = content.trim()
if (!normalizedContent) {
throw new Error('请输入消息内容')
}
return request<ConversationMessageCreateResponse>(`/banban/children/${childId}/messages`, {
method: 'POST',
data: {
content_type: 1,
content_text: normalizedContent,
client_msg_id: createClientMessageId(),
},
})
}
function parseUploadResponseData(rawData: any): any {
if (typeof rawData !== 'string') return rawData
try {
return JSON.parse(rawData)
} catch {
return { detail: rawData }
}
}
function getUploadErrorMessage(data: any): string {
if (!data) return '留言发送失败'
if (typeof data.detail === 'string') return data.detail
if (typeof data.message === 'string') return data.message
if (typeof data.errMsg === 'string') return data.errMsg
return '留言发送失败'
}
export async function sendParentVoiceMessage(
childId: number,
options: {
filePath: string
durationMs: number
transcriptText?: string
audioFormat?: string
mimeType?: string
runtime?: Record<string, any>
}
): Promise<ConversationMessageCreateResponse> {
if (!childId) {
throw new Error('当前会话不支持留言')
}
if (!options.filePath) {
throw new Error('录音文件不存在')
}
const token = getToken()
const requestUrl = `${BASE_URL}/banban/children/${childId}/voice-message`
console.info('[voice-upload] request', {
childId,
requestUrl,
filePath: options.filePath,
durationMs: options.durationMs,
audioFormat: options.audioFormat || '',
mimeType: options.mimeType || '',
runtime: options.runtime || {},
hasToken: Boolean(token),
})
const uploadResponse: any = await new Promise((resolve, reject) => {
Taro.uploadFile({
url: requestUrl,
filePath: options.filePath,
name: 'file',
header: token
? {
Authorization: `Bearer ${token}`,
}
: {},
formData: {
duration_ms: `${Math.max(1, Math.round(options.durationMs || 0))}`,
client_msg_id: createClientMessageId(),
transcript_text: options.transcriptText || '',
audio_format: options.audioFormat || '',
mime_type: options.mimeType || '',
},
success: (response) => {
console.info('[voice-upload] response', {
statusCode: response.statusCode,
errMsg: response.errMsg,
dataType: typeof response.data,
})
resolve(response)
},
fail: (error) => {
console.error('[voice-upload] fail', {
childId,
requestUrl,
errMsg: error?.errMsg,
message: error?.message,
})
reject(error)
},
})
}).catch((error: any) => {
const message = String(error?.errMsg || error?.message || 'request:fail')
throw new ApiError(0, message)
})
if (uploadResponse.statusCode === 401) {
handleUnauthorized({ redirect: true })
}
const data = parseUploadResponseData(uploadResponse.data)
if (uploadResponse.statusCode >= 400) {
console.error('[voice-upload] error response', {
statusCode: uploadResponse.statusCode,
data,
})
throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data))
}
return data as ConversationMessageCreateResponse
}