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 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 created_at: string updated_at: string } 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 的历史交流' }, } 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> { try { const roles = await request('/banban/roles') return roles.reduce>((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 compareConversationsByNewest(left: ChatConversation, right: ChatConversation): number { const timeDiff = parseTime(right.sortAt) - parseTime(left.sortAt) if (timeDiff !== 0) return timeDiff return right.key.localeCompare(left.key) } 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 getDeviceAiConversations(context?: BindingContext): Promise { const { deviceId } = context || (await getBindingContext()) if (!deviceId) return [] try { const response = await request( `/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 { 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] 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) const sortAt = item.last_message_at || item.updated_at || item.created_at 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(sortAt), sortAt, 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): 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 { 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(compareConversationsByNewest) } export async function getMessages( conversationId: number, source: ConversationSource, conversationTypeName?: string ): Promise { 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 { 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 { 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 audioFormat?: string mimeType?: string runtime?: Record } ): Promise { 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 }