feat: add device AI role switching

This commit is contained in:
stu2not
2026-05-15 15:33:28 +08:00
parent 067f5a5dcf
commit 9f9ce80649
6 changed files with 383 additions and 2 deletions

View File

@@ -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;

View File

@@ -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>
) )

View File

@@ -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,
},
})
}

View File

@@ -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,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(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"])

View 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,
)

View File

@@ -12,6 +12,7 @@ from banban.routers.device_im import router as device_im_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 database.connection import get_db_manager from database.connection import get_db_manager
from initialization import init_directories from initialization import init_directories
@@ -26,6 +27,7 @@ banban_router.include_router(devices_router, tags=["banban-devices"])
banban_router.include_router(device_im_router, tags=["banban-device-im"]) banban_router.include_router(device_im_router, tags=["banban-device-im"])
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"])
@asynccontextmanager @asynccontextmanager