diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts index 757da1c..65ad358 100644 --- a/banban-mini/src/services/chat.ts +++ b/banban-mini/src/services/chat.ts @@ -88,6 +88,30 @@ interface DeviceAiMessageListResponse { 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 @@ -161,6 +185,8 @@ const AI_ROLE_META: Record> { + 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}` : '儿童' @@ -276,6 +326,21 @@ async function getDeviceMessages(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 [] @@ -330,8 +395,10 @@ function toImConversation(item: ChildConversationItem, context: BindingContext): } } -function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): ChatConversation { - const meta = getAiMeta(item.role_key) +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, @@ -342,9 +409,9 @@ function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): C avatar: meta.icon, typeLabel: PEER_META.ai.label, description: meta.description, - lastMessage: item.content || '暂无消息', - time: formatListTime(item.created_at), - sortAt: item.created_at, + lastMessage, + time: formatListTime(sortAt), + sortAt, roleKey: item.role_key, conversationTypeName: 'ai', peerId: item.role_key, @@ -386,19 +453,16 @@ function createSyntheticParentConversation(context: BindingContext): ChatConvers export async function getConversations(): Promise { const context = await getBindingContext() - const [imConversationItems, aiMessages] = await Promise.all([getChildConversations(context), getDeviceMessages(context)]) + const [imConversationItems, aiConversations, roleMetaMap] = await Promise.all([ + getChildConversations(context), + getDeviceAiConversations(context), + getRoleMetaMap(), + ]) 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)), + ...aiConversations.map((item) => toAiConversation(item, context, roleMetaMap)), ] const hasParentConversation = conversations.some( @@ -429,30 +493,33 @@ export async function getMessages( const context = await getBindingContext() if (source === 'ai') { - const items = await getDeviceMessages(context) + const [items, roleMetaMap] = await Promise.all([getDeviceMessages(context), getRoleMetaMap()]) 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', - })) + .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) diff --git a/talkingq-url/banban/dao/device.py b/talkingq-url/banban/dao/device.py index 73852e1..467ae70 100644 --- a/talkingq-url/banban/dao/device.py +++ b/talkingq-url/banban/dao/device.py @@ -62,6 +62,56 @@ class DeviceDAO(BaseDAO): ) return result.mappings().all() + async def list_device_ai_conversations( + self, + *, + device_id: str, + cursor: int | None, + limit: int, + ) -> List[Mapping[str, Any]]: + params = {"device_id": device_id, "limit": limit + 1} + where = "ch.device_id = :device_id" + if cursor is not None: + where += " AND ch.id < :cursor" + params["cursor"] = cursor + + result = await self.execute( + text( + f""" + SELECT + ch.id AS conversation_id, + ch.role_key, + COALESCE(r.name, ch.role_key) AS role_name, + r.description AS role_description, + COALESCE(stats.message_count, 0) AS message_count, + latest.content AS last_message_preview, + latest.created_at AS last_message_at, + ch.last_interaction_time, + ch.created_at, + ch.updated_at + FROM conversation_histories AS ch + LEFT JOIN roles AS r + ON r.role_key = ch.role_key + LEFT JOIN ( + SELECT + conversation_id, + COUNT(*) AS message_count, + MAX(id) AS latest_message_id + FROM conversation_messages + GROUP BY conversation_id + ) AS stats + ON stats.conversation_id = ch.id + LEFT JOIN conversation_messages AS latest + ON latest.id = stats.latest_message_id + WHERE {where} + ORDER BY COALESCE(latest.created_at, ch.updated_at, ch.created_at) DESC, ch.id DESC + LIMIT :limit + """ + ), + params, + ) + return result.mappings().all() + async def get_device_status( self, *, diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index d8336db..c61839a 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -51,6 +51,24 @@ class DeviceMessageListResponse(BaseModel): next_cursor: int | None = None +class DeviceAiConversationItem(BaseModel): + conversation_id: int + role_key: str + role_name: str + role_description: str | None = None + message_count: int + last_message_preview: str | None = None + last_message_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class DeviceAiConversationListResponse(BaseModel): + items: list[DeviceAiConversationItem] + total: int + next_cursor: int | None = None + + class DeviceStatusResponse(BaseModel): device_id: str child_id: int | None = None @@ -159,6 +177,20 @@ def _row_to_message_item(row: Mapping) -> DeviceMessageItem: ) +def _row_to_ai_conversation_item(row: Mapping) -> DeviceAiConversationItem: + return DeviceAiConversationItem( + conversation_id=int(row["conversation_id"]), + role_key=str(row["role_key"]), + role_name=str(row["role_name"] or row["role_key"]), + role_description=row.get("role_description"), + message_count=int(row["message_count"] or 0), + last_message_preview=row.get("last_message_preview"), + last_message_at=row.get("last_message_at"), + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + def _row_to_current_location_response(row: Mapping) -> DeviceLocationCurrentResponse: return DeviceLocationCurrentResponse( child_id=int(row["child_id"]), @@ -293,6 +325,43 @@ async def list_device_messages( ) +@router.get("/{device_id}/ai-conversations", response_model=DeviceAiConversationListResponse) +async def list_device_ai_conversations( + device_id: str, + request: Request, + cursor: int | None = Query(default=None, ge=1), + limit: int = Query(default=20, ge=1, le=100), + current_user_id: int = Depends(get_current_user_id), +) -> DeviceAiConversationListResponse: + rows = await device_service.list_device_ai_conversations( + device_id=device_id, + user_id=current_user_id, + cursor=cursor, + limit=limit, + ) + + has_more = len(rows) > limit + rows = rows[:limit] + next_cursor = int(rows[-1]["conversation_id"]) if has_more and rows else None + + logger.info( + "listed device ai conversations", + extra={ + "event": "device_ai_conversations", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "returned_count": len(rows), + }, + ) + + return DeviceAiConversationListResponse( + items=[_row_to_ai_conversation_item(row) for row in rows], + total=len(rows), + next_cursor=next_cursor, + ) + + @router.get("/{device_id}/status", response_model=DeviceStatusResponse) async def get_device_status( device_id: str, diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py index 6c28ee3..0cfe1e0 100644 --- a/talkingq-url/banban/service/device.py +++ b/talkingq-url/banban/service/device.py @@ -43,6 +43,26 @@ class DeviceService(DatabaseServiceBase): finally: await db_session.close() + async def list_device_ai_conversations( + self, + *, + device_id: str, + user_id: int, + cursor: int | None, + limit: int, + ) -> List[Mapping[str, Any]]: + db_session = await self.get_session() + try: + dao = DeviceDAO(db_session) + await dao.ensure_device_access(device_id=device_id, user_id=user_id) + return await dao.list_device_ai_conversations( + device_id=device_id, + cursor=cursor, + limit=limit, + ) + finally: + await db_session.close() + async def get_device_status( self, *,