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 | 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 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 | 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 | 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 = { child: { icon: '🧒', label: '儿童', description: '孩子正在交流的儿童玩伴' }, parent: { icon: '👨', label: '家长', description: '家长通过微信小程序和孩子沟通' }, ai: { icon: '🤖', label: 'AI', description: '孩子与 AI 的历史交流' }, } const AI_ROLE_META: Record = { assistant: { name: 'AI', icon: '🤖', description: '孩子与 AI 的历史交流' }, } 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) { if (item.ext_json?.message_kind === 'leave_message') { return item.media_transcript_text || '[留言]' } return item.media_transcript_text || '[语音]' } 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): { name: string; icon: string; description: string } { if (roleKey && AI_ROLE_META[roleKey]) return AI_ROLE_META[roleKey] return { name: roleKey || 'AI', icon: '🤖', description: `孩子与 ${roleKey || 'AI'} 的历史交流`, } } 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 getConversationRank(conversation: ChatConversation): number { if (isParentChildConversation(conversation.conversationTypeName)) return 0 return 1 } async function getBindingContext(): Promise { 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 { const { deviceId } = context || (await getBindingContext()) if (!deviceId) return [] try { const response = await request(`/banban/devices/${deviceId}/messages?limit=100`) return response.items || [] } catch (error: any) { if (error?.status === 404) return [] throw error } } async function getChildConversations(context?: BindingContext): Promise { const { childId } = context || (await getBindingContext()) if (!childId) return [] try { const response = await request(`/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 { const { childId } = context || (await getBindingContext()) if (!childId || conversationId <= 0) return [] const response = await request( `/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] return { id: item.conversation_id, key: `im-${item.conversation_id}`, source: 'im', peerKind, name: isParentConversation ? '家长沟通' : getPeerDisplayName(peerKind, item.peer_id, item.peer_name), avatar: meta.icon, typeLabel: isParentConversation ? '家长沟通' : meta.label, description: isParentConversation ? '家长通过微信小程序和孩子直接沟通' : meta.description, lastMessage: item.last_message_preview || (isParentConversation ? '还没有消息,点进去发送第一条' : '暂无消息'), 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: isParentConversation ? parseNumericId(item.peer_id) : null, deviceId: context.deviceId, channelLabel: isParentConversation ? '微信小程序' : '设备端', canSend: isParentConversation, } } function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): ChatConversation { const meta = getAiMeta(item.role_key) 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: item.content || '暂无消息', time: formatListTime(item.created_at), sortAt: item.created_at, 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 { const context = await getBindingContext() const [imConversationItems, aiMessages] = await Promise.all([getChildConversations(context), getDeviceMessages(context)]) const imConversations = imConversationItems.map((item) => toImConversation(item, context)) const aiConversationMap = new Map() for (const item of aiMessages) { if (!aiConversationMap.has(item.conversation_id)) { aiConversationMap.set(item.conversation_id, item) } } const conversations = [ ...imConversations, ...Array.from(aiConversationMap.values()).map((item) => toAiConversation(item, context)), ] 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 { const context = await getBindingContext() if (source === 'ai') { const items = await getDeviceMessages(context) return items .filter((item) => item.conversation_id === conversationId) .slice() .reverse() .map((item) => ({ 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 || '孩子设备' : getAiMeta(item.role_key).name, receiverName: item.is_user ? getAiMeta(item.role_key).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 { const { conversationId, source, peerKind = 'ai', name, roleKey, conversationTypeName, childId, childName, peerId, parentUserId, deviceId, channelLabel, canSend = false, } = options 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)) { return { id: conversationId, name: '家长沟通', description: '微信小程序 <-> 儿童设备', icon: PEER_META.parent.icon, peerKind: 'parent', source, conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME, childId, childName, peerId, parentUserId: parentUserId || parseNumericId(peerId), deviceId, channelLabel: channelLabel || '微信小程序', canSend: canSend || true, } } 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 { const normalizedContent = content.trim() if (!normalizedContent) { throw new Error('请输入消息内容') } return request(`/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 } ): Promise { if (!childId) { throw new Error('当前会话不支持留言') } if (!options.filePath) { throw new Error('录音文件不存在') } const token = getToken() const requestUrl = `${BASE_URL}/banban/children/${childId}/voice-message` 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 || '', }, success: resolve, fail: reject, }) }).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) { throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data)) } return data as ConversationMessageCreateResponse }