feat: show device AI conversations
This commit is contained in:
@@ -88,6 +88,30 @@ interface DeviceAiMessageListResponse {
|
|||||||
next_cursor?: number | null
|
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 {
|
interface ChildConversationItem {
|
||||||
conversation_id: number
|
conversation_id: number
|
||||||
conversation_type: number
|
conversation_type: number
|
||||||
@@ -161,6 +185,8 @@ const AI_ROLE_META: Record<string, { name: string; icon: string; description: st
|
|||||||
assistant: { name: 'AI', icon: '🤖', description: '孩子与 AI 的历史交流' },
|
assistant: { name: 'AI', icon: '🤖', description: '孩子与 AI 的历史交流' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AiMeta = { name: string; icon: string; description: string }
|
||||||
|
|
||||||
export function isParentChildConversation(conversationTypeName?: string | null): boolean {
|
export function isParentChildConversation(conversationTypeName?: string | null): boolean {
|
||||||
return conversationTypeName === PARENT_CHILD_CONVERSATION_TYPE_NAME
|
return conversationTypeName === PARENT_CHILD_CONVERSATION_TYPE_NAME
|
||||||
}
|
}
|
||||||
@@ -225,7 +251,16 @@ function createClientMessageId(): string {
|
|||||||
return `parent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
return `parent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAiMeta(roleKey?: string): { name: string; icon: string; description: string } {
|
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]
|
if (roleKey && AI_ROLE_META[roleKey]) return AI_ROLE_META[roleKey]
|
||||||
return {
|
return {
|
||||||
name: roleKey || 'AI',
|
name: roleKey || 'AI',
|
||||||
@@ -234,6 +269,21 @@ function getAiMeta(roleKey?: string): { name: string; icon: string; description:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function getParticipantDisplayName(type: string, id?: string, name?: string | null): string {
|
||||||
if (name && name.trim()) return name.trim()
|
if (name && name.trim()) return name.trim()
|
||||||
if (type === 'child') return id ? `儿童 ${id}` : '儿童'
|
if (type === 'child') return id ? `儿童 ${id}` : '儿童'
|
||||||
@@ -276,6 +326,21 @@ async function getDeviceMessages(context?: BindingContext): Promise<DeviceAiMess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[]> {
|
async function getChildConversations(context?: BindingContext): Promise<ChildConversationItem[]> {
|
||||||
const { childId } = context || (await getBindingContext())
|
const { childId } = context || (await getBindingContext())
|
||||||
if (!childId) return []
|
if (!childId) return []
|
||||||
@@ -330,8 +395,10 @@ function toImConversation(item: ChildConversationItem, context: BindingContext):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): ChatConversation {
|
function toAiConversation(item: DeviceAiConversationItem, context: BindingContext, roleMetaMap: Record<string, AiMeta>): ChatConversation {
|
||||||
const meta = getAiMeta(item.role_key)
|
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 {
|
return {
|
||||||
id: item.conversation_id,
|
id: item.conversation_id,
|
||||||
@@ -342,9 +409,9 @@ function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): C
|
|||||||
avatar: meta.icon,
|
avatar: meta.icon,
|
||||||
typeLabel: PEER_META.ai.label,
|
typeLabel: PEER_META.ai.label,
|
||||||
description: meta.description,
|
description: meta.description,
|
||||||
lastMessage: item.content || '暂无消息',
|
lastMessage,
|
||||||
time: formatListTime(item.created_at),
|
time: formatListTime(sortAt),
|
||||||
sortAt: item.created_at,
|
sortAt,
|
||||||
roleKey: item.role_key,
|
roleKey: item.role_key,
|
||||||
conversationTypeName: 'ai',
|
conversationTypeName: 'ai',
|
||||||
peerId: item.role_key,
|
peerId: item.role_key,
|
||||||
@@ -386,19 +453,16 @@ function createSyntheticParentConversation(context: BindingContext): ChatConvers
|
|||||||
|
|
||||||
export async function getConversations(): Promise<ChatConversation[]> {
|
export async function getConversations(): Promise<ChatConversation[]> {
|
||||||
const context = await getBindingContext()
|
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 imConversations = imConversationItems.map((item) => toImConversation(item, context))
|
||||||
const aiConversationMap = new Map<number, DeviceAiMessageItem>()
|
|
||||||
|
|
||||||
for (const item of aiMessages) {
|
|
||||||
if (!aiConversationMap.has(item.conversation_id)) {
|
|
||||||
aiConversationMap.set(item.conversation_id, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversations = [
|
const conversations = [
|
||||||
...imConversations,
|
...imConversations,
|
||||||
...Array.from(aiConversationMap.values()).map((item) => toAiConversation(item, context)),
|
...aiConversations.map((item) => toAiConversation(item, context, roleMetaMap)),
|
||||||
]
|
]
|
||||||
|
|
||||||
const hasParentConversation = conversations.some(
|
const hasParentConversation = conversations.some(
|
||||||
@@ -429,12 +493,14 @@ export async function getMessages(
|
|||||||
const context = await getBindingContext()
|
const context = await getBindingContext()
|
||||||
|
|
||||||
if (source === 'ai') {
|
if (source === 'ai') {
|
||||||
const items = await getDeviceMessages(context)
|
const [items, roleMetaMap] = await Promise.all([getDeviceMessages(context), getRoleMetaMap()])
|
||||||
return items
|
return items
|
||||||
.filter((item) => item.conversation_id === conversationId)
|
.filter((item) => item.conversation_id === conversationId)
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((item) => ({
|
.map((item) => {
|
||||||
|
const meta = roleMetaMap[item.role_key] || getAiMeta(item.role_key)
|
||||||
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: item.is_user ? 'user' : 'peer',
|
type: item.is_user ? 'user' : 'peer',
|
||||||
content: item.content || '暂无内容',
|
content: item.content || '暂无内容',
|
||||||
@@ -449,10 +515,11 @@ export async function getMessages(
|
|||||||
senderId: item.is_user ? context.deviceId : item.role_key,
|
senderId: item.is_user ? context.deviceId : item.role_key,
|
||||||
receiverType: item.is_user ? 'ai' : 'device',
|
receiverType: item.is_user ? 'ai' : 'device',
|
||||||
receiverId: item.is_user ? item.role_key : context.deviceId,
|
receiverId: item.is_user ? item.role_key : context.deviceId,
|
||||||
senderName: item.is_user ? context.childName || '孩子设备' : getAiMeta(item.role_key).name,
|
senderName: item.is_user ? context.childName || '孩子设备' : meta.name,
|
||||||
receiverName: item.is_user ? getAiMeta(item.role_key).name : context.childName || '孩子设备',
|
receiverName: item.is_user ? meta.name : context.childName || '孩子设备',
|
||||||
channelLabel: 'AI',
|
channelLabel: 'AI',
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await getChildConversationMessages(conversationId, context)
|
const items = await getChildConversationMessages(conversationId, context)
|
||||||
|
|||||||
@@ -62,6 +62,56 @@ class DeviceDAO(BaseDAO):
|
|||||||
)
|
)
|
||||||
return result.mappings().all()
|
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(
|
async def get_device_status(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -51,6 +51,24 @@ class DeviceMessageListResponse(BaseModel):
|
|||||||
next_cursor: int | None = None
|
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):
|
class DeviceStatusResponse(BaseModel):
|
||||||
device_id: str
|
device_id: str
|
||||||
child_id: int | None = None
|
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:
|
def _row_to_current_location_response(row: Mapping) -> DeviceLocationCurrentResponse:
|
||||||
return DeviceLocationCurrentResponse(
|
return DeviceLocationCurrentResponse(
|
||||||
child_id=int(row["child_id"]),
|
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)
|
@router.get("/{device_id}/status", response_model=DeviceStatusResponse)
|
||||||
async def get_device_status(
|
async def get_device_status(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ class DeviceService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
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(
|
async def get_device_status(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user