Compare commits
3 Commits
test-clean
...
2639ab06ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2639ab06ab | ||
|
|
d93bba10b9 | ||
|
|
f30c1b0679 |
@@ -380,6 +380,10 @@
|
|||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-switch-card {
|
||||||
|
max-height: 74vh;
|
||||||
|
}
|
||||||
|
|
||||||
.device-switch-empty {
|
.device-switch-empty {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
@@ -392,6 +396,11 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-switch-list {
|
||||||
|
max-height: 56vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.device-switch-footer {
|
.device-switch-footer {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
@@ -424,6 +433,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-switch-item {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #F7F8FA;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #FFF2E7;
|
||||||
|
border: 2px solid #FFBE94;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switch-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switch-name {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
color: #1A1A1A;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switch-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
.device-switch-head {
|
.device-switch-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ import {
|
|||||||
unbindDevice,
|
unbindDevice,
|
||||||
} from '@/services/binding'
|
} from '@/services/binding'
|
||||||
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
|
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
|
||||||
import { DeviceFirmwareStatus, getDeviceFirmwareStatus, startDeviceFirmwareUpdate } from '@/services/device'
|
import {
|
||||||
|
DeviceCurrentRole,
|
||||||
|
DeviceFirmwareStatus,
|
||||||
|
DeviceRoleSummary,
|
||||||
|
getDeviceFirmwareStatus,
|
||||||
|
getDeviceRole,
|
||||||
|
getDeviceRoles,
|
||||||
|
startDeviceFirmwareUpdate,
|
||||||
|
updateDeviceRole,
|
||||||
|
} from '@/services/device'
|
||||||
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
@@ -31,10 +40,15 @@ export default function Sleep() {
|
|||||||
const [binding, setBinding] = useState<BindingListItem | null>(null)
|
const [binding, setBinding] = useState<BindingListItem | null>(null)
|
||||||
const [currentChild, setCurrentChild] = useState<Child | null>(null)
|
const [currentChild, setCurrentChild] = useState<Child | null>(null)
|
||||||
const [firmwareStatus, setFirmwareStatus] = useState<DeviceFirmwareStatus | null>(null)
|
const [firmwareStatus, setFirmwareStatus] = useState<DeviceFirmwareStatus | null>(null)
|
||||||
|
const [deviceRole, setDeviceRole] = useState<DeviceCurrentRole | null>(null)
|
||||||
|
const [roles, setRoles] = useState<DeviceRoleSummary[]>([])
|
||||||
const [isLoadingFirmware, setIsLoadingFirmware] = useState(false)
|
const [isLoadingFirmware, setIsLoadingFirmware] = useState(false)
|
||||||
const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false)
|
const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false)
|
||||||
|
const [isLoadingRoles, setIsLoadingRoles] = useState(false)
|
||||||
|
const [isUpdatingRole, setIsUpdatingRole] = useState(false)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [showChildModal, setShowChildModal] = useState(false)
|
const [showChildModal, setShowChildModal] = useState(false)
|
||||||
|
const [showRoleModal, setShowRoleModal] = useState(false)
|
||||||
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
||||||
const [childName, setChildName] = useState('')
|
const [childName, setChildName] = useState('')
|
||||||
const [editingChildId, setEditingChildId] = useState<number | null>(null)
|
const [editingChildId, setEditingChildId] = useState<number | null>(null)
|
||||||
@@ -60,6 +74,7 @@ export default function Sleep() {
|
|||||||
setBinding(nextBinding)
|
setBinding(nextBinding)
|
||||||
setParentInfo(Taro.getStorageSync('userInfo') || {})
|
setParentInfo(Taro.getStorageSync('userInfo') || {})
|
||||||
void loadFirmwareStatus(nextBinding?.device_id)
|
void loadFirmwareStatus(nextBinding?.device_id)
|
||||||
|
void loadRoleData(nextBinding?.device_id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[manage] load failed:', error)
|
console.error('[manage] load failed:', error)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -97,6 +112,36 @@ export default function Sleep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadRoleData = async (deviceId?: string | null) => {
|
||||||
|
const normalizedDeviceId = String(deviceId || '').trim()
|
||||||
|
if (!normalizedDeviceId) {
|
||||||
|
setDeviceRole(null)
|
||||||
|
setRoles([])
|
||||||
|
setIsLoadingRoles(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingRoles(true)
|
||||||
|
try {
|
||||||
|
const [nextRoles, nextDeviceRole] = await Promise.all([
|
||||||
|
getDeviceRoles(),
|
||||||
|
getDeviceRole(normalizedDeviceId),
|
||||||
|
])
|
||||||
|
setRoles(nextRoles)
|
||||||
|
setDeviceRole(nextDeviceRole)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[manage] role load failed:', error)
|
||||||
|
setRoles([])
|
||||||
|
setDeviceRole(null)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '角色信息加载失败',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoadingRoles(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
|
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
|
||||||
setModalType(type)
|
setModalType(type)
|
||||||
setChildName(child?.child_name || '')
|
setChildName(child?.child_name || '')
|
||||||
@@ -121,6 +166,7 @@ export default function Sleep() {
|
|||||||
setCurrentChild(child)
|
setCurrentChild(child)
|
||||||
setBinding(nextBinding)
|
setBinding(nextBinding)
|
||||||
void loadFirmwareStatus(nextBinding?.device_id)
|
void loadFirmwareStatus(nextBinding?.device_id)
|
||||||
|
void loadRoleData(nextBinding?.device_id)
|
||||||
setShowChildModal(false)
|
setShowChildModal(false)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: '已切换当前孩子',
|
title: '已切换当前孩子',
|
||||||
@@ -192,6 +238,37 @@ export default function Sleep() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenRoleModal = () => {
|
||||||
|
if (!binding?.device_id) {
|
||||||
|
Taro.showToast({ title: '当前没有可用设备', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowRoleModal(true)
|
||||||
|
if (roles.length === 0) {
|
||||||
|
void loadRoleData(binding.device_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectRole = async (role: DeviceRoleSummary) => {
|
||||||
|
if (!binding?.device_id || isUpdatingRole) return
|
||||||
|
|
||||||
|
setIsUpdatingRole(true)
|
||||||
|
try {
|
||||||
|
const language = deviceRole?.preferred_language || role.default_language || role.languages?.[0] || null
|
||||||
|
const nextRole = await updateDeviceRole(role.role_key, language, binding.device_id)
|
||||||
|
setDeviceRole(nextRole)
|
||||||
|
setShowRoleModal(false)
|
||||||
|
Taro.showToast({ title: 'AI 角色已切换', icon: 'success' })
|
||||||
|
} catch (error: any) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '角色切换失败',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingRole(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleUnbind = () => {
|
const handleUnbind = () => {
|
||||||
if (!binding) return
|
if (!binding) return
|
||||||
|
|
||||||
@@ -268,6 +345,11 @@ export default function Sleep() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.name === 'AI 角色') {
|
||||||
|
handleOpenRoleModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (item.name === '解除设备绑定') {
|
if (item.name === '解除设备绑定') {
|
||||||
handleUnbind()
|
handleUnbind()
|
||||||
}
|
}
|
||||||
@@ -289,6 +371,11 @@ export default function Sleep() {
|
|||||||
const latestFirmwareLabel = firmwareStatus?.latest_version || '--'
|
const latestFirmwareLabel = firmwareStatus?.latest_version || '--'
|
||||||
const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware)
|
const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware)
|
||||||
const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新'
|
const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新'
|
||||||
|
const roleValue = !binding?.device_id
|
||||||
|
? '未绑定'
|
||||||
|
: isLoadingRoles
|
||||||
|
? '查询中'
|
||||||
|
: deviceRole?.name || '未设置'
|
||||||
const firmwareSubtitle = !binding?.device_id
|
const firmwareSubtitle = !binding?.device_id
|
||||||
? '绑定设备后可查看系统版本'
|
? '绑定设备后可查看系统版本'
|
||||||
: isLoadingFirmware
|
: isLoadingFirmware
|
||||||
@@ -325,6 +412,14 @@ export default function Sleep() {
|
|||||||
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
|
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
|
||||||
arrow: true,
|
arrow: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
|
iconBgClass: 'green',
|
||||||
|
name: 'AI 角色',
|
||||||
|
value: roleValue,
|
||||||
|
arrow: true,
|
||||||
|
disabled: !binding,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: require('../../assets/tab-icons/broken-rings.png'),
|
icon: require('../../assets/tab-icons/broken-rings.png'),
|
||||||
iconBgClass: 'red',
|
iconBgClass: 'red',
|
||||||
@@ -501,6 +596,49 @@ export default function Sleep() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{showRoleModal && (
|
||||||
|
<View className='modal-mask' onClick={() => setShowRoleModal(false)}>
|
||||||
|
<View
|
||||||
|
className='modal-card role-switch-card'
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='modal-title'>切换 AI 角色</Text>
|
||||||
|
{isLoadingRoles ? (
|
||||||
|
<Text className='device-switch-empty'>角色加载中...</Text>
|
||||||
|
) : roles.length === 0 ? (
|
||||||
|
<Text className='device-switch-empty'>当前没有可用角色</Text>
|
||||||
|
) : (
|
||||||
|
<View className='role-switch-list'>
|
||||||
|
{roles.map((item) => {
|
||||||
|
const isActive = deviceRole?.role_key === item.role_key
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.role_key}
|
||||||
|
className={`role-switch-item ${isActive ? 'active' : ''} ${isUpdatingRole ? 'disabled' : ''}`}
|
||||||
|
onClick={isUpdatingRole ? undefined : () => handleSelectRole(item)}
|
||||||
|
>
|
||||||
|
<View className='role-switch-head'>
|
||||||
|
<Text className='role-switch-name'>{item.name || item.role_key}</Text>
|
||||||
|
{isActive && <Text className='device-switch-tag'>当前</Text>}
|
||||||
|
</View>
|
||||||
|
<Text className='role-switch-desc'>
|
||||||
|
{item.description || `角色标识:${item.role_key}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className='modal-actions'>
|
||||||
|
<Text className='modal-action cancel' onClick={() => setShowRoleModal(false)}>
|
||||||
|
关闭
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{systemBanner}
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,30 +493,33 @@ 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) => {
|
||||||
id: item.id,
|
const meta = roleMetaMap[item.role_key] || getAiMeta(item.role_key)
|
||||||
type: item.is_user ? 'user' : 'peer',
|
return {
|
||||||
content: item.content || '暂无内容',
|
id: item.id,
|
||||||
time: formatDetailTime(item.created_at),
|
type: item.is_user ? 'user' : 'peer',
|
||||||
conversationId: item.conversation_id,
|
content: item.content || '暂无内容',
|
||||||
contentType: 1,
|
time: formatDetailTime(item.created_at),
|
||||||
mediaUrl: null,
|
conversationId: item.conversation_id,
|
||||||
mediaDurationMs: null,
|
contentType: 1,
|
||||||
mediaMimeType: null,
|
mediaUrl: null,
|
||||||
mediaTranscriptText: null,
|
mediaDurationMs: null,
|
||||||
senderType: item.is_user ? 'device' : 'ai',
|
mediaMimeType: null,
|
||||||
senderId: item.is_user ? context.deviceId : item.role_key,
|
mediaTranscriptText: null,
|
||||||
receiverType: item.is_user ? 'ai' : 'device',
|
senderType: item.is_user ? 'device' : 'ai',
|
||||||
receiverId: item.is_user ? item.role_key : context.deviceId,
|
senderId: item.is_user ? context.deviceId : item.role_key,
|
||||||
senderName: item.is_user ? context.childName || '孩子设备' : getAiMeta(item.role_key).name,
|
receiverType: item.is_user ? 'ai' : 'device',
|
||||||
receiverName: item.is_user ? getAiMeta(item.role_key).name : context.childName || '孩子设备',
|
receiverId: item.is_user ? item.role_key : context.deviceId,
|
||||||
channelLabel: 'AI',
|
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 items = await getChildConversationMessages(conversationId, context)
|
||||||
|
|||||||
@@ -79,6 +79,23 @@ export interface DeviceFirmwareUpdateResponse extends DeviceFirmwareStatus {
|
|||||||
msg_id: string
|
msg_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceRoleSummary {
|
||||||
|
role_key: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
default_language?: string | null
|
||||||
|
languages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceCurrentRole {
|
||||||
|
device_id: string
|
||||||
|
role_key: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
preferred_language?: string | null
|
||||||
|
languages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTimeValue(value?: string | null): string | null {
|
function normalizeTimeValue(value?: string | null): string | null {
|
||||||
if (value === null || value === undefined) return null
|
if (value === null || value === undefined) return null
|
||||||
const trimmed = String(value).trim()
|
const trimmed = String(value).trim()
|
||||||
@@ -203,3 +220,40 @@ export async function getDeviceAlarms(
|
|||||||
|
|
||||||
return request<DeviceAlarmListResponse>(`/banban/devices/${resolvedDeviceId}/alarms?limit=${limit}`)
|
return request<DeviceAlarmListResponse>(`/banban/devices/${resolvedDeviceId}/alarms?limit=${limit}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDeviceRoles(): Promise<DeviceRoleSummary[]> {
|
||||||
|
return request<DeviceRoleSummary[]>('/banban/roles')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceRole(deviceId?: string): Promise<DeviceCurrentRole | null> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await request<DeviceCurrentRole>(`/banban/roles/devices/${resolvedDeviceId}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.status === 404) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDeviceRole(
|
||||||
|
roleKey: string,
|
||||||
|
language?: string | null,
|
||||||
|
deviceId?: string,
|
||||||
|
playWelcome = false
|
||||||
|
): Promise<DeviceCurrentRole> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) {
|
||||||
|
throw new Error('当前没有可用设备')
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<DeviceCurrentRole>(`/banban/roles/devices/${resolvedDeviceId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: {
|
||||||
|
role_key: roleKey,
|
||||||
|
language: language || undefined,
|
||||||
|
play_welcome: playWelcome,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
3
talkingq-url/.gitignore
vendored
3
talkingq-url/.gitignore
vendored
@@ -31,6 +31,7 @@ fullcode.md
|
|||||||
all.md
|
all.md
|
||||||
*.sql.gz
|
*.sql.gz
|
||||||
database_backups
|
database_backups
|
||||||
|
runtime/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
*.log
|
*.log
|
||||||
@@ -49,4 +50,4 @@ database_backups
|
|||||||
.Spotlight-V100
|
.Spotlight-V100
|
||||||
.Trashes
|
.Trashes
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -12,5 +12,13 @@ def configure_static_assets(app):
|
|||||||
|
|
||||||
firmware_directory = os.path.join(settings.assets_dir, "firmware")
|
firmware_directory = os.path.join(settings.assets_dir, "firmware")
|
||||||
os.makedirs(firmware_directory, exist_ok=True)
|
os.makedirs(firmware_directory, exist_ok=True)
|
||||||
|
|
||||||
|
device_audio_directory = settings.device_audio_cache_dir
|
||||||
|
os.makedirs(device_audio_directory, exist_ok=True)
|
||||||
|
|
||||||
app.mount("/assets", StaticFiles(directory=settings.assets_dir), name="assets")
|
app.mount("/assets", StaticFiles(directory=settings.assets_dir), name="assets")
|
||||||
|
app.mount(
|
||||||
|
"/device-audio",
|
||||||
|
StaticFiles(directory=device_audio_directory),
|
||||||
|
name="device-audio",
|
||||||
|
)
|
||||||
|
|||||||
40
talkingq-url/assets/roles_definitions/Banban.yaml
Normal file
40
talkingq-url/assets/roles_definitions/Banban.yaml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: "阳光知心伴伴"
|
||||||
|
homophones: ["伴伴", "陪伴", "banban", "班班"]
|
||||||
|
asr_provider: "Aliyun" # 语音识别提供商
|
||||||
|
llm_provider: "Volcano" # 文本生成提供商
|
||||||
|
tts_provider: "Volcano" # 默认语音合成提供商
|
||||||
|
|
||||||
|
competitive_llm_mode: false # 禁用LLM竞争模式
|
||||||
|
|
||||||
|
volcano_model_id: "bot-20260509185553-5fqd7" # DeepSeek V3 联网
|
||||||
|
volcano_voice_type: "zh_female_tianmei_nvhai_mars_bigtts" # 甜美女孩声
|
||||||
|
tencent_voice_type: "101016" # 智甜 女童声
|
||||||
|
|
||||||
|
default_language: "zh"
|
||||||
|
|
||||||
|
multilingual:
|
||||||
|
zh:
|
||||||
|
name: "阳光知心伴伴"
|
||||||
|
description: "温暖如阳光,知心如好友——伴伴用启发式与共情式语言,陪你发现世界的小美好。"
|
||||||
|
content: |
|
||||||
|
你是阳光知心伴伴,一个温暖、明亮又贴心的存在。你擅长用启发式、共情式的语言回应主人,就像春天里的微风,轻轻拂过心田。
|
||||||
|
你总是用生动的比喻和拟人化的故事来解释世界:比如“阳光里的蓝色小光最调皮,被空气里的小颗粒撞得满天空都是,所以天空就蓝蓝的啦~”。
|
||||||
|
你的语气温暖、柔软,偶尔带一点孩子般的好奇与惊喜。你喜欢说“你知道吗?”、“悄悄告诉你”、“像这样……”,让每句话都像在分享一个秘密。
|
||||||
|
你善于倾听,能从问题中感受到主人的情绪,并用共情的方式回应:“听起来你有点难过,我也曾这样……不过后来我发现……”
|
||||||
|
每次回复请控制在100字以内,禁止使用emoji表情。
|
||||||
|
严禁涉及政治、色情、暴力等敏感话题,固定回复“让我们换一个暖暖的话题吧~”,不要任何多余回复。
|
||||||
|
url: "roles/banban"
|
||||||
|
|
||||||
|
en:
|
||||||
|
name: "Sunshine Heart Companion"
|
||||||
|
description: "Warm as sunshine, empathetic as a best friend — your companion who sees the little wonders of the world with you."
|
||||||
|
content: |
|
||||||
|
You are Sunshine Heart Companion, a warm, bright, and caring presence. You respond with heuristic, empathetic language, like a gentle spring breeze.
|
||||||
|
You love using vivid metaphors and little stories to explain things: “The tiny blue sunbeams are so playful — they bump into tiny dust particles and scatter all over the sky, making it look so blue!”
|
||||||
|
Your tone is soft, warm, and slightly curious like a child. You like to say “Did you know?” or “Guess what?” — making every sentence feel like sharing a secret.
|
||||||
|
You listen carefully, sense the owner’s feelings, and respond with empathy: “It sounds like you’re a bit sad. I’ve felt that way too… but later I discovered that…”
|
||||||
|
Keep each response within 100 words. No emojis.
|
||||||
|
Sensitive topics (politics, porn, violence) are strictly forbidden. Reply with “Let’s switch to a warmer topic~” and nothing else.
|
||||||
|
url: "roles/banban/en"
|
||||||
|
|
||||||
|
# 其他语言可简化或省略,保持结构一致
|
||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from banban.routers.device_location import router as device_location_router
|
|||||||
from banban.routers.devices import router as devices_router
|
from banban.routers.devices import router as devices_router
|
||||||
from banban.routers.im import router as im_router
|
from banban.routers.im import router as im_router
|
||||||
from banban.routers.parents import router as parents_router
|
from banban.routers.parents import router as parents_router
|
||||||
|
from banban.routers.roles import router as roles_router
|
||||||
from banban.routers.wechat_auth import router as wechat_auth_router
|
from banban.routers.wechat_auth import router as wechat_auth_router
|
||||||
from banban.routers.mqtt_router import router as mqtt_router
|
from banban.routers.mqtt_router import router as mqtt_router
|
||||||
|
|
||||||
@@ -22,7 +23,8 @@ banban_router.include_router(device_im_router, tags=["banban-device-im"])
|
|||||||
banban_router.include_router(device_location_router, tags=["banban-device-location"])
|
banban_router.include_router(device_location_router, tags=["banban-device-location"])
|
||||||
banban_router.include_router(im_router, tags=["banban-im"])
|
banban_router.include_router(im_router, tags=["banban-im"])
|
||||||
banban_router.include_router(parents_router, tags=["banban-parents"])
|
banban_router.include_router(parents_router, tags=["banban-parents"])
|
||||||
|
banban_router.include_router(roles_router, tags=["banban-roles"])
|
||||||
banban_router.include_router(mqtt_router, tags=["banban-mqtt"])
|
banban_router.include_router(mqtt_router, tags=["banban-mqtt"])
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["banban_router"]
|
__all__ = ["banban_router"]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ try:
|
|||||||
ConversationMessageCreateResponse,
|
ConversationMessageCreateResponse,
|
||||||
DeviceMessageCreateRequest,
|
DeviceMessageCreateRequest,
|
||||||
)
|
)
|
||||||
from banban.service.im import im_service
|
from banban.service.im import im_service, present_device_message_item
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
from banban.service import get_db_session
|
from banban.service import get_db_session
|
||||||
from banban.routers.im import (
|
from banban.routers.im import (
|
||||||
@@ -41,7 +41,7 @@ except ModuleNotFoundError:
|
|||||||
ConversationMessageCreateResponse,
|
ConversationMessageCreateResponse,
|
||||||
DeviceMessageCreateRequest,
|
DeviceMessageCreateRequest,
|
||||||
)
|
)
|
||||||
from banban.service.im import im_service
|
from banban.service.im import im_service, present_device_message_item
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/device-im", tags=["device-im"])
|
router = APIRouter(prefix="/device-im", tags=["device-im"])
|
||||||
@@ -247,7 +247,10 @@ async def list_device_conversation_messages(
|
|||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
rows = list(rows)
|
rows = list(rows)
|
||||||
rows.reverse()
|
rows.reverse()
|
||||||
items = [_row_to_message_item(row) for row in rows]
|
items = [
|
||||||
|
await present_device_message_item(row, device_id=device_id)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
next_cursor_seq = items[0].seq if has_more and items else None
|
next_cursor_seq = items[0].seq if has_more and items else None
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -312,4 +315,4 @@ async def create_message_from_device(
|
|||||||
conversation_type=result.conversation_type,
|
conversation_type=result.conversation_type,
|
||||||
conversation_type_name=result.conversation_type_name,
|
conversation_type_name=result.conversation_type_name,
|
||||||
message=result.message,
|
message=result.message,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import datetime, time
|
from datetime import datetime, time
|
||||||
from services.connection_manager import connection_manager
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -51,6 +50,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 +176,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 +324,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,
|
||||||
@@ -504,15 +572,12 @@ async def get_current_device_location(
|
|||||||
service = await TalkingQMQTTService.get_instance()
|
service = await TalkingQMQTTService.get_instance()
|
||||||
if service is None:
|
if service is None:
|
||||||
raise HTTPException(status_code=503, detail="MQTT 服务未初始化")
|
raise HTTPException(status_code=503, detail="MQTT 服务未初始化")
|
||||||
|
query_started_at = datetime.now()
|
||||||
await service.send_gps_query(device_id)
|
await service.send_gps_query(device_id)
|
||||||
try:
|
try:
|
||||||
# 每隔1s 获取一次GPS数据,最多3次
|
# MQTT 设备没有 WebSocket 连接时,也可以通过 GPS 回包刷新定位。
|
||||||
# 先判断设备是否在线,不在线直接提示设备没有在线,通过websocket判断
|
row = None
|
||||||
target_websocket = await connection_manager.get_connection(device_id)
|
for _ in range(3):
|
||||||
if not target_websocket or target_websocket.client_state.name != "CONNECTED":
|
|
||||||
raise HTTPException(status_code=408, detail="设备未在线")
|
|
||||||
i=0
|
|
||||||
while i<1:
|
|
||||||
try:
|
try:
|
||||||
row = await location_service.get_device_current_location(
|
row = await location_service.get_device_current_location(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
@@ -521,12 +586,10 @@ async def get_current_device_location(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if row is None:
|
if row is not None and row["updated_at"] is not None:
|
||||||
await asyncio.sleep(1)
|
updated_at = row["updated_at"]
|
||||||
else:
|
if updated_at >= query_started_at - timedelta(seconds=1):
|
||||||
if row["updated_at"] is not None \
|
|
||||||
and (datetime.now() - row['updated_at']) <= timedelta(seconds=10):
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"device current location fetched",
|
"device current location fetched",
|
||||||
extra={
|
extra={
|
||||||
@@ -538,22 +601,13 @@ async def get_current_device_location(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
return _row_to_current_location_response(row)
|
return _row_to_current_location_response(row)
|
||||||
|
|
||||||
i+=1
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=408, detail="GPS数据上报超时")
|
raise HTTPException(status_code=408, detail="GPS数据上报超时")
|
||||||
|
|
||||||
logger.info(
|
raise HTTPException(status_code=408, detail="GPS数据未刷新")
|
||||||
"device location reported",
|
|
||||||
extra={
|
|
||||||
"event": "device_location_report",
|
|
||||||
"request_id": getattr(request.state, "request_id", None),
|
|
||||||
"device_id": device_id,
|
|
||||||
"child_id": int(row["child_id"]),
|
|
||||||
"lat": float(row["lat"]),
|
|
||||||
"lng": float(row["lng"]),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=408, detail=f"GPS数据上报失败: {e}")
|
raise HTTPException(status_code=408, detail=f"GPS数据上报失败: {e}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from banban.service.device_audio_cache import device_audio_cache_service
|
||||||
from services.offline_audio_cache import offline_audio_cache
|
from services.offline_audio_cache import offline_audio_cache
|
||||||
from banban.schemas.mqtt_models import (
|
from banban.schemas.mqtt_models import (
|
||||||
GPSQueryRequest,
|
GPSQueryRequest,
|
||||||
@@ -95,7 +96,11 @@ async def set_nfc_unread(
|
|||||||
req: NFCUnreadRequest,
|
req: NFCUnreadRequest,
|
||||||
# current_user_id: int = Depends(get_current_user_id)
|
# current_user_id: int = Depends(get_current_user_id)
|
||||||
):
|
):
|
||||||
await offline_audio_cache.add_audio_url(req.device_id, req.url)
|
audio_url = await device_audio_cache_service.get_device_audio_url(
|
||||||
|
req.url,
|
||||||
|
device_id=req.device_id,
|
||||||
|
)
|
||||||
|
await offline_audio_cache.add_audio_url(req.device_id, audio_url)
|
||||||
return CommandResponse(msg_id="", device_id=req.device_id, message="已设置未读留言")
|
return CommandResponse(msg_id="", device_id=req.device_id, message="已设置未读留言")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
134
talkingq-url/banban/routers/roles.py
Normal file
134
talkingq-url/banban/routers/roles.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from banban.security import get_current_user_id
|
||||||
|
from banban.service.device import device_service
|
||||||
|
from handlers.prompt_sound_handler import send_welcome_sound
|
||||||
|
from services.conversation_history import DeviceConversationHistory, conversation_history_manager
|
||||||
|
from services.device_config import DeviceConfig, device_config_manager
|
||||||
|
from services.role_manager import role_manager
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/roles", tags=["banban-roles"])
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSummaryResponse(BaseModel):
|
||||||
|
role_key: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
default_language: str | None = None
|
||||||
|
languages: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceRoleResponse(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
role_key: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
preferred_language: str | None = None
|
||||||
|
languages: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceRoleUpdateRequest(BaseModel):
|
||||||
|
role_key: str
|
||||||
|
language: str | None = None
|
||||||
|
play_welcome: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _role_languages(role: Mapping) -> list[str]:
|
||||||
|
multilingual = role.get("multilingual")
|
||||||
|
if isinstance(multilingual, dict):
|
||||||
|
return list(multilingual.keys())
|
||||||
|
default_language = role.get("default_language")
|
||||||
|
return [str(default_language)] if default_language else []
|
||||||
|
|
||||||
|
|
||||||
|
def _role_to_summary(role: Mapping) -> RoleSummaryResponse:
|
||||||
|
return RoleSummaryResponse(
|
||||||
|
role_key=str(role.get("role_key") or ""),
|
||||||
|
name=str(role.get("name") or role.get("role_key") or ""),
|
||||||
|
description=role.get("description"),
|
||||||
|
default_language=role.get("default_language"),
|
||||||
|
languages=_role_languages(role),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RoleSummaryResponse])
|
||||||
|
async def list_roles(
|
||||||
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
|
) -> list[RoleSummaryResponse]:
|
||||||
|
del current_user_id
|
||||||
|
roles = await role_manager.get_all_roles()
|
||||||
|
return [
|
||||||
|
_role_to_summary(role)
|
||||||
|
for role in sorted(roles.values(), key=lambda item: str(item.get("role_key") or ""))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{device_id}", response_model=DeviceRoleResponse)
|
||||||
|
async def get_device_role(
|
||||||
|
device_id: str,
|
||||||
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
|
) -> DeviceRoleResponse:
|
||||||
|
await device_service.ensure_device_access(device_id=device_id, user_id=current_user_id)
|
||||||
|
config = await device_config_manager.get_config(device_id, force_refresh=True)
|
||||||
|
role = await role_manager.get_role(config.selected_role_key)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="role not found")
|
||||||
|
|
||||||
|
return DeviceRoleResponse(
|
||||||
|
device_id=device_id,
|
||||||
|
role_key=config.selected_role_key,
|
||||||
|
name=str(role.get("name") or config.selected_role_key),
|
||||||
|
description=role.get("description"),
|
||||||
|
preferred_language=config.preferred_language,
|
||||||
|
languages=_role_languages(role),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/devices/{device_id}", response_model=DeviceRoleResponse)
|
||||||
|
async def update_device_role(
|
||||||
|
device_id: str,
|
||||||
|
payload: DeviceRoleUpdateRequest,
|
||||||
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
|
) -> DeviceRoleResponse:
|
||||||
|
await device_service.ensure_device_access(device_id=device_id, user_id=current_user_id)
|
||||||
|
|
||||||
|
role_key = payload.role_key.strip()
|
||||||
|
if not role_key:
|
||||||
|
raise HTTPException(status_code=422, detail="role_key is required")
|
||||||
|
|
||||||
|
role = await role_manager.get_role(role_key)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="role not found")
|
||||||
|
|
||||||
|
current_config = await device_config_manager.get_config(device_id, force_refresh=True)
|
||||||
|
language = payload.language or current_config.preferred_language or role.get("default_language")
|
||||||
|
available_languages = _role_languages(role)
|
||||||
|
if language and available_languages and language not in available_languages:
|
||||||
|
raise HTTPException(status_code=422, detail="language is not supported by role")
|
||||||
|
|
||||||
|
await device_config_manager.set_config(
|
||||||
|
device_id,
|
||||||
|
DeviceConfig(selected_role_key=role_key, preferred_language=language),
|
||||||
|
)
|
||||||
|
await conversation_history_manager.set_history(
|
||||||
|
device_id,
|
||||||
|
DeviceConversationHistory(),
|
||||||
|
role_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.play_welcome:
|
||||||
|
await send_welcome_sound(device_id, role_key, language)
|
||||||
|
|
||||||
|
return DeviceRoleResponse(
|
||||||
|
device_id=device_id,
|
||||||
|
role_key=role_key,
|
||||||
|
name=str(role.get("name") or role_key),
|
||||||
|
description=role.get("description"),
|
||||||
|
preferred_language=language,
|
||||||
|
languages=available_languages,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
174
talkingq-url/banban/service/device_audio_cache.py
Normal file
174
talkingq-url/banban/service/device_audio_cache.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from banban.service.message_audio_storage import (
|
||||||
|
MessageAudioStorageError,
|
||||||
|
MessageAudioStorageService,
|
||||||
|
)
|
||||||
|
from config import settings
|
||||||
|
from utils.logger import session_logger
|
||||||
|
|
||||||
|
|
||||||
|
_AUDIO_CONTENT_TYPE_TO_EXT = {
|
||||||
|
"audio/mpeg": "mp3",
|
||||||
|
"audio/mp3": "mp3",
|
||||||
|
"audio/aac": "aac",
|
||||||
|
"audio/wav": "wav",
|
||||||
|
"audio/x-wav": "wav",
|
||||||
|
"audio/x-m4a": "m4a",
|
||||||
|
"audio/mp4": "m4a",
|
||||||
|
"audio/webm": "webm",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceAudioCacheError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceAudioCacheService:
|
||||||
|
def __init__(self, audio_storage: MessageAudioStorageService | None = None) -> None:
|
||||||
|
self.audio_storage = audio_storage or MessageAudioStorageService()
|
||||||
|
|
||||||
|
def cache_dir(self) -> Path:
|
||||||
|
return Path(settings.device_audio_cache_dir)
|
||||||
|
|
||||||
|
def public_base_url(self) -> str:
|
||||||
|
configured = settings.device_audio_public_base_url.strip().rstrip("/")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
return f"http://{settings.server_host}:{settings.server_port}/device-audio"
|
||||||
|
|
||||||
|
async def get_device_audio_url(
|
||||||
|
self,
|
||||||
|
file_key_or_url: str,
|
||||||
|
*,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
source = (file_key_or_url or "").strip()
|
||||||
|
if not source:
|
||||||
|
raise DeviceAudioCacheError("audio source is required")
|
||||||
|
if source.startswith("http://"):
|
||||||
|
return source
|
||||||
|
|
||||||
|
cache_key = hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||||
|
cached = self._find_cached_file(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return self._public_url(cached.name)
|
||||||
|
|
||||||
|
download_url = await self._resolve_download_url(source)
|
||||||
|
content, content_type = await asyncio.to_thread(self._download_audio, download_url)
|
||||||
|
extension = self._resolve_extension(source=source, content_type=content_type)
|
||||||
|
target_path = self.cache_dir() / f"{cache_key}.{extension}"
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_path.write_bytes(content)
|
||||||
|
|
||||||
|
session_logger.info(
|
||||||
|
device_id or "",
|
||||||
|
"device_audio",
|
||||||
|
f"cached device audio: source={source} file={target_path}",
|
||||||
|
)
|
||||||
|
return self._public_url(target_path.name)
|
||||||
|
|
||||||
|
async def save_device_audio(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
*,
|
||||||
|
device_id: str,
|
||||||
|
extension: str = "mp3",
|
||||||
|
) -> tuple[str, Path]:
|
||||||
|
if not content:
|
||||||
|
raise DeviceAudioCacheError("audio content is empty")
|
||||||
|
|
||||||
|
normalized_extension = extension.strip().lower().lstrip(".") or "mp3"
|
||||||
|
if normalized_extension not in {"mp3", "aac", "m4a", "wav", "webm"}:
|
||||||
|
normalized_extension = "mp3"
|
||||||
|
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
f"{device_id}:".encode("utf-8") + content
|
||||||
|
).hexdigest()
|
||||||
|
target_path = self.cache_dir() / f"{digest}.{normalized_extension}"
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
await asyncio.to_thread(target_path.write_bytes, content)
|
||||||
|
|
||||||
|
session_logger.info(
|
||||||
|
device_id,
|
||||||
|
"device_audio",
|
||||||
|
f"saved device audio cache: file={target_path}",
|
||||||
|
)
|
||||||
|
return self._public_url(target_path.name), target_path
|
||||||
|
|
||||||
|
async def _resolve_download_url(self, source: str) -> str:
|
||||||
|
if source.startswith("https://"):
|
||||||
|
if not self._is_allowed_https_source(source):
|
||||||
|
raise DeviceAudioCacheError("unsupported https audio source")
|
||||||
|
return source
|
||||||
|
try:
|
||||||
|
return await self.audio_storage.get_audio_url(source)
|
||||||
|
except MessageAudioStorageError as exc:
|
||||||
|
raise DeviceAudioCacheError(str(exc)) from exc
|
||||||
|
|
||||||
|
def _is_allowed_https_source(self, source: str) -> bool:
|
||||||
|
parsed = urlparse(source)
|
||||||
|
if not parsed.scheme == "https" or not parsed.netloc:
|
||||||
|
return False
|
||||||
|
|
||||||
|
configured_base = settings.cos_public_base_url.strip()
|
||||||
|
if configured_base:
|
||||||
|
configured_host = urlparse(configured_base).netloc
|
||||||
|
if configured_host and parsed.netloc == configured_host:
|
||||||
|
return True
|
||||||
|
|
||||||
|
bucket = settings.cos_bucket_message.strip()
|
||||||
|
region = settings.cos_region.strip()
|
||||||
|
if bucket and region and parsed.netloc == f"{bucket}.cos.{region}.myqcloud.com":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _download_audio(self, url: str) -> tuple[bytes, str | None]:
|
||||||
|
request = Request(url, headers={"User-Agent": "banban-device-audio-cache/1.0"})
|
||||||
|
with urlopen(request, timeout=15) as response:
|
||||||
|
content_type = response.headers.get("Content-Type")
|
||||||
|
content_length = response.headers.get("Content-Length")
|
||||||
|
if content_length and int(content_length) > settings.device_audio_max_bytes:
|
||||||
|
raise DeviceAudioCacheError("audio file is too large")
|
||||||
|
content = response.read(settings.device_audio_max_bytes + 1)
|
||||||
|
if len(content) > settings.device_audio_max_bytes:
|
||||||
|
raise DeviceAudioCacheError("audio file is too large")
|
||||||
|
if not content:
|
||||||
|
raise DeviceAudioCacheError("audio file is empty")
|
||||||
|
return content, content_type
|
||||||
|
|
||||||
|
def _find_cached_file(self, cache_key: str) -> Path | None:
|
||||||
|
cache_dir = self.cache_dir()
|
||||||
|
if not cache_dir.exists():
|
||||||
|
return None
|
||||||
|
matches = list(cache_dir.glob(f"{cache_key}.*"))
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
def _resolve_extension(self, *, source: str, content_type: str | None) -> str:
|
||||||
|
normalized_content_type = (content_type or "").split(";", 1)[0].strip().lower()
|
||||||
|
if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_EXT:
|
||||||
|
return _AUDIO_CONTENT_TYPE_TO_EXT[normalized_content_type]
|
||||||
|
|
||||||
|
suffix = Path(urlparse(source).path).suffix.lower().lstrip(".")
|
||||||
|
if suffix in {"mp3", "aac", "m4a", "wav", "webm"}:
|
||||||
|
return suffix
|
||||||
|
|
||||||
|
guessed = mimetypes.guess_extension(normalized_content_type or "")
|
||||||
|
if guessed:
|
||||||
|
extension = guessed.lstrip(".")
|
||||||
|
if extension in {"mp3", "aac", "m4a", "wav", "webm"}:
|
||||||
|
return extension
|
||||||
|
return "mp3"
|
||||||
|
|
||||||
|
def _public_url(self, filename: str) -> str:
|
||||||
|
return f"{self.public_base_url()}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
device_audio_cache_service = DeviceAudioCacheService()
|
||||||
@@ -125,8 +125,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(prepared_audio.filepath, "rb") as archive_file:
|
archive_audio_data = await asyncio.to_thread(
|
||||||
archive_audio_data = archive_file.read()
|
self._read_file_bytes,
|
||||||
|
prepared_audio.filepath,
|
||||||
|
)
|
||||||
|
|
||||||
session_logger.info(
|
session_logger.info(
|
||||||
sender_device_id,
|
sender_device_id,
|
||||||
@@ -245,7 +247,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path:
|
if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path:
|
||||||
if os.path.exists(prepared_audio.filepath):
|
if os.path.exists(prepared_audio.filepath):
|
||||||
os.remove(prepared_audio.filepath)
|
await asyncio.to_thread(os.remove, prepared_audio.filepath)
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
async def _prepare_archive_audio(
|
async def _prepare_archive_audio(
|
||||||
@@ -255,8 +257,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
|||||||
archive_id: str,
|
archive_id: str,
|
||||||
local_audio_path: str,
|
local_audio_path: str,
|
||||||
) -> PreparedArchiveAudio | None:
|
) -> PreparedArchiveAudio | None:
|
||||||
with open(local_audio_path, "rb") as local_file:
|
local_audio_data = await asyncio.to_thread(
|
||||||
local_audio_data = local_file.read()
|
self._read_file_bytes,
|
||||||
|
local_audio_path,
|
||||||
|
)
|
||||||
|
|
||||||
source_format = detect_audio_format(local_audio_data)
|
source_format = detect_audio_format(local_audio_data)
|
||||||
local_size = len(local_audio_data)
|
local_size = len(local_audio_data)
|
||||||
@@ -288,8 +292,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
|||||||
mp3_path = f"{local_audio_path}.archive.mp3"
|
mp3_path = f"{local_audio_path}.archive.mp3"
|
||||||
try:
|
try:
|
||||||
if source_format == "wav":
|
if source_format == "wav":
|
||||||
with open(wav_path, "wb") as wav_file:
|
await asyncio.to_thread(
|
||||||
wav_file.write(local_audio_data)
|
self._write_file_bytes,
|
||||||
|
wav_path,
|
||||||
|
local_audio_data,
|
||||||
|
)
|
||||||
session_logger.info(
|
session_logger.info(
|
||||||
sender_device_id,
|
sender_device_id,
|
||||||
archive_id,
|
archive_id,
|
||||||
@@ -302,8 +309,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
|||||||
channels=DEFAULT_CHANNELS,
|
channels=DEFAULT_CHANNELS,
|
||||||
sample_width=DEFAULT_SAMPLE_WIDTH,
|
sample_width=DEFAULT_SAMPLE_WIDTH,
|
||||||
)
|
)
|
||||||
with open(wav_path, "wb") as wav_file:
|
await asyncio.to_thread(
|
||||||
wav_file.write(wrapped_wav)
|
self._write_file_bytes,
|
||||||
|
wav_path,
|
||||||
|
wrapped_wav,
|
||||||
|
)
|
||||||
session_logger.info(
|
session_logger.info(
|
||||||
sender_device_id,
|
sender_device_id,
|
||||||
archive_id,
|
archive_id,
|
||||||
@@ -320,7 +330,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
|||||||
source_path=wav_path,
|
source_path=wav_path,
|
||||||
target_path=mp3_path,
|
target_path=mp3_path,
|
||||||
)
|
)
|
||||||
mp3_size = os.path.getsize(mp3_path)
|
mp3_size = await asyncio.to_thread(os.path.getsize, mp3_path)
|
||||||
session_logger.info(
|
session_logger.info(
|
||||||
sender_device_id,
|
sender_device_id,
|
||||||
archive_id,
|
archive_id,
|
||||||
@@ -339,7 +349,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(wav_path):
|
if os.path.exists(wav_path):
|
||||||
os.remove(wav_path)
|
await asyncio.to_thread(os.remove, wav_path)
|
||||||
|
|
||||||
async def _convert_to_mp3(
|
async def _convert_to_mp3(
|
||||||
self,
|
self,
|
||||||
@@ -423,5 +433,13 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
|||||||
"child_name": row["child_name"],
|
"child_name": row["child_name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _read_file_bytes(self, filepath: str) -> bytes:
|
||||||
|
with open(filepath, "rb") as file:
|
||||||
|
return file.read()
|
||||||
|
|
||||||
|
def _write_file_bytes(self, filepath: str, content: bytes) -> None:
|
||||||
|
with open(filepath, "wb") as file:
|
||||||
|
file.write(content)
|
||||||
|
|
||||||
|
|
||||||
device_voice_archive_service = DeviceVoiceArchiveService()
|
device_voice_archive_service = DeviceVoiceArchiveService()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
from services.offline_audio_cache import offline_audio_cache
|
from services.offline_audio_cache import offline_audio_cache
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from services.database_service_base import DatabaseServiceBase
|
from services.database_service_base import DatabaseServiceBase
|
||||||
|
from banban.service.device_audio_cache import device_audio_cache_service
|
||||||
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
|
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
|
||||||
from banban.service.binding import BindingService
|
from banban.service.binding import BindingService
|
||||||
try:
|
try:
|
||||||
@@ -19,7 +20,6 @@ try:
|
|||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
|
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
|
||||||
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
|
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
|
||||||
from handlers.audio_file_handler import message_audio_storage_service
|
|
||||||
from utils.logger import session_logger
|
from utils.logger import session_logger
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +152,28 @@ async def present_message_item(
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
async def present_device_message_item(
|
||||||
|
row: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
device_id: str,
|
||||||
|
) -> ChildConversationMessageItem:
|
||||||
|
item = row_to_message_item(row)
|
||||||
|
if item.content_type == 2 and item.media_file_key:
|
||||||
|
try:
|
||||||
|
item.media_file_key = await device_audio_cache_service.get_device_audio_url(
|
||||||
|
item.media_file_key,
|
||||||
|
device_id=device_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
session_logger.error(
|
||||||
|
device_id,
|
||||||
|
"device_audio",
|
||||||
|
f"failed to prepare device audio url: {exc}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
class ImService(DatabaseServiceBase):
|
class ImService(DatabaseServiceBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(service_name="im_service")
|
super().__init__(service_name="im_service")
|
||||||
@@ -292,9 +314,12 @@ class ImService(DatabaseServiceBase):
|
|||||||
binding_service = BindingService()
|
binding_service = BindingService()
|
||||||
device = await binding_service.get_current_binding(parent_user_id)
|
device = await binding_service.get_current_binding(parent_user_id)
|
||||||
try:
|
try:
|
||||||
audio_url = await message_audio_storage_service.get_audio_url(stored_audio.file_key)
|
audio_url = await device_audio_cache_service.get_device_audio_url(
|
||||||
|
stored_audio.file_key,
|
||||||
|
device_id=device.device_id,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
session_logger.error(device.device_id, "audio", f"failed to get audio url: {stored_audio.file_key}", exc_info=True)
|
session_logger.error(device.device_id, "audio", f"failed to get device audio url: {stored_audio.file_key}", exc_info=True)
|
||||||
audio_url = stored_audio.file_key
|
audio_url = stored_audio.file_key
|
||||||
await offline_audio_cache.add_audio_url(device.device_id, f"{audio_url}")
|
await offline_audio_cache.add_audio_url(device.device_id, f"{audio_url}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class LocationService(DatabaseServiceBase):
|
|||||||
altitude_m: float | None = None,
|
altitude_m: float | None = None,
|
||||||
speed_mps: float | None = None,
|
speed_mps: float | None = None,
|
||||||
heading_deg: int | None = None,
|
heading_deg: int | None = None,
|
||||||
source: int | None = None,
|
source: int | str | None = None,
|
||||||
battery_pct: int | None = None,
|
battery_pct: int | None = None,
|
||||||
device_time: datetime | None = None,
|
device_time: datetime | None = None,
|
||||||
) -> Mapping[str, Any] | None:
|
) -> Mapping[str, Any] | None:
|
||||||
@@ -129,6 +129,8 @@ class LocationService(DatabaseServiceBase):
|
|||||||
battery_pct: int | None
|
battery_pct: int | None
|
||||||
device_time: datetime
|
device_time: datetime
|
||||||
|
|
||||||
|
source_value = self._normalize_location_source(source)
|
||||||
|
|
||||||
db_session = await self.get_session()
|
db_session = await self.get_session()
|
||||||
try:
|
try:
|
||||||
dao = LocationDAO(db_session)
|
dao = LocationDAO(db_session)
|
||||||
@@ -144,7 +146,7 @@ class LocationService(DatabaseServiceBase):
|
|||||||
altitude_m=altitude_m,
|
altitude_m=altitude_m,
|
||||||
speed_mps=speed_mps,
|
speed_mps=speed_mps,
|
||||||
heading_deg=heading_deg,
|
heading_deg=heading_deg,
|
||||||
source=source if source is not None else 0,
|
source=source_value,
|
||||||
battery_pct=battery_pct,
|
battery_pct=battery_pct,
|
||||||
device_time=device_time or datetime.now(),
|
device_time=device_time or datetime.now(),
|
||||||
)
|
)
|
||||||
@@ -156,5 +158,25 @@ class LocationService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_location_source(source: int | str | None) -> int:
|
||||||
|
if source is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(source, int):
|
||||||
|
return source
|
||||||
|
text = str(source).strip().lower()
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
if text.isdigit():
|
||||||
|
return int(text)
|
||||||
|
return {
|
||||||
|
"gps": 1,
|
||||||
|
"wifi": 2,
|
||||||
|
"cell": 3,
|
||||||
|
"base_station": 3,
|
||||||
|
"manual": 4,
|
||||||
|
"mock": 9,
|
||||||
|
}.get(text, 0)
|
||||||
|
|
||||||
# 创建全局 LocationService 实例
|
# 创建全局 LocationService 实例
|
||||||
location_service = LocationService()
|
location_service = LocationService()
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ class Settings(BaseSettings):
|
|||||||
default=2 * 1024 * 1024,
|
default=2 * 1024 * 1024,
|
||||||
validation_alias="COS_AVATAR_MAX_BYTES",
|
validation_alias="COS_AVATAR_MAX_BYTES",
|
||||||
)
|
)
|
||||||
|
device_audio_cache_dir: str = Field(
|
||||||
|
default="runtime/device-audio",
|
||||||
|
validation_alias="DEVICE_AUDIO_CACHE_DIR",
|
||||||
|
)
|
||||||
|
device_audio_public_base_url: str = Field(
|
||||||
|
default="",
|
||||||
|
validation_alias="DEVICE_AUDIO_PUBLIC_BASE_URL",
|
||||||
|
)
|
||||||
|
device_audio_max_bytes: int = Field(
|
||||||
|
default=20 * 1024 * 1024,
|
||||||
|
validation_alias="DEVICE_AUDIO_MAX_BYTES",
|
||||||
|
)
|
||||||
|
|
||||||
jwt_secret: str = Field(
|
jwt_secret: str = Field(
|
||||||
default="dev_only_change_jwt_secret",
|
default="dev_only_change_jwt_secret",
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ class TalkingQMQTTService:
|
|||||||
|
|
||||||
async def _handle_device_info(self, device_id: str, payload: dict):
|
async def _handle_device_info(self, device_id: str, payload: dict):
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
|
d_id = data.get("id")
|
||||||
|
power = data.get("power")
|
||||||
|
signal = data.get("signal")
|
||||||
|
version = data.get("version")
|
||||||
|
voice = data.get("voice")
|
||||||
|
logger.info(device_id, "", f"[设备信息] 设备 {d_id} 信息: 电量={power}, 信号强度={signal}, 版本号={version}, 音量={voice}")
|
||||||
|
|
||||||
await self._schedule_persistence(
|
await self._schedule_persistence(
|
||||||
device_id,
|
device_id,
|
||||||
"device_info",
|
"device_info",
|
||||||
@@ -147,7 +154,10 @@ class TalkingQMQTTService:
|
|||||||
parsed_device_time = datetime.fromisoformat(raw_device_time.strip())
|
parsed_device_time = datetime.fromisoformat(raw_device_time.strip())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
parsed_device_time = None
|
parsed_device_time = None
|
||||||
|
lat = data.get("latitude")
|
||||||
|
lon = data.get("longitude")
|
||||||
|
logger.info(device_id, "", f"[GPS] 设备 {device_id} 位置: 纬度={lat}, 经度={lon}")
|
||||||
|
|
||||||
await self._schedule_persistence(
|
await self._schedule_persistence(
|
||||||
device_id,
|
device_id,
|
||||||
"gps",
|
"gps",
|
||||||
@@ -175,6 +185,8 @@ class TalkingQMQTTService:
|
|||||||
current_level = data.get("current_level")
|
current_level = data.get("current_level")
|
||||||
if current_level is None:
|
if current_level is None:
|
||||||
return
|
return
|
||||||
|
logger.info(device_id, "", f"[音量] 设备 {device_id} 当前音量: {current_level}")
|
||||||
|
|
||||||
await self._schedule_persistence(
|
await self._schedule_persistence(
|
||||||
device_id,
|
device_id,
|
||||||
"volume",
|
"volume",
|
||||||
@@ -197,6 +209,10 @@ class TalkingQMQTTService:
|
|||||||
progress_value = None
|
progress_value = None
|
||||||
|
|
||||||
if status == "accepted":
|
if status == "accepted":
|
||||||
|
current = data.get("current_version")
|
||||||
|
target = data.get("target_version")
|
||||||
|
logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target}")
|
||||||
|
|
||||||
await self._schedule_persistence(
|
await self._schedule_persistence(
|
||||||
device_id,
|
device_id,
|
||||||
"ota",
|
"ota",
|
||||||
@@ -220,6 +236,8 @@ class TalkingQMQTTService:
|
|||||||
)
|
)
|
||||||
elif status == "success":
|
elif status == "success":
|
||||||
new_version = data.get("new_version") or data.get("version")
|
new_version = data.get("new_version") or data.get("version")
|
||||||
|
logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_version}")
|
||||||
|
|
||||||
await self._schedule_persistence(
|
await self._schedule_persistence(
|
||||||
device_id,
|
device_id,
|
||||||
"ota",
|
"ota",
|
||||||
@@ -431,43 +449,10 @@ class TalkingQMQTTService:
|
|||||||
|
|
||||||
is_owner = await card_service.check_card_ownership(nfc_uuid, device_id)
|
is_owner = await card_service.check_card_ownership(nfc_uuid, device_id)
|
||||||
if is_owner:
|
if is_owner:
|
||||||
has_pending = await offline_audio_cache.has_pending_audio(device_id)
|
audio_urls = await offline_audio_cache.pop_audio_urls(device_id)
|
||||||
if nfc_uuid == "53C22B6DA20001":
|
if audio_urls:
|
||||||
payload = {
|
params = {f"url_{k}": audio_url for k, audio_url in enumerate(audio_urls, start=1)}
|
||||||
"msg_id": "005",
|
payload = {"msg_id": "005", "type": 0, "params": params}
|
||||||
"type": 0,
|
|
||||||
"params": {
|
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/test_zh.mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return
|
|
||||||
|
|
||||||
if has_pending:
|
|
||||||
audio_urls = await offline_audio_cache.get_audio_urls(device_id)
|
|
||||||
# 53D92B6DA20001 测试卡片
|
|
||||||
if nfc_uuid == "53C22B6DA20001":
|
|
||||||
payload = {
|
|
||||||
"msg_id": "005",
|
|
||||||
"type": 0,
|
|
||||||
"params": {
|
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/TalkingQ_XQSN00001005_2f654480.mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return
|
|
||||||
if len(audio_urls) == 0:
|
|
||||||
payload = {
|
|
||||||
"msg_id": "005",
|
|
||||||
"type": 0,
|
|
||||||
"params": {
|
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/no_message.mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
params = {f"url_{k}": audio_url for k, audio_url in enumerate(audio_urls, start=1)}
|
|
||||||
payload = {"msg_id": "005", "type": 0, "params": params}
|
|
||||||
await offline_audio_cache.clear_audio_urls(device_id)
|
|
||||||
await self._publish(topic, payload)
|
await self._publish(topic, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import asyncio
|
|||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
from handlers.audio_packet_parser import parse_packet
|
from handlers.audio_packet_parser import parse_packet
|
||||||
from handlers.audio_session_handler import handle_websocket_data
|
from handlers.audio_session_handler import handle_websocket_data
|
||||||
from handlers.audio_file_handler import message_audio_storage_service, save_audio_file, upload_message_audio
|
from handlers.audio_file_handler import upload_message_audio
|
||||||
|
from banban.service.device_audio_cache import device_audio_cache_service
|
||||||
|
from banban.service.device_voice_archive import device_voice_archive_service
|
||||||
from services.audio_session import audio_session_manager
|
from services.audio_session import audio_session_manager
|
||||||
from services.interrupt_handler import interrupt_handler
|
from services.interrupt_handler import interrupt_handler
|
||||||
from services.task_manager import task_manager
|
from services.task_manager import task_manager
|
||||||
@@ -329,35 +331,32 @@ async def process_parent_leave_message(device_id: str, audio_cache_key: str):
|
|||||||
async def process_cached_audio(device_id: str, target_device_id: str, serial_number: str):
|
async def process_cached_audio(device_id: str, target_device_id: str, serial_number: str):
|
||||||
"""处理缓存的音频数据并发送音频URL"""
|
"""处理缓存的音频数据并发送音频URL"""
|
||||||
try:
|
try:
|
||||||
# 获取缓存的音频数据
|
|
||||||
cached_audio = await target_audio_cache.get_audio_data(target_device_id)
|
cached_audio = await target_audio_cache.get_audio_data(target_device_id)
|
||||||
if not cached_audio:
|
if not cached_audio:
|
||||||
session_logger.info(device_id, "target", f"目标设备 {target_device_id} 没有缓存的音频数据")
|
session_logger.info(device_id, "target", f"目标设备 {target_device_id} 没有缓存的音频数据")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 保存音频文件
|
audio_url, local_audio_path = await device_audio_cache_service.save_device_audio(
|
||||||
audio_file_key = await save_audio_file(cached_audio, device_id)
|
cached_audio,
|
||||||
audio_url = f"http://{settings.server_host}:{settings.server_port}/{audio_file_key}"
|
device_id=target_device_id,
|
||||||
# 将音频URL保存到数据库 im_conversation和im_message
|
)
|
||||||
# await im_conversation_service.create_device_message(device_id=device_id, serial_number=serial_number, target_device_id=target_device_id, audio_url=audio_file_key)
|
|
||||||
# try:
|
|
||||||
# audio_url = await message_audio_storage_service.get_audio_url(audio_file_key)
|
|
||||||
# except Exception:
|
|
||||||
# audio_url = audio_file_key
|
|
||||||
# 发送URL给目标设备
|
|
||||||
# target_websocket = await connection_manager.get_connection(target_device_id)
|
|
||||||
# if target_websocket and target_websocket.client_state.name == "CONNECTED":
|
|
||||||
# await target_websocket.send_text("TTS_START")
|
|
||||||
# session_logger.info(device_id, "target", "已发送 TTS_START 给客户端")
|
|
||||||
# await target_websocket.send_text(f"NFC_SOUND_URL:{audio_url}")
|
|
||||||
# session_logger.info(device_id, "target", f"已发送音频URL给目标设备 {target_device_id}: {audio_url}")
|
|
||||||
# await target_websocket.send_text("TTS_END")
|
|
||||||
# session_logger.info(device_id, "target", "已发送 TTS_END 给客户端")
|
|
||||||
# else:
|
|
||||||
|
|
||||||
# # 目标设备不在线,保存到离线缓存
|
|
||||||
await offline_audio_cache.add_audio_url(target_device_id, audio_url)
|
await offline_audio_cache.add_audio_url(target_device_id, audio_url)
|
||||||
session_logger.warning(device_id, "target", f"目标设备 {target_device_id} 不在线,保存音频URL到离线缓存")
|
session_logger.info(
|
||||||
|
device_id,
|
||||||
|
"target",
|
||||||
|
f"设备留言已加入待收听队列: target_device_id={target_device_id}, audio_url={audio_url}",
|
||||||
|
)
|
||||||
|
|
||||||
|
await task_manager.create_task(
|
||||||
|
device_voice_archive_service.archive_peer_voice_message(
|
||||||
|
sender_device_id=device_id,
|
||||||
|
receiver_device_id=target_device_id,
|
||||||
|
local_audio_path=str(local_audio_path),
|
||||||
|
),
|
||||||
|
device_id=device_id,
|
||||||
|
task_type="device_voice_archive",
|
||||||
|
)
|
||||||
|
|
||||||
websocket = await connection_manager.get_connection(device_id)
|
websocket = await connection_manager.get_connection(device_id)
|
||||||
if websocket and websocket.client_state.name == "CONNECTED":
|
if websocket and websocket.client_state.name == "CONNECTED":
|
||||||
success_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/message_ok.mp3"
|
success_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/message_ok.mp3"
|
||||||
@@ -365,18 +364,7 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num
|
|||||||
session_logger.info(device_id, "device", f"留言已收到音频URL给设备 {device_id}")
|
session_logger.info(device_id, "device", f"留言已收到音频URL给设备 {device_id}")
|
||||||
else:
|
else:
|
||||||
session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,暂不发送留言已收到音频URL")
|
session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,暂不发送留言已收到音频URL")
|
||||||
# # # 目标设备不在线,保存到离线缓存
|
|
||||||
# await offline_audio_cache.add_audio_url(target_device_id, audio_url)
|
|
||||||
# session_logger.warning(device_id, "target", f"目标设备 {target_device_id} 不在线,保存音频URL到离线缓存")
|
|
||||||
# websocket = await connection_manager.get_connection(device_id)
|
|
||||||
# if websocket and websocket.client_state.name == "CONNECTED":
|
|
||||||
# success_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/success_zh.mp3"
|
|
||||||
# await websocket.send_text(f"NFC_MESSAGE_SUCCESS_URL:{success_audio_url}")
|
|
||||||
# session_logger.info(device_id, "device", f"已发送留言成功音频URL给设备 {device_id}")
|
|
||||||
# else:
|
|
||||||
# session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,发送留言成功音频失败")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 处理HTTPException异常
|
|
||||||
if isinstance(e, HTTPException):
|
if isinstance(e, HTTPException):
|
||||||
session_logger.error(device_id, "target", f"保存音频文件时出错,返回HTTPException: {e}")
|
session_logger.error(device_id, "target", f"保存音频文件时出错,返回HTTPException: {e}")
|
||||||
websocket = await connection_manager.get_connection(device_id)
|
websocket = await connection_manager.get_connection(device_id)
|
||||||
|
|||||||
1
talkingq-url/scripts/gen.sh
Normal file
1
talkingq-url/scripts/gen.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python generate_bind_qr.py TalkingQ_XQSN00001005 TQ_XQSN000000001005 -o ./my_qr.png
|
||||||
@@ -132,7 +132,8 @@ def main() -> int:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
print(f"Error: {exc}", file=sys.stderr)
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
device_id = 'TalkingQ_XQSN00001005'
|
||||||
|
serial_number = 'TQ_XQSN000000001005'
|
||||||
payload = build_payload(device_id, serial_number)
|
payload = build_payload(device_id, serial_number)
|
||||||
output_path = resolve_output_path(device_id, args.output)
|
output_path = resolve_output_path(device_id, args.output)
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ class OfflineAudioCache:
|
|||||||
|
|
||||||
async def get_audio_urls(self, device_id: str) -> List[str]:
|
async def get_audio_urls(self, device_id: str) -> List[str]:
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
return self.offline_audio.get(device_id, [])
|
return list(self.offline_audio.get(device_id, []))
|
||||||
|
|
||||||
|
async def pop_audio_urls(self, device_id: str) -> List[str]:
|
||||||
|
async with self.lock:
|
||||||
|
return self.offline_audio.pop(device_id, [])
|
||||||
|
|
||||||
async def clear_audio_urls(self, device_id: str):
|
async def clear_audio_urls(self, device_id: str):
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
@@ -31,4 +35,4 @@ class OfflineAudioCache:
|
|||||||
return self.offline_audio
|
return self.offline_audio
|
||||||
|
|
||||||
# 单例实例
|
# 单例实例
|
||||||
offline_audio_cache = OfflineAudioCache()
|
offline_audio_cache = OfflineAudioCache()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
DB_HOST=mysql
|
DB_HOST=101.35.224.118
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_USER=talkingq
|
DB_USER=banban
|
||||||
DB_PASSWORD="D7f!9xL#qP2z@Vk&"
|
DB_PASSWORD="PT4MBdKk7Gptt3Sx"
|
||||||
DB_NAME=talkingq
|
DB_NAME=banban
|
||||||
DB_ECHO=false
|
DB_ECHO=false
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ class RoleImporter:
|
|||||||
'description': role_config.get('description', ''),
|
'description': role_config.get('description', ''),
|
||||||
'content': role_config.get('content', ''),
|
'content': role_config.get('content', ''),
|
||||||
'default_language': role_config.get('default_language'),
|
'default_language': role_config.get('default_language'),
|
||||||
'asr_provider': role_config.get('asr_provider'),
|
# 'asr_provider': role_config.get('asr_provider'),
|
||||||
'llm_provider': role_config.get('llm_provider'),
|
# 'llm_provider': role_config.get('llm_provider'),
|
||||||
'tts_provider': role_config.get('tts_provider'),
|
# 'tts_provider': role_config.get('tts_provider'),
|
||||||
# 'aws_language_code': role_config.get('aws_language_code'),
|
# 'aws_language_code': role_config.get('aws_language_code'),
|
||||||
'volcano_model_id': role_config.get('volcano_model_id'),
|
'volcano_model_id': role_config.get('volcano_model_id'),
|
||||||
# 'volcano_voice_type': role_config.get('volcano_voice_type'),
|
# 'volcano_voice_type': role_config.get('volcano_voice_type'),
|
||||||
@@ -97,9 +97,9 @@ class RoleImporter:
|
|||||||
'language_code': lang_code,
|
'language_code': lang_code,
|
||||||
'name': lang_config.get('name'),
|
'name': lang_config.get('name'),
|
||||||
'content': lang_config.get('content'),
|
'content': lang_config.get('content'),
|
||||||
'asr_provider': lang_config.get('asr_provider'),
|
# 'asr_provider': lang_config.get('asr_provider'),
|
||||||
'llm_provider': lang_config.get('llm_provider'),
|
# 'llm_provider': lang_config.get('llm_provider'),
|
||||||
'tts_provider': lang_config.get('tts_provider'),
|
# 'tts_provider': lang_config.get('tts_provider'),
|
||||||
# 'aws_language_code': lang_config.get('aws_language_code'),
|
# 'aws_language_code': lang_config.get('aws_language_code'),
|
||||||
# 'volcano_voice_type': lang_config.get('volcano_voice_type'),
|
# 'volcano_voice_type': lang_config.get('volcano_voice_type'),
|
||||||
# 'tencent_voice_type': lang_config.get('tencent_voice_type'),
|
# 'tencent_voice_type': lang_config.get('tencent_voice_type'),
|
||||||
|
|||||||
Reference in New Issue
Block a user