Compare commits
3 Commits
test-clean
...
2639ab06ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2639ab06ab | ||
|
|
d93bba10b9 | ||
|
|
f30c1b0679 |
@@ -380,6 +380,10 @@
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.role-switch-card {
|
||||
max-height: 74vh;
|
||||
}
|
||||
|
||||
.device-switch-empty {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
@@ -392,6 +396,11 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.role-switch-list {
|
||||
max-height: 56vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-switch-footer {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -10,7 +10,16 @@ import {
|
||||
unbindDevice,
|
||||
} from '@/services/binding'
|
||||
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 './index.scss'
|
||||
|
||||
@@ -31,10 +40,15 @@ export default function Sleep() {
|
||||
const [binding, setBinding] = useState<BindingListItem | null>(null)
|
||||
const [currentChild, setCurrentChild] = useState<Child | 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 [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false)
|
||||
const [isLoadingRoles, setIsLoadingRoles] = useState(false)
|
||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showChildModal, setShowChildModal] = useState(false)
|
||||
const [showRoleModal, setShowRoleModal] = useState(false)
|
||||
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
||||
const [childName, setChildName] = useState('')
|
||||
const [editingChildId, setEditingChildId] = useState<number | null>(null)
|
||||
@@ -60,6 +74,7 @@ export default function Sleep() {
|
||||
setBinding(nextBinding)
|
||||
setParentInfo(Taro.getStorageSync('userInfo') || {})
|
||||
void loadFirmwareStatus(nextBinding?.device_id)
|
||||
void loadRoleData(nextBinding?.device_id)
|
||||
} catch (error: any) {
|
||||
console.error('[manage] load failed:', error)
|
||||
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) => {
|
||||
setModalType(type)
|
||||
setChildName(child?.child_name || '')
|
||||
@@ -121,6 +166,7 @@ export default function Sleep() {
|
||||
setCurrentChild(child)
|
||||
setBinding(nextBinding)
|
||||
void loadFirmwareStatus(nextBinding?.device_id)
|
||||
void loadRoleData(nextBinding?.device_id)
|
||||
setShowChildModal(false)
|
||||
Taro.showToast({
|
||||
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 = () => {
|
||||
if (!binding) return
|
||||
|
||||
@@ -268,6 +345,11 @@ export default function Sleep() {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.name === 'AI 角色') {
|
||||
handleOpenRoleModal()
|
||||
return
|
||||
}
|
||||
|
||||
if (item.name === '解除设备绑定') {
|
||||
handleUnbind()
|
||||
}
|
||||
@@ -289,6 +371,11 @@ export default function Sleep() {
|
||||
const latestFirmwareLabel = firmwareStatus?.latest_version || '--'
|
||||
const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware)
|
||||
const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新'
|
||||
const roleValue = !binding?.device_id
|
||||
? '未绑定'
|
||||
: isLoadingRoles
|
||||
? '查询中'
|
||||
: deviceRole?.name || '未设置'
|
||||
const firmwareSubtitle = !binding?.device_id
|
||||
? '绑定设备后可查看系统版本'
|
||||
: isLoadingFirmware
|
||||
@@ -325,6 +412,14 @@ export default function Sleep() {
|
||||
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
|
||||
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'),
|
||||
iconBgClass: 'red',
|
||||
@@ -501,6 +596,49 @@ export default function Sleep() {
|
||||
</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}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -88,6 +88,30 @@ interface DeviceAiMessageListResponse {
|
||||
next_cursor?: number | null
|
||||
}
|
||||
|
||||
interface DeviceAiConversationItem {
|
||||
conversation_id: number
|
||||
role_key: string
|
||||
role_name: string
|
||||
role_description?: string | null
|
||||
message_count: number
|
||||
last_message_preview?: string | null
|
||||
last_message_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface DeviceAiConversationListResponse {
|
||||
items: DeviceAiConversationItem[]
|
||||
total: number
|
||||
next_cursor?: number | null
|
||||
}
|
||||
|
||||
interface DeviceRoleSummary {
|
||||
role_key: string
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface ChildConversationItem {
|
||||
conversation_id: number
|
||||
conversation_type: number
|
||||
@@ -161,6 +185,8 @@ const AI_ROLE_META: Record<string, { name: string; icon: string; description: st
|
||||
assistant: { name: 'AI', icon: '🤖', description: '孩子与 AI 的历史交流' },
|
||||
}
|
||||
|
||||
type AiMeta = { name: string; icon: string; description: string }
|
||||
|
||||
export function isParentChildConversation(conversationTypeName?: string | null): boolean {
|
||||
return conversationTypeName === PARENT_CHILD_CONVERSATION_TYPE_NAME
|
||||
}
|
||||
@@ -225,7 +251,16 @@ function createClientMessageId(): string {
|
||||
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]
|
||||
return {
|
||||
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 {
|
||||
if (name && name.trim()) return name.trim()
|
||||
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[]> {
|
||||
const { childId } = context || (await getBindingContext())
|
||||
if (!childId) return []
|
||||
@@ -330,8 +395,10 @@ function toImConversation(item: ChildConversationItem, context: BindingContext):
|
||||
}
|
||||
}
|
||||
|
||||
function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): ChatConversation {
|
||||
const meta = getAiMeta(item.role_key)
|
||||
function toAiConversation(item: DeviceAiConversationItem, context: BindingContext, roleMetaMap: Record<string, AiMeta>): ChatConversation {
|
||||
const meta = roleMetaMap[item.role_key] || getAiMeta(item.role_key, item.role_name, item.role_description)
|
||||
const lastMessage = item.last_message_preview || (item.message_count > 0 ? '暂无内容' : '这个会话还没有消息')
|
||||
const sortAt = item.last_message_at || item.updated_at || item.created_at
|
||||
|
||||
return {
|
||||
id: item.conversation_id,
|
||||
@@ -342,9 +409,9 @@ function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): C
|
||||
avatar: meta.icon,
|
||||
typeLabel: PEER_META.ai.label,
|
||||
description: meta.description,
|
||||
lastMessage: item.content || '暂无消息',
|
||||
time: formatListTime(item.created_at),
|
||||
sortAt: item.created_at,
|
||||
lastMessage,
|
||||
time: formatListTime(sortAt),
|
||||
sortAt,
|
||||
roleKey: item.role_key,
|
||||
conversationTypeName: 'ai',
|
||||
peerId: item.role_key,
|
||||
@@ -386,19 +453,16 @@ function createSyntheticParentConversation(context: BindingContext): ChatConvers
|
||||
|
||||
export async function getConversations(): Promise<ChatConversation[]> {
|
||||
const context = await getBindingContext()
|
||||
const [imConversationItems, aiMessages] = await Promise.all([getChildConversations(context), getDeviceMessages(context)])
|
||||
const [imConversationItems, aiConversations, roleMetaMap] = await Promise.all([
|
||||
getChildConversations(context),
|
||||
getDeviceAiConversations(context),
|
||||
getRoleMetaMap(),
|
||||
])
|
||||
const imConversations = imConversationItems.map((item) => toImConversation(item, context))
|
||||
const aiConversationMap = new Map<number, DeviceAiMessageItem>()
|
||||
|
||||
for (const item of aiMessages) {
|
||||
if (!aiConversationMap.has(item.conversation_id)) {
|
||||
aiConversationMap.set(item.conversation_id, item)
|
||||
}
|
||||
}
|
||||
|
||||
const conversations = [
|
||||
...imConversations,
|
||||
...Array.from(aiConversationMap.values()).map((item) => toAiConversation(item, context)),
|
||||
...aiConversations.map((item) => toAiConversation(item, context, roleMetaMap)),
|
||||
]
|
||||
|
||||
const hasParentConversation = conversations.some(
|
||||
@@ -429,12 +493,14 @@ export async function getMessages(
|
||||
const context = await getBindingContext()
|
||||
|
||||
if (source === 'ai') {
|
||||
const items = await getDeviceMessages(context)
|
||||
const [items, roleMetaMap] = await Promise.all([getDeviceMessages(context), getRoleMetaMap()])
|
||||
return items
|
||||
.filter((item) => item.conversation_id === conversationId)
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item) => ({
|
||||
.map((item) => {
|
||||
const meta = roleMetaMap[item.role_key] || getAiMeta(item.role_key)
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.is_user ? 'user' : 'peer',
|
||||
content: item.content || '暂无内容',
|
||||
@@ -449,10 +515,11 @@ export async function getMessages(
|
||||
senderId: item.is_user ? context.deviceId : item.role_key,
|
||||
receiverType: item.is_user ? 'ai' : 'device',
|
||||
receiverId: item.is_user ? item.role_key : context.deviceId,
|
||||
senderName: item.is_user ? context.childName || '孩子设备' : getAiMeta(item.role_key).name,
|
||||
receiverName: item.is_user ? getAiMeta(item.role_key).name : context.childName || '孩子设备',
|
||||
senderName: item.is_user ? context.childName || '孩子设备' : meta.name,
|
||||
receiverName: item.is_user ? meta.name : context.childName || '孩子设备',
|
||||
channelLabel: 'AI',
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const items = await getChildConversationMessages(conversationId, context)
|
||||
|
||||
@@ -79,6 +79,23 @@ export interface DeviceFirmwareUpdateResponse extends DeviceFirmwareStatus {
|
||||
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 {
|
||||
if (value === null || value === undefined) return null
|
||||
const trimmed = String(value).trim()
|
||||
@@ -203,3 +220,40 @@ export async function getDeviceAlarms(
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
1
talkingq-url/.gitignore
vendored
1
talkingq-url/.gitignore
vendored
@@ -31,6 +31,7 @@ fullcode.md
|
||||
all.md
|
||||
*.sql.gz
|
||||
database_backups
|
||||
runtime/
|
||||
|
||||
# Docker
|
||||
*.log
|
||||
|
||||
@@ -13,4 +13,12 @@ def configure_static_assets(app):
|
||||
firmware_directory = os.path.join(settings.assets_dir, "firmware")
|
||||
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(
|
||||
"/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()
|
||||
|
||||
async def list_device_ai_conversations(
|
||||
self,
|
||||
*,
|
||||
device_id: str,
|
||||
cursor: int | None,
|
||||
limit: int,
|
||||
) -> List[Mapping[str, Any]]:
|
||||
params = {"device_id": device_id, "limit": limit + 1}
|
||||
where = "ch.device_id = :device_id"
|
||||
if cursor is not None:
|
||||
where += " AND ch.id < :cursor"
|
||||
params["cursor"] = cursor
|
||||
|
||||
result = await self.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
ch.id AS conversation_id,
|
||||
ch.role_key,
|
||||
COALESCE(r.name, ch.role_key) AS role_name,
|
||||
r.description AS role_description,
|
||||
COALESCE(stats.message_count, 0) AS message_count,
|
||||
latest.content AS last_message_preview,
|
||||
latest.created_at AS last_message_at,
|
||||
ch.last_interaction_time,
|
||||
ch.created_at,
|
||||
ch.updated_at
|
||||
FROM conversation_histories AS ch
|
||||
LEFT JOIN roles AS r
|
||||
ON r.role_key = ch.role_key
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
conversation_id,
|
||||
COUNT(*) AS message_count,
|
||||
MAX(id) AS latest_message_id
|
||||
FROM conversation_messages
|
||||
GROUP BY conversation_id
|
||||
) AS stats
|
||||
ON stats.conversation_id = ch.id
|
||||
LEFT JOIN conversation_messages AS latest
|
||||
ON latest.id = stats.latest_message_id
|
||||
WHERE {where}
|
||||
ORDER BY COALESCE(latest.created_at, ch.updated_at, ch.created_at) DESC, ch.id DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
return result.mappings().all()
|
||||
|
||||
async def get_device_status(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -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.im import router as im_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.mqtt_router import router as mqtt_router
|
||||
|
||||
@@ -22,6 +23,7 @@ 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(im_router, tags=["banban-im"])
|
||||
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"])
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ try:
|
||||
ConversationMessageCreateResponse,
|
||||
DeviceMessageCreateRequest,
|
||||
)
|
||||
from banban.service.im import im_service
|
||||
from banban.service.im import im_service, present_device_message_item
|
||||
except ModuleNotFoundError:
|
||||
from banban.service import get_db_session
|
||||
from banban.routers.im import (
|
||||
@@ -41,7 +41,7 @@ except ModuleNotFoundError:
|
||||
ConversationMessageCreateResponse,
|
||||
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"])
|
||||
@@ -247,7 +247,10 @@ async def list_device_conversation_messages(
|
||||
rows = rows[:limit]
|
||||
rows = list(rows)
|
||||
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
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, time
|
||||
from services.connection_manager import connection_manager
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
@@ -51,6 +50,24 @@ class DeviceMessageListResponse(BaseModel):
|
||||
next_cursor: int | None = None
|
||||
|
||||
|
||||
class DeviceAiConversationItem(BaseModel):
|
||||
conversation_id: int
|
||||
role_key: str
|
||||
role_name: str
|
||||
role_description: str | None = None
|
||||
message_count: int
|
||||
last_message_preview: str | None = None
|
||||
last_message_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DeviceAiConversationListResponse(BaseModel):
|
||||
items: list[DeviceAiConversationItem]
|
||||
total: int
|
||||
next_cursor: int | None = None
|
||||
|
||||
|
||||
class DeviceStatusResponse(BaseModel):
|
||||
device_id: str
|
||||
child_id: int | None = None
|
||||
@@ -159,6 +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:
|
||||
return DeviceLocationCurrentResponse(
|
||||
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)
|
||||
async def get_device_status(
|
||||
device_id: str,
|
||||
@@ -504,15 +572,12 @@ async def get_current_device_location(
|
||||
service = await TalkingQMQTTService.get_instance()
|
||||
if service is None:
|
||||
raise HTTPException(status_code=503, detail="MQTT 服务未初始化")
|
||||
query_started_at = datetime.now()
|
||||
await service.send_gps_query(device_id)
|
||||
try:
|
||||
# 每隔1s 获取一次GPS数据,最多3次
|
||||
# 先判断设备是否在线,不在线直接提示设备没有在线,通过websocket判断
|
||||
target_websocket = await connection_manager.get_connection(device_id)
|
||||
if not target_websocket or target_websocket.client_state.name != "CONNECTED":
|
||||
raise HTTPException(status_code=408, detail="设备未在线")
|
||||
i=0
|
||||
while i<1:
|
||||
# MQTT 设备没有 WebSocket 连接时,也可以通过 GPS 回包刷新定位。
|
||||
row = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
row = await location_service.get_device_current_location(
|
||||
device_id=device_id,
|
||||
@@ -522,11 +587,9 @@ async def get_current_device_location(
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
if row is None:
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
if row["updated_at"] is not None \
|
||||
and (datetime.now() - row['updated_at']) <= timedelta(seconds=10):
|
||||
if row is not None and row["updated_at"] is not None:
|
||||
updated_at = row["updated_at"]
|
||||
if updated_at >= query_started_at - timedelta(seconds=1):
|
||||
logger.info(
|
||||
"device current location fetched",
|
||||
extra={
|
||||
@@ -539,21 +602,12 @@ async def get_current_device_location(
|
||||
)
|
||||
return _row_to_current_location_response(row)
|
||||
|
||||
i+=1
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=408, detail="GPS数据上报超时")
|
||||
|
||||
logger.info(
|
||||
"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"]),
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=408, detail="GPS数据未刷新")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=408, detail=f"GPS数据上报失败: {e}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
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 banban.schemas.mqtt_models import (
|
||||
GPSQueryRequest,
|
||||
@@ -95,7 +96,11 @@ async def set_nfc_unread(
|
||||
req: NFCUnreadRequest,
|
||||
# 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="已设置未读留言")
|
||||
|
||||
|
||||
|
||||
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:
|
||||
await db_session.close()
|
||||
|
||||
async def list_device_ai_conversations(
|
||||
self,
|
||||
*,
|
||||
device_id: str,
|
||||
user_id: int,
|
||||
cursor: int | None,
|
||||
limit: int,
|
||||
) -> List[Mapping[str, Any]]:
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
dao = DeviceDAO(db_session)
|
||||
await dao.ensure_device_access(device_id=device_id, user_id=user_id)
|
||||
return await dao.list_device_ai_conversations(
|
||||
device_id=device_id,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
)
|
||||
finally:
|
||||
await db_session.close()
|
||||
|
||||
async def get_device_status(
|
||||
self,
|
||||
*,
|
||||
|
||||
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 = archive_file.read()
|
||||
archive_audio_data = await asyncio.to_thread(
|
||||
self._read_file_bytes,
|
||||
prepared_audio.filepath,
|
||||
)
|
||||
|
||||
session_logger.info(
|
||||
sender_device_id,
|
||||
@@ -245,7 +247,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
finally:
|
||||
if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path:
|
||||
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()
|
||||
|
||||
async def _prepare_archive_audio(
|
||||
@@ -255,8 +257,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
archive_id: str,
|
||||
local_audio_path: str,
|
||||
) -> PreparedArchiveAudio | None:
|
||||
with open(local_audio_path, "rb") as local_file:
|
||||
local_audio_data = local_file.read()
|
||||
local_audio_data = await asyncio.to_thread(
|
||||
self._read_file_bytes,
|
||||
local_audio_path,
|
||||
)
|
||||
|
||||
source_format = detect_audio_format(local_audio_data)
|
||||
local_size = len(local_audio_data)
|
||||
@@ -288,8 +292,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
mp3_path = f"{local_audio_path}.archive.mp3"
|
||||
try:
|
||||
if source_format == "wav":
|
||||
with open(wav_path, "wb") as wav_file:
|
||||
wav_file.write(local_audio_data)
|
||||
await asyncio.to_thread(
|
||||
self._write_file_bytes,
|
||||
wav_path,
|
||||
local_audio_data,
|
||||
)
|
||||
session_logger.info(
|
||||
sender_device_id,
|
||||
archive_id,
|
||||
@@ -302,8 +309,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
channels=DEFAULT_CHANNELS,
|
||||
sample_width=DEFAULT_SAMPLE_WIDTH,
|
||||
)
|
||||
with open(wav_path, "wb") as wav_file:
|
||||
wav_file.write(wrapped_wav)
|
||||
await asyncio.to_thread(
|
||||
self._write_file_bytes,
|
||||
wav_path,
|
||||
wrapped_wav,
|
||||
)
|
||||
session_logger.info(
|
||||
sender_device_id,
|
||||
archive_id,
|
||||
@@ -320,7 +330,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
source_path=wav_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(
|
||||
sender_device_id,
|
||||
archive_id,
|
||||
@@ -339,7 +349,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.remove(wav_path)
|
||||
await asyncio.to_thread(os.remove, wav_path)
|
||||
|
||||
async def _convert_to_mp3(
|
||||
self,
|
||||
@@ -423,5 +433,13 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
"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()
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from services.offline_audio_cache import offline_audio_cache
|
||||
from fastapi import HTTPException
|
||||
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.binding import BindingService
|
||||
try:
|
||||
@@ -19,7 +20,6 @@ try:
|
||||
except ModuleNotFoundError:
|
||||
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
|
||||
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
|
||||
from handlers.audio_file_handler import message_audio_storage_service
|
||||
from utils.logger import session_logger
|
||||
|
||||
|
||||
@@ -152,6 +152,28 @@ async def present_message_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):
|
||||
def __init__(self):
|
||||
super().__init__(service_name="im_service")
|
||||
@@ -292,9 +314,12 @@ class ImService(DatabaseServiceBase):
|
||||
binding_service = BindingService()
|
||||
device = await binding_service.get_current_binding(parent_user_id)
|
||||
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:
|
||||
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
|
||||
await offline_audio_cache.add_audio_url(device.device_id, f"{audio_url}")
|
||||
except Exception:
|
||||
|
||||
@@ -109,7 +109,7 @@ class LocationService(DatabaseServiceBase):
|
||||
altitude_m: float | None = None,
|
||||
speed_mps: float | None = None,
|
||||
heading_deg: int | None = None,
|
||||
source: int | None = None,
|
||||
source: int | str | None = None,
|
||||
battery_pct: int | None = None,
|
||||
device_time: datetime | None = None,
|
||||
) -> Mapping[str, Any] | None:
|
||||
@@ -129,6 +129,8 @@ class LocationService(DatabaseServiceBase):
|
||||
battery_pct: int | None
|
||||
device_time: datetime
|
||||
|
||||
source_value = self._normalize_location_source(source)
|
||||
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
dao = LocationDAO(db_session)
|
||||
@@ -144,7 +146,7 @@ class LocationService(DatabaseServiceBase):
|
||||
altitude_m=altitude_m,
|
||||
speed_mps=speed_mps,
|
||||
heading_deg=heading_deg,
|
||||
source=source if source is not None else 0,
|
||||
source=source_value,
|
||||
battery_pct=battery_pct,
|
||||
device_time=device_time or datetime.now(),
|
||||
)
|
||||
@@ -156,5 +158,25 @@ class LocationService(DatabaseServiceBase):
|
||||
finally:
|
||||
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 实例
|
||||
location_service = LocationService()
|
||||
|
||||
@@ -99,6 +99,18 @@ class Settings(BaseSettings):
|
||||
default=2 * 1024 * 1024,
|
||||
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(
|
||||
default="dev_only_change_jwt_secret",
|
||||
|
||||
@@ -121,6 +121,13 @@ class TalkingQMQTTService:
|
||||
|
||||
async def _handle_device_info(self, device_id: str, payload: dict):
|
||||
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(
|
||||
device_id,
|
||||
"device_info",
|
||||
@@ -147,6 +154,9 @@ class TalkingQMQTTService:
|
||||
parsed_device_time = datetime.fromisoformat(raw_device_time.strip())
|
||||
except ValueError:
|
||||
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(
|
||||
device_id,
|
||||
@@ -175,6 +185,8 @@ class TalkingQMQTTService:
|
||||
current_level = data.get("current_level")
|
||||
if current_level is None:
|
||||
return
|
||||
logger.info(device_id, "", f"[音量] 设备 {device_id} 当前音量: {current_level}")
|
||||
|
||||
await self._schedule_persistence(
|
||||
device_id,
|
||||
"volume",
|
||||
@@ -197,6 +209,10 @@ class TalkingQMQTTService:
|
||||
progress_value = None
|
||||
|
||||
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(
|
||||
device_id,
|
||||
"ota",
|
||||
@@ -220,6 +236,8 @@ class TalkingQMQTTService:
|
||||
)
|
||||
elif status == "success":
|
||||
new_version = data.get("new_version") or data.get("version")
|
||||
logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_version}")
|
||||
|
||||
await self._schedule_persistence(
|
||||
device_id,
|
||||
"ota",
|
||||
@@ -431,43 +449,10 @@ class TalkingQMQTTService:
|
||||
|
||||
is_owner = await card_service.check_card_ownership(nfc_uuid, device_id)
|
||||
if is_owner:
|
||||
has_pending = await offline_audio_cache.has_pending_audio(device_id)
|
||||
if nfc_uuid == "53C22B6DA20001":
|
||||
payload = {
|
||||
"msg_id": "005",
|
||||
"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:
|
||||
audio_urls = await offline_audio_cache.pop_audio_urls(device_id)
|
||||
if audio_urls:
|
||||
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)
|
||||
return
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import asyncio
|
||||
from fastapi import WebSocket
|
||||
from handlers.audio_packet_parser import parse_packet
|
||||
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.interrupt_handler import interrupt_handler
|
||||
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):
|
||||
"""处理缓存的音频数据并发送音频URL"""
|
||||
try:
|
||||
# 获取缓存的音频数据
|
||||
cached_audio = await target_audio_cache.get_audio_data(target_device_id)
|
||||
if not cached_audio:
|
||||
session_logger.info(device_id, "target", f"目标设备 {target_device_id} 没有缓存的音频数据")
|
||||
return
|
||||
|
||||
# 保存音频文件
|
||||
audio_file_key = await save_audio_file(cached_audio, device_id)
|
||||
audio_url = f"http://{settings.server_host}:{settings.server_port}/{audio_file_key}"
|
||||
# 将音频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:
|
||||
|
||||
# # 目标设备不在线,保存到离线缓存
|
||||
audio_url, local_audio_path = await device_audio_cache_service.save_device_audio(
|
||||
cached_audio,
|
||||
device_id=target_device_id,
|
||||
)
|
||||
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)
|
||||
if websocket and websocket.client_state.name == "CONNECTED":
|
||||
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}")
|
||||
else:
|
||||
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:
|
||||
# 处理HTTPException异常
|
||||
if isinstance(e, HTTPException):
|
||||
session_logger.error(device_id, "target", f"保存音频文件时出错,返回HTTPException: {e}")
|
||||
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:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
device_id = 'TalkingQ_XQSN00001005'
|
||||
serial_number = 'TQ_XQSN000000001005'
|
||||
payload = build_payload(device_id, serial_number)
|
||||
output_path = resolve_output_path(device_id, args.output)
|
||||
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 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 with self.lock:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=mysql
|
||||
DB_HOST=101.35.224.118
|
||||
DB_PORT=3306
|
||||
DB_USER=talkingq
|
||||
DB_PASSWORD="D7f!9xL#qP2z@Vk&"
|
||||
DB_NAME=talkingq
|
||||
DB_USER=banban
|
||||
DB_PASSWORD="PT4MBdKk7Gptt3Sx"
|
||||
DB_NAME=banban
|
||||
DB_ECHO=false
|
||||
|
||||
@@ -76,9 +76,9 @@ class RoleImporter:
|
||||
'description': role_config.get('description', ''),
|
||||
'content': role_config.get('content', ''),
|
||||
'default_language': role_config.get('default_language'),
|
||||
'asr_provider': role_config.get('asr_provider'),
|
||||
'llm_provider': role_config.get('llm_provider'),
|
||||
'tts_provider': role_config.get('tts_provider'),
|
||||
# 'asr_provider': role_config.get('asr_provider'),
|
||||
# 'llm_provider': role_config.get('llm_provider'),
|
||||
# 'tts_provider': role_config.get('tts_provider'),
|
||||
# 'aws_language_code': role_config.get('aws_language_code'),
|
||||
'volcano_model_id': role_config.get('volcano_model_id'),
|
||||
# 'volcano_voice_type': role_config.get('volcano_voice_type'),
|
||||
@@ -97,9 +97,9 @@ class RoleImporter:
|
||||
'language_code': lang_code,
|
||||
'name': lang_config.get('name'),
|
||||
'content': lang_config.get('content'),
|
||||
'asr_provider': lang_config.get('asr_provider'),
|
||||
'llm_provider': lang_config.get('llm_provider'),
|
||||
'tts_provider': lang_config.get('tts_provider'),
|
||||
# 'asr_provider': lang_config.get('asr_provider'),
|
||||
# 'llm_provider': lang_config.get('llm_provider'),
|
||||
# 'tts_provider': lang_config.get('tts_provider'),
|
||||
# 'aws_language_code': lang_config.get('aws_language_code'),
|
||||
# 'volcano_voice_type': lang_config.get('volcano_voice_type'),
|
||||
# 'tencent_voice_type': lang_config.get('tencent_voice_type'),
|
||||
|
||||
Reference in New Issue
Block a user