diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index 6074d2a..5dfe3b5 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -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; diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index 62d654f..2264cb3 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -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(null) const [currentChild, setCurrentChild] = useState(null) const [firmwareStatus, setFirmwareStatus] = useState(null) + const [deviceRole, setDeviceRole] = useState(null) + const [roles, setRoles] = useState([]) 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(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() { )} + {showRoleModal && ( + setShowRoleModal(false)}> + { + event.stopPropagation() + }} + > + 切换 AI 角色 + {isLoadingRoles ? ( + 角色加载中... + ) : roles.length === 0 ? ( + 当前没有可用角色 + ) : ( + + {roles.map((item) => { + const isActive = deviceRole?.role_key === item.role_key + return ( + handleSelectRole(item)} + > + + {item.name || item.role_key} + {isActive && 当前} + + + {item.description || `角色标识:${item.role_key}`} + + + ) + })} + + )} + + setShowRoleModal(false)}> + 关闭 + + + + + )} {systemBanner} ) diff --git a/banban-mini/src/services/device.ts b/banban-mini/src/services/device.ts index ff78d1e..0b0bc9f 100644 --- a/banban-mini/src/services/device.ts +++ b/banban-mini/src/services/device.ts @@ -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(`/banban/devices/${resolvedDeviceId}/alarms?limit=${limit}`) } + +export async function getDeviceRoles(): Promise { + return request('/banban/roles') +} + +export async function getDeviceRole(deviceId?: string): Promise { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) return null + + try { + return await request(`/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 { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) { + throw new Error('当前没有可用设备') + } + + return request(`/banban/roles/devices/${resolvedDeviceId}`, { + method: 'PUT', + data: { + role_key: roleKey, + language: language || undefined, + play_welcome: playWelcome, + }, + }) +} diff --git a/talkingq-url/banban/routers/__init__.py b/talkingq-url/banban/routers/__init__.py index e05a878..6bc45f9 100644 --- a/talkingq-url/banban/routers/__init__.py +++ b/talkingq-url/banban/routers/__init__.py @@ -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,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(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"]) -__all__ = ["banban_router"] \ No newline at end of file +__all__ = ["banban_router"] diff --git a/talkingq-url/banban/routers/roles.py b/talkingq-url/banban/routers/roles.py new file mode 100644 index 0000000..af11547 --- /dev/null +++ b/talkingq-url/banban/routers/roles.py @@ -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, + ) diff --git a/talkingq-url/banban_main.py b/talkingq-url/banban_main.py index 844d72a..70cccff 100644 --- a/talkingq-url/banban_main.py +++ b/talkingq-url/banban_main.py @@ -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.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 database.connection import get_db_manager 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(im_router, tags=["banban-im"]) banban_router.include_router(parents_router, tags=["banban-parents"]) +banban_router.include_router(roles_router, tags=["banban-roles"]) @asynccontextmanager