import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { BookOutlined, ClockCircleOutlined, CloseOutlined, DisconnectOutlined, DeleteOutlined, DownOutlined, EditOutlined, HeartOutlined, HomeOutlined, HourglassOutlined, LaptopOutlined, MessageOutlined, MoreOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, SearchOutlined, SettingOutlined, StopOutlined, TagOutlined, ToolOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons"; import { APIRequestError, platformApi, type Consent, type ContentErrorType, type ConversationReadiness, type ConversationContentSummary, type ConversationDetail, type ConversationQuery, type ConversationUsage, type ConversationSummary, type ConversationTurn, type ConversationMedia, type CreateSubjectFactRequest, type CreateSubjectRequest, type CuratedMemory, type CurrentUser, type DevicePresence, type DeviceBindingImpact, type DeviceTwin, type ExportTask, type FactCandidate, type FirmwareRelease, type FirmwareReleaseImpact, type FirmwareRollout, type FirmwareAssignment, type FirmwareAssignmentEvent, type FirmwareSigningKey, type GrantConsentRequest, type Household, type HouseholdMember, type ManagedDevice, type MemberInvitation, type MemberInvitationPreview, type PrepareFirmwareReleaseRequest, type RecordingAccessGrant, type SharingGrant, type PrivacyRequest, type Subject, type SubjectFact, type UpdateDeviceTwinRequest, type UpdateSubjectRequest, type Story } from "../api/client"; import { FamilyUsageOverview } from "../components/UsageOverview"; export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages"; type Tab = CustomerSection | "firmware"; type LoadState = "idle" | "loading" | "ready" | "error"; type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error"; type EventStreamState = "connecting" | "connected" | "reconnecting"; type ConversationPace = "slow" | "normal" | "lively"; type DialoguePreset = { key: "daily" | "memory" | "interest"; label: string; description: string; goal: string; pace: ConversationPace; preferredTopics: string[]; avoidedTopics: string[]; }; type RecordingSegment = ConversationMedia["segments"][number]; type RecordingGrantInput = { grantee_user_id: string; permission: "PLAY" | "DOWNLOAD"; purpose: string; expires_at: string }; type SharingGrantInput = { resource_type: "STORY" | "TRANSCRIPT" | "RECORDING"; resource_id: string; grantee_user_id: string; permission: "VIEW_TEXT" | "LISTEN" | "DOWNLOAD" | "EDIT"; purpose: string; expires_at: string; login_required: boolean }; type FamilyControlDraft = | { kind: "household_create"; tenantID: string; tenantName: string; accountDisplayName: string; householdName: string; acknowledged: boolean; confirmation: string } | { kind: "invitation_accept"; token: string; preview: MemberInvitationPreview; accountDisplayName: string; acknowledged: boolean; confirmation: string } | { kind: "subject_create"; household: Household; input: CreateSubjectRequest; acknowledged: boolean; confirmation: string } | { kind: "fact_create"; subjectID: string; subjectName: string; input: CreateSubjectFactRequest; acknowledged: boolean; confirmation: string } | { kind: "subject_update"; item: Subject; input: UpdateSubjectRequest; acknowledged: boolean; confirmation: string } | { kind: "device_twin_update"; item: ManagedDevice; twin: DeviceTwin; input: UpdateDeviceTwinRequest; previousVolume?: number; speakerVolume: number; acknowledged: boolean; confirmation: string } | { kind: "consent_grant"; subjectID: string; subjectName: string; input: GrantConsentRequest; notice: string; acknowledged: boolean; confirmation: string } | { kind: "firmware_key_register"; household: Household; keyID: string; publicKey: string; fingerprint: string; acknowledged: boolean; confirmation: string } | { kind: "firmware_key_revoke"; household: Household; item: FirmwareSigningKey; acknowledged: boolean; confirmation: string } | { kind: "firmware_release_publish"; household: Household; input: PrepareFirmwareReleaseRequest; file: File; acknowledged: boolean; confirmation: string } | { kind: "firmware_release_resume"; household: Household; item: FirmwareRelease; file: File; acknowledged: boolean; confirmation: string } | { kind: "firmware_release_pause_distribution"; household: Household; item: FirmwareRelease; runningRollouts: number; acknowledged: boolean; confirmation: string } | { kind: "firmware_release_resume_distribution"; household: Household; item: FirmwareRelease; acknowledged: boolean; confirmation: string } | { kind: "firmware_release_retire"; household: Household; item: FirmwareRelease; acknowledged: boolean; confirmation: string } | { kind: "firmware_rollout_start"; household: Household; release: FirmwareRelease; name: string; percentage: number; devices: ManagedDevice[]; acknowledged: boolean; confirmation: string } | { kind: "consent_withdraw"; subjectID: string; subjectName: string; item: Consent; acknowledged: boolean; confirmation: string } | { kind: "member_remove"; household: Household; item: HouseholdMember; acknowledged: boolean; confirmation: string } | { kind: "ownership_transfer"; household: Household; item: HouseholdMember; acknowledged: boolean; confirmation: string } | { kind: "member_role_change"; household: Household; item: HouseholdMember; targetRole: "admin" | "editor" | "viewer"; acknowledged: boolean; confirmation: string } | { kind: "invitation_create"; household: Household; inviteeHint: string; role: "admin" | "editor" | "viewer"; expiresInHours: number; acknowledged: boolean; confirmation: string } | { kind: "invitation_revoke"; household: Household; item: MemberInvitation; acknowledged: boolean; confirmation: string } | { kind: "fact_confirm"; subjectID: string; subjectName: string; item: SubjectFact; acknowledged: boolean; confirmation: string } | { kind: "recording_access_grant"; subjectID: string; subjectName: string; granteeName: string; input: RecordingGrantInput; acknowledged: boolean; confirmation: string } | { kind: "recording_access_revoke"; subjectID: string; subjectName: string; item: RecordingAccessGrant; granteeName: string; acknowledged: boolean; confirmation: string } | { kind: "recording_play_address"; subjectID: string; subjectName: string; conversationID: string; item: RecordingSegment; purpose: string; acknowledged: boolean; confirmation: string } | { kind: "recording_download_address"; subjectID: string; subjectName: string; conversationID: string; item: RecordingSegment; purpose: string; acknowledged: boolean; confirmation: string } | { kind: "privacy_access_request"; subjectID: string; subjectName: string; categories: string[]; acknowledged: boolean; confirmation: string } | { kind: "privacy_export_request"; subjectID: string; subjectName: string; categories: string[]; acknowledged: boolean; confirmation: string } | { kind: "privacy_export_download"; subjectID: string; subjectName: string; categories: string[]; item: ExportTask; acknowledged: boolean; confirmation: string } | { kind: "device_claim"; household: Household; subjectID: string; subjectName: string; claimCode: string; acknowledged: boolean; confirmation: string } | { kind: "device_diagnostics"; item: ManagedDevice; presence?: DevicePresence; acknowledged: boolean; confirmation: string } | { kind: "firmware_pause"; item: FirmwareRollout; acknowledged: boolean; confirmation: string } | { kind: "firmware_rollback"; item: FirmwareRollout; release: FirmwareRelease; acknowledged: boolean; confirmation: string } | { kind: "sharing_grant_create"; subjectID: string; subjectName: string; granteeName: string; resourceLabel: string; input: SharingGrantInput; acknowledged: boolean; confirmation: string } | { kind: "sharing_revoke"; item: SharingGrant; acknowledged: boolean; confirmation: string }; const dialoguePresets: DialoguePreset[] = [ { key: "daily", label: "日常陪伴", description: "从当天生活和轻松小事自然开场", goal: "轻松聊聊当天的生活,让长者感到有人陪伴", pace: "slow", preferredTopics: ["天气", "饮食", "睡眠", "花草", "电视节目"], avoidedTopics: ["疾病", "家庭矛盾", "收入"] }, { key: "memory", label: "回忆往事", description: "温和引导年轻时的经历和重要人物", goal: "温和引导长者回忆年轻时的工作和生活经历", pace: "slow", preferredTopics: ["家乡", "童年", "工作经历", "老朋友", "节日"], avoidedTopics: ["遗憾经历", "家庭矛盾", "经济压力"] }, { key: "interest", label: "兴趣聊天", description: "围绕熟悉爱好展开低压力交流", goal: "围绕长者喜欢的兴趣进行轻松交流", pace: "normal", preferredTopics: ["戏曲", "养花", "做饭", "散步", "孙辈"], avoidedTopics: ["争议新闻", "疾病", "家庭矛盾"] } ]; export function SaaSWorkspace({ accessToken, accounts, section = "home", onSectionChange, showNavigation = true, enableFirmware = false }: { accessToken: string; accounts: CurrentUser["accounts"]; section?: CustomerSection; onSectionChange?: (section: CustomerSection) => void; showNavigation?: boolean; enableFirmware?: boolean; }) { const [tab, setTab] = useState(section); const [state, setState] = useState("loading"); const [error, setError] = useState(); const [households, setHouseholds] = useState([]); const [devices, setDevices] = useState([]); const [selectedHouseholdID, setSelectedHouseholdID] = useState(); const [subjects, setSubjects] = useState([]); const [members, setMembers] = useState([]); const [invitations, setInvitations] = useState([]); const [selectedSubjectID, setSelectedSubjectID] = useState(); const [facts, setFacts] = useState([]); const [consents, setConsents] = useState([]); const [conversationReadiness, setConversationReadiness] = useState(); const [conversations, setConversations] = useState([]); const [conversationStatus, setConversationStatus] = useState(""); const [conversationEngineMode, setConversationEngineMode] = useState(""); const [conversationFrom, setConversationFrom] = useState(""); const [conversationTo, setConversationTo] = useState(""); const [conversationNextCursor, setConversationNextCursor] = useState(); const [conversationLoading, setConversationLoading] = useState(false); const [conversationLoadingMore, setConversationLoadingMore] = useState(false); const [conversationError, setConversationError] = useState(); const [conversationDetailError, setConversationDetailError] = useState(); const [selectedConversationID, setSelectedConversationID] = useState(); const [subjectLoading, setSubjectLoading] = useState(false); const [subjectError, setSubjectError] = useState(); const [presence, setPresence] = useState>({}); const [twins, setTwins] = useState>({}); const [detail, setDetail] = useState(); const [conversationUsage, setConversationUsage] = useState(); const [turns, setTurns] = useState([]); const [contentSummaries, setContentSummaries] = useState([]); const [conversationMedia, setConversationMedia] = useState(); const [mediaState, setMediaState] = useState("idle"); const [mediaError, setMediaError] = useState(); const [recordingGrants, setRecordingGrants] = useState([]); const [sharingGrants, setSharingGrants] = useState([]); const [issuedShare, setIssuedShare] = useState<{ id: string; token?: string }>(); const [audioURLs, setAudioURLs] = useState>({}); const [stories, setStories] = useState([]); const [memories, setMemories] = useState([]); const [privacyRequests, setPrivacyRequests] = useState([]); const [exportTasks, setExportTasks] = useState([]); const [firmwareReleases, setFirmwareReleases] = useState([]); const [firmwareSigningKeys, setFirmwareSigningKeys] = useState([]); const [firmwareRollouts, setFirmwareRollouts] = useState([]); const [selectedFirmwareRollout, setSelectedFirmwareRollout] = useState(); const [firmwareReleaseImpact, setFirmwareReleaseImpact] = useState(); const [firmwareImpactLoading, setFirmwareImpactLoading] = useState(false); const [selectedStory, setSelectedStory] = useState(); const [detailLoading, setDetailLoading] = useState(false); const [refreshRevision, setRefreshRevision] = useState(0); const [actionBusy, setActionBusy] = useState(false); const [actionMessage, setActionMessage] = useState(); const [actionError, setActionError] = useState(); const [familyControlDraft, setFamilyControlDraft] = useState(); const [newHouseholdTenantID, setNewHouseholdTenantID] = useState(accounts[0]?.tenant_id ?? ""); const [newHouseholdName, setNewHouseholdName] = useState(""); const [newSubjectName, setNewSubjectName] = useState(""); const [newSubjectBackground, setNewSubjectBackground] = useState(""); const [newSubjectInterests, setNewSubjectInterests] = useState(""); const [newSubjectGoal, setNewSubjectGoal] = useState(""); const [newSubjectPace, setNewSubjectPace] = useState("slow"); const [newSubjectTopics, setNewSubjectTopics] = useState(""); const [newSubjectAvoidedTopics, setNewSubjectAvoidedTopics] = useState(""); const [subjectCreateOpen, setSubjectCreateOpen] = useState(false); const [householdCreateOpen, setHouseholdCreateOpen] = useState(false); const [invitationAcceptOpen, setInvitationAcceptOpen] = useState(false); const [subjectOperationsOpen, setSubjectOperationsOpen] = useState(false); const [deviceClaimOpen, setDeviceClaimOpen] = useState(false); const [newFactType, setNewFactType] = useState("family_note"); const [newFactContent, setNewFactContent] = useState(""); const [newFactSensitivity, setNewFactSensitivity] = useState<"normal" | "sensitive" | "high">("normal"); const [newConsentType, setNewConsentType] = useState("story_extraction"); const [consentConfirmed, setConsentConfirmed] = useState(false); const [claimCode, setClaimCode] = useState(""); const [inviteeHint, setInviteeHint] = useState(""); const [inviteRole, setInviteRole] = useState<"admin" | "editor" | "viewer">("viewer"); const [issuedInvitationToken, setIssuedInvitationToken] = useState(); const [acceptInvitationToken, setAcceptInvitationToken] = useState(""); const [firmwareKeyID, setFirmwareKeyID] = useState(""); const [firmwarePublicKey, setFirmwarePublicKey] = useState(""); const [firmwareFile, setFirmwareFile] = useState(); const [firmwareDigest, setFirmwareDigest] = useState(""); const [firmwareModel, setFirmwareModel] = useState("simulated-terminal"); const [firmwareHardware, setFirmwareHardware] = useState("sim-rev-a"); const [firmwareVersion, setFirmwareVersion] = useState(""); const [firmwareSignature, setFirmwareSignature] = useState(""); const [firmwareSigningKeyID, setFirmwareSigningKeyID] = useState(""); const [firmwareMinimumSecurityVersion, setFirmwareMinimumSecurityVersion] = useState("1.0.0"); const [firmwareReleaseNotes, setFirmwareReleaseNotes] = useState(""); const [rolloutReleaseID, setRolloutReleaseID] = useState(""); const [rolloutName, setRolloutName] = useState(""); const [rolloutPercentage, setRolloutPercentage] = useState(10); const [rolloutDeviceIDs, setRolloutDeviceIDs] = useState([]); const [rollbackReleaseID, setRollbackReleaseID] = useState(""); const [eventStreamState, setEventStreamState] = useState("connecting"); const [lastEventAt, setLastEventAt] = useState(); const [eventNotices, setEventNotices] = useState>([]); const eventRefreshTimer = useRef(undefined); const lastEventRefreshAt = useRef(0); const conversationDetailController = useRef(undefined); const claimCodeIsDeviceID = isUUID(claimCode.trim()); useEffect(() => setTab(section), [section]); function selectSection(next: CustomerSection) { setTab(next); onSectionChange?.(next); } const scheduleEventRefresh = useCallback(() => { if (eventRefreshTimer.current !== undefined) return; const minimumIntervalMS = 2000; const delay = Math.max(0, minimumIntervalMS - (Date.now() - lastEventRefreshAt.current)); eventRefreshTimer.current = window.setTimeout(() => { eventRefreshTimer.current = undefined; lastEventRefreshAt.current = Date.now(); setRefreshRevision((current) => current + 1); }, delay); }, []); useEffect(() => () => { if (eventRefreshTimer.current !== undefined) window.clearTimeout(eventRefreshTimer.current); conversationDetailController.current?.abort(); }, []); const loadRoot = useCallback(async (signal?: AbortSignal) => { setState("loading"); setError(undefined); try { const [householdResponse, deviceResponse] = await Promise.all([ platformApi.households(accessToken, signal), platformApi.devices(accessToken, signal) ]); const nextHouseholds = householdResponse.data.items; const nextDevices = deviceResponse.data.items; setHouseholds(nextHouseholds); setDevices(nextDevices); setSelectedHouseholdID((current) => current && nextHouseholds.some((item) => item.id === current) ? current : nextHouseholds[0]?.id ); setState("ready"); } catch (cause) { if (cause instanceof DOMException && cause.name === "AbortError") return; setError(errorMessage(cause)); setState("error"); } }, [accessToken]); useEffect(() => { const controller = new AbortController(); void loadRoot(controller.signal); return () => controller.abort(); }, [loadRoot]); useEffect(() => { setNewHouseholdTenantID((current) => accounts.some((account) => account.tenant_id === current) ? current : accounts[0]?.tenant_id ?? ""); }, [accounts]); useEffect(() => { const controller = new AbortController(); const accountKey = accounts.map((account) => account.user_id).sort().join("-"); const storageKey = `web-event-cursor:${accountKey}`; let cursor = window.sessionStorage.getItem(storageKey) ?? ""; const run = async () => { while (!controller.signal.aborted) { setEventStreamState(cursor ? "reconnecting" : "connecting"); try { const response = await fetch("/api/v1/events", { signal: controller.signal, headers: { Accept: "text/event-stream", Authorization: `Bearer ${accessToken}`, ...(cursor ? { "Last-Event-ID": cursor } : {}) } }); if (response.status === 410) { cursor = ""; window.sessionStorage.removeItem(storageKey); scheduleEventRefresh(); await waitForReconnect(controller.signal, 1000); continue; } if (!response.ok || !response.body) throw new Error(`状态推送连接失败(HTTP ${response.status})`); setEventStreamState("connected"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!controller.signal.aborted) { const chunk = await reader.read(); if (chunk.done) break; buffer += decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n"); let boundary = buffer.indexOf("\n\n"); while (boundary >= 0) { const block = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const id = block.split("\n").find((line) => line.startsWith("id: "))?.slice(4).trim(); const data = block.split("\n").find((line) => line.startsWith("data: "))?.slice(6); if (id && /^\d+$/.test(id) && Number(id) > Number(cursor || 0)) { cursor = id; window.sessionStorage.setItem(storageKey, id); if (data) { setLastEventAt(new Date().toISOString()); try { const item = JSON.parse(data) as { id?: number; type?: string; resource_type?: string; status?: string; occurred_at?: string }; setEventNotices((current) => [{ id: String(item.id ?? id), type: item.type ?? "resource.updated", resourceType: item.resource_type ?? "resource", status: item.status ?? "UPDATED", occurredAt: item.occurred_at ?? new Date().toISOString() }, ...current.filter((notice) => notice.id !== String(item.id ?? id))].slice(0, 50)); } catch { // The event still triggers an authoritative REST refresh when a legacy payload cannot be decoded. } scheduleEventRefresh(); } } boundary = buffer.indexOf("\n\n"); } } } catch (cause) { if (controller.signal.aborted || (cause instanceof DOMException && cause.name === "AbortError")) return; setEventStreamState("reconnecting"); } await waitForReconnect(controller.signal, 1000); } }; void run(); return () => controller.abort(); }, [accessToken, accounts, scheduleEventRefresh]); useEffect(() => { if (!selectedHouseholdID) { setSubjects([]); setMembers([]); setInvitations([]); setSelectedSubjectID(undefined); return; } const controller = new AbortController(); setSubjectLoading(true); setSubjectError(undefined); const canManageMembers = households.find((item) => item.id === selectedHouseholdID)?.capabilities.includes("member.manage") ?? false; void Promise.all([ platformApi.subjects(selectedHouseholdID, accessToken, controller.signal), platformApi.members(selectedHouseholdID, accessToken, controller.signal), canManageMembers ? platformApi.invitations(selectedHouseholdID, accessToken, controller.signal) : Promise.resolve(undefined) ]).then(([subjectResponse, memberResponse, invitationResponse]) => { const items = subjectResponse.data.items; setSubjects(items); setMembers(memberResponse.data.items); setInvitations(invitationResponse?.data.items ?? []); setSelectedSubjectID((current) => current && items.some((item) => item.id === current) ? current : items[0]?.id); }) .catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause)); }) .finally(() => { if (!controller.signal.aborted) setSubjectLoading(false); }); return () => controller.abort(); }, [accessToken, households, refreshRevision, selectedHouseholdID]); useEffect(() => { if (!selectedSubjectID) { setFacts([]); setConsents([]); setConversationReadiness(undefined); setStories([]); setMemories([]); setPrivacyRequests([]); setExportTasks([]); setSelectedStory(undefined); return; } const controller = new AbortController(); setSubjectLoading(true); setSubjectError(undefined); const mayReadPrivacy = households.find((item) => item.id === selectedHouseholdID)?.capabilities.includes("privacy.read") ?? false; void Promise.all([ platformApi.facts(selectedSubjectID, accessToken, controller.signal), platformApi.consents(selectedSubjectID, accessToken, controller.signal), platformApi.conversationReadiness(selectedSubjectID, accessToken, controller.signal), platformApi.stories(selectedSubjectID, accessToken, controller.signal), platformApi.memories(selectedSubjectID, accessToken, controller.signal), mayReadPrivacy ? platformApi.privacyRequests(selectedSubjectID, accessToken, controller.signal) : Promise.resolve(undefined), mayReadPrivacy ? platformApi.exportTasks(selectedSubjectID, accessToken, controller.signal) : Promise.resolve(undefined) ]).then(([factResponse, consentResponse, readinessResponse, storyResponse, memoryResponse, privacyResponse, exportResponse]) => { setFacts(factResponse.data.items); setConsents(consentResponse.data.items); setConversationReadiness(readinessResponse.data); setStories(storyResponse.data.items); setMemories(memoryResponse.data.items); setPrivacyRequests(privacyResponse?.data.items ?? []); setExportTasks(exportResponse?.data.items ?? []); }).catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause)); }).finally(() => { if (!controller.signal.aborted) setSubjectLoading(false); }); return () => controller.abort(); }, [accessToken, households, refreshRevision, selectedHouseholdID, selectedSubjectID]); useEffect(() => { conversationDetailController.current?.abort(); setDetail(undefined); setSelectedConversationID(undefined); setConversationDetailError(undefined); setDetailLoading(false); setConversationUsage(undefined); setTurns([]); setContentSummaries([]); setConversationMedia(undefined); setMediaState("idle"); setAudioURLs({}); setSelectedStory(undefined); }, [selectedSubjectID]); useEffect(() => { if (!selectedSubjectID) { setConversations([]); setConversationNextCursor(undefined); setConversationError(undefined); setConversationLoading(false); return; } if (conversationFrom && conversationTo && conversationFrom > conversationTo) { setConversations([]); setConversationNextCursor(undefined); setConversationError("开始日期不能晚于结束日期"); setConversationLoading(false); return; } const controller = new AbortController(); setConversationLoading(true); setConversationError(undefined); void platformApi.conversations(selectedSubjectID, conversationQuery({ status: conversationStatus, engineMode: conversationEngineMode, from: conversationFrom, to: conversationTo }), accessToken, controller.signal).then((response) => { setConversations(response.data.items); setConversationNextCursor(response.data.next_cursor); }).catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setConversationError(errorMessage(cause)); }).finally(() => { if (!controller.signal.aborted) setConversationLoading(false); }); return () => controller.abort(); }, [accessToken, conversationEngineMode, conversationFrom, conversationStatus, conversationTo, refreshRevision, selectedSubjectID]); useEffect(() => { if (devices.length === 0) { setPresence({}); setTwins({}); return; } const controller = new AbortController(); void Promise.all(devices.map(async (device) => { const [presenceResponse, twinResponse] = await Promise.all([ platformApi.devicePresence(device.id, accessToken, controller.signal), platformApi.deviceTwin(device.id, accessToken, controller.signal) ]); return { id: device.id, presence: presenceResponse.data, twin: twinResponse.data }; })).then((results) => { setPresence(Object.fromEntries(results.map((item) => [item.id, item.presence]))); setTwins(Object.fromEntries(results.map((item) => [item.id, item.twin]))); }).catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorMessage(cause)); }); return () => controller.abort(); }, [accessToken, devices, refreshRevision]); const selectedHousehold = households.find((item) => item.id === selectedHouseholdID); const selectedSubject = subjects.find((item) => item.id === selectedSubjectID); const canWriteSubject = selectedHousehold?.capabilities.includes("subject.write") ?? false; const canManageDevice = selectedHousehold?.capabilities.includes("device.manage") ?? false; const canManageMembers = selectedHousehold?.capabilities.includes("member.manage") ?? false; const canTransferOwnership = selectedHousehold?.capabilities.includes("ownership.transfer") ?? false; const canReviewStory = selectedHousehold?.capabilities.includes("story.review") ?? false; const canReadPrivacy = selectedHousehold?.capabilities.includes("privacy.read") ?? false; const canManagePrivacy = selectedHousehold?.capabilities.includes("privacy.manage") ?? false; const canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false); const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const canManageSharing = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? ""; const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`; const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective); const effectiveConsents = consents.filter((item) => item.effective).length; const confirmedFacts = facts.filter((item) => item.confirmation_status === "confirmed").length; useEffect(() => { setFamilyControlDraft(undefined); setClaimCode(""); setAcceptInvitationToken(""); }, [tab, selectedHouseholdID, selectedSubjectID, selectedConversationID]); useEffect(() => { if (!selectedSubjectID || !canManageRecordingAccess) { setRecordingGrants([]); return; } const controller = new AbortController(); void platformApi.recordingAccessGrants(selectedSubjectID, accessToken, controller.signal) .then((response) => setRecordingGrants(response.data.items)) .catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause)); }); return () => controller.abort(); }, [accessToken, canManageRecordingAccess, refreshRevision, selectedSubjectID]); useEffect(() => { setIssuedShare(undefined); if (!selectedSubjectID || !canManageSharing) { setSharingGrants([]); return; } const controller = new AbortController(); void platformApi.sharingGrants(selectedSubjectID, accessToken, controller.signal) .then((response) => setSharingGrants(response.data.items)) .catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause)); }); return () => controller.abort(); }, [accessToken, canManageSharing, refreshRevision, selectedSubjectID]); useEffect(() => { setFirmwareReleaseImpact(undefined); }, [selectedHouseholdID]); useEffect(() => { if (!selectedHouseholdID || !canManageFirmware) { setFirmwareSigningKeys([]); setFirmwareReleases([]); setFirmwareRollouts([]); setSelectedFirmwareRollout(undefined); setFirmwareReleaseImpact(undefined); return; } const controller = new AbortController(); void Promise.all([ platformApi.firmwareSigningKeys(selectedHouseholdID, accessToken, controller.signal), platformApi.firmwareReleases(selectedHouseholdID, accessToken, controller.signal), platformApi.firmwareRollouts(selectedHouseholdID, accessToken, controller.signal) ]).then(async ([keyResponse, releaseResponse, rolloutResponse]) => { setFirmwareSigningKeys(keyResponse.data.items); setFirmwareReleases(releaseResponse.data.items); setFirmwareRollouts(rolloutResponse.data.items); setRolloutReleaseID((current) => current || releaseResponse.data.items.find((item) => item.status === "READY")?.id || ""); setRollbackReleaseID((current) => current || releaseResponse.data.items.find((item) => item.status === "READY")?.id || ""); const first = rolloutResponse.data.items[0]; if (first) { const detailResponse = await platformApi.firmwareRollout(first.id, accessToken, controller.signal); setSelectedFirmwareRollout(detailResponse.data); } else { setSelectedFirmwareRollout(undefined); } }).catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause)); }); return () => controller.abort(); }, [accessToken, canManageFirmware, refreshRevision, selectedHouseholdID]); useEffect(() => { const currentHouseholdDevices = devices.filter((item) => item.household_id === selectedHouseholdID).map((item) => item.id); setRolloutDeviceIDs((current) => current.filter((id) => currentHouseholdDevices.includes(id)).length > 0 ? current.filter((id) => currentHouseholdDevices.includes(id)) : currentHouseholdDevices); }, [devices, selectedHouseholdID]); async function openConversation(conversationID: string) { conversationDetailController.current?.abort(); const controller = new AbortController(); conversationDetailController.current = controller; setDetailLoading(true); setSelectedConversationID(conversationID); setConversationDetailError(undefined); setDetail(undefined); setTurns([]); setContentSummaries([]); setConversationUsage(undefined); setConversationMedia(undefined); setMediaState("loading"); setMediaError(undefined); setAudioURLs({}); try { const [response, turnResponse, summaryResponse, usageResponse] = await Promise.all([ platformApi.conversation(conversationID, accessToken, controller.signal), platformApi.conversationTurns(conversationID, accessToken, controller.signal), platformApi.conversationSummaries(conversationID, accessToken, controller.signal), platformApi.conversationUsage(conversationID, accessToken, controller.signal) ]); if (controller.signal.aborted) return; setDetail(response.data); setTurns(turnResponse.data.items); setContentSummaries(summaryResponse.data.items); setConversationUsage(usageResponse.data); if (response.data.retention_mode === "transcript_and_audio") { await loadConversationMedia(conversationID, controller.signal); } else { setConversationMedia(undefined); setMediaState("idle"); } } catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError") && !controller.signal.aborted) { setConversationDetailError(errorMessage(cause)); setMediaState("idle"); } } finally { if (conversationDetailController.current === controller) setDetailLoading(false); } } async function openStoryConversation(story: Story) { selectSection("conversations"); await openConversation(story.conversation_id); } async function loadMoreConversations() { if (!selectedSubjectID || !conversationNextCursor || conversationLoadingMore) return; setConversationLoadingMore(true); setConversationError(undefined); try { const response = await platformApi.conversations(selectedSubjectID, conversationQuery({ status: conversationStatus, engineMode: conversationEngineMode, from: conversationFrom, to: conversationTo }, conversationNextCursor), accessToken); setConversations((current) => { const existing = new Set(current.map((item) => item.id)); return [...current, ...response.data.items.filter((item) => !existing.has(item.id))]; }); setConversationNextCursor(response.data.next_cursor); } catch (cause) { setConversationError(errorMessage(cause)); } finally { setConversationLoadingMore(false); } } function clearConversationDetail() { conversationDetailController.current?.abort(); setDetail(undefined); setSelectedConversationID(undefined); setConversationDetailError(undefined); setDetailLoading(false); setConversationUsage(undefined); setTurns([]); setContentSummaries([]); setConversationMedia(undefined); setMediaState("idle"); setAudioURLs({}); } function resetConversationFilters() { clearConversationDetail(); setConversations([]); setConversationStatus(""); setConversationEngineMode(""); setConversationFrom(""); setConversationTo(""); setConversationLoadingMore(false); } async function loadConversationMedia(conversationID: string, signal?: AbortSignal) { setMediaState("loading"); setMediaError(undefined); try { const response = await platformApi.conversationMedia(conversationID, accessToken, signal); if (signal?.aborted) return; setConversationMedia(response.data); setMediaState("ready"); } catch (cause) { if (cause instanceof DOMException && cause.name === "AbortError") return; setConversationMedia(undefined); if (cause instanceof APIRequestError && cause.code === "RECORDING_ACCESS_DENIED") { setMediaState("restricted"); } else { setMediaState("error"); setMediaError(errorMessage(cause)); } } } function grantRecordingAccess(input: RecordingGrantInput) { if (!selectedSubjectID || !selectedSubject) return; const granteeName = members.find((item) => item.user_id === input.grantee_user_id)?.display_name ?? input.grantee_user_id; openFamilyControl({ kind: "recording_access_grant", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, granteeName, input, acknowledged: false, confirmation: "" }); } async function revokeRecordingAccess(grant: RecordingAccessGrant) { if (!selectedSubjectID || !selectedSubject) return; const granteeName = members.find((item) => item.user_id === grant.grantee_user_id)?.display_name ?? grant.grantee_user_id; openFamilyControl({ kind: "recording_access_revoke", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, item: grant, granteeName, acknowledged: false, confirmation: "" }); } function accessRecording(segment: RecordingSegment, action: "PLAY" | "DOWNLOAD", purpose: string) { if (!selectedSubjectID || !selectedSubject || !detail) return; openFamilyControl({ kind: action === "PLAY" ? "recording_play_address" : "recording_download_address", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, conversationID: detail.id, item: segment, purpose, acknowledged: false, confirmation: "" }); } async function runAction(successMessage: string, action: () => Promise): Promise { setActionBusy(true); setActionError(undefined); setActionMessage(undefined); try { await action(); setActionMessage(successMessage); setRefreshRevision((current) => current + 1); return true; } catch (cause) { setActionError(errorMessage(cause)); return false; } finally { setActionBusy(false); } } function createSubject(event: FormEvent) { event.preventDefault(); if (!selectedHousehold || !newSubjectName.trim() || !newSubjectBackground.trim() || !newSubjectGoal.trim()) return; openFamilyControl({ kind: "subject_create", household: selectedHousehold, input: { preferred_name: newSubjectName.trim(), language: "zh-CN", timezone: "Asia/Shanghai", profile_data: { background: newSubjectBackground.trim(), interests: splitContextList(newSubjectInterests) }, conversation_preferences: { conversation_goal: newSubjectGoal.trim(), pace: newSubjectPace, preferred_topics: splitContextList(newSubjectTopics), avoided_topics: splitContextList(newSubjectAvoidedTopics) } }, acknowledged: false, confirmation: "" }); } function applyNewSubjectDialoguePreset(preset: DialoguePreset) { setNewSubjectGoal(preset.goal); setNewSubjectPace(preset.pace); setNewSubjectTopics(preset.preferredTopics.join("、")); setNewSubjectAvoidedTopics(preset.avoidedTopics.join("、")); } function updateSubjectProfile(subject: Subject, input: UpdateSubjectRequest) { openFamilyControl({ kind: "subject_update", item: subject, input, acknowledged: false, confirmation: "" }); } function createHousehold(event: FormEvent) { event.preventDefault(); if (!newHouseholdTenantID || !newHouseholdName.trim()) return; const account = accounts.find((item) => item.tenant_id === newHouseholdTenantID); if (!account) return; openFamilyControl({ kind: "household_create", tenantID: newHouseholdTenantID, tenantName: account.tenant_name, accountDisplayName: account.display_name, householdName: newHouseholdName.trim(), acknowledged: false, confirmation: "" }); } function createFact(event: FormEvent) { event.preventDefault(); if (!selectedSubject || !newFactContent.trim()) return; openFamilyControl({ kind: "fact_create", subjectID: selectedSubject.id, subjectName: selectedSubject.preferred_name, input: { fact_type: newFactType.trim(), content: newFactContent.trim(), source_type: "family_entry", source_ref: "family-web-entry", confirmation_status: "pending", sensitivity: newFactSensitivity }, acknowledged: false, confirmation: "" }); } async function grantConsent(event: FormEvent) { event.preventDefault(); if (!selectedSubject || !policyVersion || !consentConfirmed || selectedConsentAlreadyEffective) return; const noticeDigest = await digestHex(consentNotice); openFamilyControl({ kind: "consent_grant", subjectID: selectedSubject.id, subjectName: selectedSubject.preferred_name, input: { type: newConsentType as "transcript_storage" | "raw_audio_storage" | "story_extraction" | "personalized_memory" | "family_sharing", scope: { scope: "future_conversations" }, policy_version: policyVersion, notice_digest: noticeDigest, evidence_ref: "family-web-confirmation" }, notice: consentNotice, acknowledged: false, confirmation: "" }); } function claimDevice(event: FormEvent) { event.preventDefault(); if (!selectedHousehold || !selectedSubjectID || !selectedSubject || !claimCode.trim() || claimCodeIsDeviceID) return false; openFamilyControl({ kind: "device_claim", household: selectedHousehold, subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, claimCode: claimCode.trim(), acknowledged: false, confirmation: "" }); return true; } async function confirmFact(fact: SubjectFact) { if (!selectedSubjectID || !selectedSubject) return; openFamilyControl({ kind: "fact_confirm", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, item: fact, acknowledged: false, confirmation: "" }); } async function withdrawConsent(consent: Consent) { if (!selectedSubjectID || !selectedSubject) return; openFamilyControl({ kind: "consent_withdraw", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, item: consent, acknowledged: false, confirmation: "" }); } function updateDeviceVolume(device: ManagedDevice, speakerVolume: number) { const twin = twins[device.id]; if (!twin || !Number.isInteger(speakerVolume) || speakerVolume < 0 || speakerVolume > 100) return; const existingVolume = typeof twin.desired_config.volume === "object" && twin.desired_config.volume ? twin.desired_config.volume as Record : {}; openFamilyControl({ kind: "device_twin_update", item: device, twin, input: { desired_config: { volume: { ...existingVolume, speaker: speakerVolume } } }, previousVolume: deviceSpeakerVolume(twin.desired_config), speakerVolume, acknowledged: false, confirmation: "" }); } function createInvitation(event: FormEvent) { event.preventDefault(); if (!selectedHousehold) return; openFamilyControl({ kind: "invitation_create", household: selectedHousehold, inviteeHint: inviteeHint.trim(), role: inviteRole, expiresInHours: 168, acknowledged: false, confirmation: "" }); } async function acceptInvitation(event: FormEvent) { event.preventDefault(); const token = acceptInvitationToken.trim(); if (token.length < 32 || token.length > 128) return; setActionBusy(true); setActionError(undefined); setActionMessage(undefined); try { const preview = (await platformApi.previewInvitation(token, accessToken)).data; openFamilyControl({ kind: "invitation_accept", token, preview, accountDisplayName: accounts[0]?.display_name ?? "当前登录账号", acknowledged: false, confirmation: "" }); } catch (cause) { setActionError(errorMessage(cause)); } finally { setActionBusy(false); } } async function updateMemberRole(member: HouseholdMember) { if (!selectedHousehold || member.is_owner) return; openFamilyControl({ kind: "member_role_change", household: selectedHousehold, item: member, targetRole: member.role as "admin" | "editor" | "viewer", acknowledged: false, confirmation: "" }); } async function removeMember(member: HouseholdMember) { if (!selectedHouseholdID || !selectedHousehold) return; openFamilyControl({ kind: "member_remove", household: selectedHousehold, item: member, acknowledged: false, confirmation: "" }); } async function revokeInvitation(invitation: MemberInvitation) { if (!selectedHousehold) return; openFamilyControl({ kind: "invitation_revoke", household: selectedHousehold, item: invitation, acknowledged: false, confirmation: "" }); } async function transferOwnership(member: HouseholdMember) { if (!selectedHousehold) return; openFamilyControl({ kind: "ownership_transfer", household: selectedHousehold, item: member, acknowledged: false, confirmation: "" }); } async function renameHousehold(name: string): Promise { if (!selectedHousehold || !name.trim()) return false; return runAction("家庭名称已更新。", async () => { const response = await platformApi.updateHousehold(selectedHousehold, name.trim(), accessToken); setHouseholds((current) => current.map((item) => item.id === response.data.id ? response.data : item)); }); } async function openStory(storyID: string) { setSubjectLoading(true); setSubjectError(undefined); try { const response = await platformApi.story(storyID, accessToken); setSelectedStory(response.data); } catch (cause) { setSubjectError(errorMessage(cause)); } finally { setSubjectLoading(false); } } async function reviseStory(story: Story, content: string, reason: string, errorType?: ContentErrorType) { await runAction("故事已保存为新的人工修订版本。", async () => { const response = await platformApi.reviseStory(story, content, reason, accessToken, errorType); setSelectedStory(response.data); }); } async function confirmStory(story: Story, reason: string) { await runAction("故事已人工确认,并生成可追溯的长期记忆。", async () => { const response = await platformApi.confirmStory(story, reason, accessToken); setSelectedStory(response.data.story); }); } async function rejectStory(story: Story, reason: string, errorType?: ContentErrorType) { await runAction("故事候选及其候选事实已拒绝。", async () => { const response = await platformApi.rejectStory(story, reason, accessToken, errorType); setSelectedStory(response.data); }); } async function updateFactCandidate(story: Story, fact: FactCandidate, input: { fact_type?: string; content?: string; sensitivity?: "normal" | "sensitive" | "high"; status?: "CANDIDATE" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; error_type?: ContentErrorType; reason: string }): Promise { return runAction(input.status === "CONFIRMED" ? "事实候选已单独确认。" : input.status === "REJECTED" ? "事实候选已单独拒绝。" : input.status === "SUPERSEDED" ? "事实候选已停用。" : "事实候选修改已保存。", async () => { const response = await platformApi.updateFactCandidate(story.id, fact, input, accessToken); setSelectedStory({ ...story, facts: story.facts.map((item) => item.id === fact.id ? response.data : item) }); }); } async function updateMemory(memory: CuratedMemory, input: { content?: string; status?: "ACTIVE" | "DISABLED"; error_type?: ContentErrorType; reason: string }): Promise { return runAction(input.content ? "记忆已保存为新版本,旧索引已失效。" : input.status === "DISABLED" ? "记忆已停用,不会进入新通话。" : "记忆已重新启用并等待索引。", async () => { await platformApi.updateMemory(memory, input, accessToken); }); } async function deleteMemory(memory: CuratedMemory, reason: string): Promise { return runAction("记忆删除请求已受理,记忆已立即停止用于新通话。", async () => { await platformApi.deleteMemory(memory, reason, accessToken); }); } async function createPrivacyAccessRequest() { if (!selectedSubjectID || !selectedSubject) return; openFamilyControl({ kind: "privacy_access_request", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, categories: privacyCategories(), acknowledged: false, confirmation: "" }); } async function createPrivacyCorrectionRequest(corrections: { preferred_name?: string; language?: string; timezone?: string }): Promise { if (!selectedSubjectID) return false; return runAction("资料更正申请已完成,后续新通话将使用新的档案版本。", async () => { await platformApi.createPrivacyCorrectionRequest(selectedSubjectID, corrections, accessToken); setRefreshRevision((current) => current + 1); }); } async function createConsentWithdrawalRequest(consentTypes: Consent["type"][]): Promise { if (!selectedSubjectID) return false; return runAction("所选授权已撤回;依赖授权的活动通话已按安全规则停止。", async () => { await platformApi.createConsentWithdrawalRequest(selectedSubjectID, consentTypes, accessToken); setRefreshRevision((current) => current + 1); }); } async function createPrivacyDeleteRequest(reason: string, confirmation: string): Promise { if (!selectedSubjectID || !selectedSubject) return false; return runAction("主体删除已完成;个人内容已脱敏,缓存、索引和私有导出已完成对账。", async () => { const response = await platformApi.createPrivacyDeleteRequest(selectedSubjectID, reason, confirmation, accessToken); const deadline = Date.now() + 30000; let current = response.data; while (!['COMPLETED', 'PARTIAL_FAILED'].includes(current.status) && Date.now() < deadline) { await new Promise((resolve) => window.setTimeout(resolve, 500)); current = (await platformApi.privacyRequest(current.id, accessToken)).data; } if (current.status !== 'COMPLETED') throw new Error("删除对账未全部完成,请通过隐私请求编号继续检查子任务状态"); }); } async function createExportTask() { if (!selectedSubjectID || !selectedSubject) return; openFamilyControl({ kind: "privacy_export_request", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, categories: privacyCategories(), acknowledged: false, confirmation: "" }); } function downloadExport(task: ExportTask) { if (!selectedSubjectID || !selectedSubject) return; openFamilyControl({ kind: "privacy_export_download", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, categories: exportTaskCategories(task), item: task, acknowledged: false, confirmation: "" }); } async function requestDeviceDiagnostics(device: ManagedDevice) { openFamilyControl({ kind: "device_diagnostics", item: device, presence: presence[device.id], acknowledged: false, confirmation: "" }); } async function selectFirmwareFile(file?: File) { setFirmwareFile(file); setFirmwareDigest(""); if (!file) return; if (file.size < 1 || file.size > 268435456) { setActionError("固件文件必须在1字节至256MB之间。"); setFirmwareFile(undefined); return; } const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer()); setFirmwareDigest(Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")); } async function registerFirmwareKey(event: FormEvent) { event.preventDefault(); if (!selectedHousehold || !firmwareKeyID.trim() || !firmwarePublicKey.trim()) return; const publicKey = decodeStandardBase64(firmwarePublicKey.trim()); if (!publicKey || publicKey.byteLength !== 32) return; const fingerprintBytes = await crypto.subtle.digest("SHA-256", publicKey); const fingerprint = Array.from(new Uint8Array(fingerprintBytes), (byte) => byte.toString(16).padStart(2, "0")).join(""); const existing = firmwareSigningKeys.find((item) => item.key_id === firmwareKeyID.trim()); if (existing && (existing.status !== "ACTIVE" || existing.fingerprint_sha256 !== fingerprint)) { setActionError(existing.status === "REVOKED" ? "该密钥编号已经吊销,不能重新激活;请使用新的轮换编号。" : "该密钥编号已绑定另一把公钥,不能静默替换;请核对指纹或使用新的轮换编号。"); return; } openFamilyControl({ kind: "firmware_key_register", household: selectedHousehold, keyID: firmwareKeyID.trim(), publicKey: firmwarePublicKey.trim(), fingerprint, acknowledged: false, confirmation: "" }); } function revokeFirmwareKey(item: FirmwareSigningKey) { if (!selectedHousehold || item.status !== "ACTIVE" || item.uploading_releases > 0 || item.running_rollouts > 0) return; openFamilyControl({ kind: "firmware_key_revoke", household: selectedHousehold, item, acknowledged: false, confirmation: "" }); } function publishFirmware(event: FormEvent) { event.preventDefault(); if (!selectedHousehold || !firmwareFile || !firmwareDigest || !firmwareModel.trim() || !firmwareVersion.trim() || !firmwareSignature.trim() || !firmwareSigningKeyID.trim()) return; const signature = decodeStandardBase64(firmwareSignature.trim()); if (!signature || signature.byteLength !== 64) return; openFamilyControl({ kind: "firmware_release_publish", household: selectedHousehold, file: firmwareFile, input: { model: firmwareModel.trim(), hardware_revision: firmwareHardware.trim(), version: firmwareVersion.trim(), expected_size: firmwareFile.size, expected_digest: firmwareDigest, signature: firmwareSignature.trim(), signing_key_id: firmwareSigningKeyID.trim(), minimum_security_version: firmwareMinimumSecurityVersion.trim(), release_notes: firmwareReleaseNotes.trim() || `由管理工作台上传:${firmwareFile.name}` }, acknowledged: false, confirmation: "" }); } function resumeFirmwareRelease(item: FirmwareRelease, file: File, digest: string) { if (!selectedHousehold || item.status !== "UPLOADING" || file.size !== Number(item.expected_size) || digest !== item.expected_digest) return; openFamilyControl({ kind: "firmware_release_resume", household: selectedHousehold, item, file, acknowledged: false, confirmation: "" }); } function retireIncompleteFirmware(item: FirmwareRelease) { if (!selectedHousehold || !["UPLOADING", "READY", "PAUSED"].includes(item.status)) return; openFamilyControl({ kind: "firmware_release_retire", household: selectedHousehold, item, acknowledged: false, confirmation: "" }); } function pauseFirmwareDistribution(item: FirmwareRelease) { if (!selectedHousehold || item.status !== "READY") return; const runningRollouts = firmwareRollouts.filter((rollout) => rollout.release_id === item.id && rollout.status === "RUNNING").length; openFamilyControl({ kind: "firmware_release_pause_distribution", household: selectedHousehold, item, runningRollouts, acknowledged: false, confirmation: "" }); } function resumeFirmwareDistribution(item: FirmwareRelease) { if (!selectedHousehold || item.status !== "PAUSED") return; openFamilyControl({ kind: "firmware_release_resume_distribution", household: selectedHousehold, item, acknowledged: false, confirmation: "" }); } function startFirmwareRollout(event: FormEvent) { event.preventDefault(); if (!selectedHousehold || !rolloutReleaseID || !rolloutName.trim() || rolloutDeviceIDs.length === 0) return; const release = firmwareReleases.find((item) => item.id === rolloutReleaseID && item.status === "READY"); const selectedDevices = devices.filter((item) => item.household_id === selectedHousehold.id && rolloutDeviceIDs.includes(item.id) && item.status === "registered" && item.model === release?.model && (!release?.hardware_revision || item.hardware_revision === release.hardware_revision)); if (!release || selectedDevices.length !== rolloutDeviceIDs.length) return; openFamilyControl({ kind: "firmware_rollout_start", household: selectedHousehold, release, name: rolloutName.trim(), percentage: rolloutPercentage, devices: selectedDevices, acknowledged: false, confirmation: "" }); } async function openFirmwareRollout(rolloutID: string) { setDetailLoading(true); try { const response = await platformApi.firmwareRollout(rolloutID, accessToken); setSelectedFirmwareRollout(response.data); } catch (cause) { setSubjectError(errorMessage(cause)); } finally { setDetailLoading(false); } } async function openFirmwareReleaseImpact(releaseID: string) { setFirmwareImpactLoading(true); try { setFirmwareReleaseImpact((await platformApi.firmwareReleaseImpact(releaseID, accessToken)).data); } catch (cause) { setSubjectError(errorMessage(cause)); } finally { setFirmwareImpactLoading(false); } } async function refreshFirmwareLifecycle(householdID: string) { const [keys, releases, rollouts] = await Promise.all([ platformApi.firmwareSigningKeys(householdID, accessToken), platformApi.firmwareReleases(householdID, accessToken), platformApi.firmwareRollouts(householdID, accessToken) ]); setFirmwareSigningKeys(keys.data.items); setFirmwareReleases(releases.data.items); setFirmwareRollouts(rollouts.data.items); if (selectedFirmwareRollout && rollouts.data.items.some((item) => item.id === selectedFirmwareRollout.id)) { setSelectedFirmwareRollout((await platformApi.firmwareRollout(selectedFirmwareRollout.id, accessToken)).data); } if (firmwareReleaseImpact && releases.data.items.some((item) => item.id === firmwareReleaseImpact.release_id)) { setFirmwareReleaseImpact((await platformApi.firmwareReleaseImpact(firmwareReleaseImpact.release_id, accessToken)).data); } } async function pauseFirmwareRollout(rollout: FirmwareRollout) { openFamilyControl({ kind: "firmware_pause", item: rollout, acknowledged: false, confirmation: "" }); } async function rollbackFirmwareRollout(rollout: FirmwareRollout) { const release = firmwareReleases.find((item) => item.id === rollbackReleaseID); if (!release) return; openFamilyControl({ kind: "firmware_rollback", item: rollout, release, acknowledged: false, confirmation: "" }); } function createSharingGrant(input: SharingGrantInput) { if (!selectedSubjectID || !selectedSubject) return; const granteeName = members.find((item) => item.user_id === input.grantee_user_id)?.display_name ?? input.grantee_user_id; const resourceLabel = sharingInputResourceLabel(input, stories, conversations, conversationMedia); openFamilyControl({ kind: "sharing_grant_create", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, granteeName, resourceLabel, input, acknowledged: false, confirmation: "" }); } async function revokeSharingGrant(grant: SharingGrant) { openFamilyControl({ kind: "sharing_revoke", item: grant, acknowledged: false, confirmation: "" }); } function openFamilyControl(draft: FamilyControlDraft) { setFamilyControlDraft(draft); setActionError(undefined); setActionMessage(undefined); window.requestAnimationFrame(() => document.getElementById("family-controlled-operation")?.scrollIntoView({ behavior: "smooth", block: "center" })); } function cancelFamilyControl() { if (familyControlDraft?.kind === "device_claim") setClaimCode(""); if (familyControlDraft?.kind === "invitation_accept") setAcceptInvitationToken(""); if (familyControlDraft?.kind === "consent_grant") setConsentConfirmed(false); setFamilyControlDraft(undefined); } async function submitFamilyControl(event: FormEvent) { event.preventDefault(); const draft = familyControlDraft; if (!draft || !familyControlReady(draft)) return; let completed = false; if (draft.kind === "household_create") completed = await runAction(`家庭“${draft.householdName}”已创建,当前账号已成为家庭所有者。`, async () => { const created = await platformApi.createHousehold(draft.tenantID, draft.householdName, accessToken); const refreshed = await platformApi.households(accessToken); setHouseholds(refreshed.data.items); setSelectedHouseholdID(created.data.id); setNewHouseholdName(""); setState("ready"); }); else if (draft.kind === "invitation_accept") completed = await runAction("邀请已接受,新的客户组织账号和家庭成员关系已经生效。", async () => { await platformApi.acceptInvitation(draft.token, accessToken); setAcceptInvitationToken(""); await loadRoot(); }); else if (draft.kind === "subject_create") completed = await runAction("长者档案已创建;后续新通话将按档案版本冻结所列背景和交流偏好。", async () => { const response = await platformApi.createSubject(draft.household.id, draft.input, accessToken); setSelectedSubjectID(response.data.id); setNewSubjectName(""); setNewSubjectBackground(""); setNewSubjectInterests(""); setNewSubjectGoal(""); setNewSubjectPace("slow"); setNewSubjectTopics(""); setNewSubjectAvoidedTopics(""); }); else if (draft.kind === "fact_create") completed = await runAction("稳定事实候选已登记,仍需人工核对后才会作为已确认背景使用。", async () => { await platformApi.createFact(draft.subjectID, draft.input, accessToken); setNewFactContent(""); setNewFactSensitivity("normal"); }); else if (draft.kind === "subject_update") completed = await runAction("长者档案新版本已保存;后续新通话将冻结新版本,当前和历史通话保持原快照。", async () => { const response = await platformApi.updateSubject(draft.item, draft.input, accessToken); setSubjects((current) => current.map((item) => item.id === response.data.id ? response.data : item)); }); else if (draft.kind === "device_twin_update") completed = await runAction("新的设备音量期望配置已保存,等待设备领取并上报实际配置。", async () => { const response = await platformApi.updateDeviceTwin(draft.item.id, draft.twin.desired_revision, draft.input, accessToken); setTwins((current) => ({ ...current, [draft.item.id]: response.data })); }); else if (draft.kind === "consent_grant") completed = await runAction("新的长者分项授权已记录,并生成用于后续新通话的不可变授权快照。", async () => { await platformApi.grantConsent(draft.subjectID, draft.input, accessToken); setConsentConfirmed(false); }); else if (draft.kind === "firmware_key_register") completed = await runAction("固件可信公钥已登记;平台只保存公钥,不接触签名私钥。", async () => { await platformApi.registerFirmwareSigningKey(draft.household.id, { key_id: draft.keyID, public_key_base64: draft.publicKey }, accessToken); setFirmwareSigningKeys((await platformApi.firmwareSigningKeys(draft.household.id, accessToken)).data.items); setFirmwareSigningKeyID(draft.keyID); setFirmwareKeyID(""); setFirmwarePublicKey(""); }); else if (draft.kind === "firmware_key_revoke") completed = await runAction("固件可信公钥已吊销;现有历史记录保留,但不能再用于新制品或新灰度。", async () => { await platformApi.revokeFirmwareSigningKey(draft.household.id, draft.item.key_id, accessToken); setFirmwareSigningKeys((await platformApi.firmwareSigningKeys(draft.household.id, accessToken)).data.items); }); else if (draft.kind === "firmware_release_publish") completed = await runAction("固件已上传,并通过对象大小、SHA-256和Ed25519签名校验。", async () => { const prepared = await platformApi.prepareFirmwareRelease(draft.household.id, draft.input, accessToken); if (!prepared.data.upload_url) throw new Error("固件上传地址未生成"); const uploaded = await fetch(prepared.data.upload_url, { method: "PUT", body: draft.file, headers: { "Content-Type": "application/octet-stream" } }); if (!uploaded.ok) throw new Error(`固件对象上传失败(HTTP ${uploaded.status})`); await platformApi.finalizeFirmwareRelease(prepared.data.id, accessToken); setFirmwareReleases((await platformApi.firmwareReleases(draft.household.id, accessToken)).data.items); setFirmwareFile(undefined); setFirmwareDigest(""); setFirmwareVersion(""); setFirmwareSignature(""); setFirmwareReleaseNotes(""); }); else if (draft.kind === "firmware_release_resume") completed = await runAction("固件续传完成,并重新通过对象大小、SHA-256和Ed25519签名校验。", async () => { const renewed = await platformApi.renewFirmwareUpload(draft.item.id, accessToken); if (!renewed.data.upload_url) throw new Error("固件续传地址未生成"); const uploaded = await fetch(renewed.data.upload_url, { method: "PUT", body: draft.file, headers: { "Content-Type": "application/octet-stream" } }); if (!uploaded.ok) throw new Error(`固件对象续传失败(HTTP ${uploaded.status})`); await platformApi.finalizeFirmwareRelease(draft.item.id, accessToken); setFirmwareReleases((await platformApi.firmwareReleases(draft.household.id, accessToken)).data.items); }); else if (draft.kind === "firmware_release_pause_distribution") completed = await runAction("固件版本已暂停分发;新灰度和新下载已阻断,关联运行批次已暂停。", async () => { await platformApi.pauseFirmwareRelease(draft.item.id, accessToken); await refreshFirmwareLifecycle(draft.household.id); }); else if (draft.kind === "firmware_release_resume_distribution") completed = await runAction("固件版本已恢复为可分发;原有已暂停批次不会自动重启。", async () => { await platformApi.resumeFirmwareRelease(draft.item.id, accessToken); await refreshFirmwareLifecycle(draft.household.id); }); else if (draft.kind === "firmware_release_retire") completed = await runAction(draft.item.status === "UPLOADING" ? "未完成固件版本已退役并尝试清理对象。" : "已验证固件版本已退役;制品和历史记录保留,但不能再分发。", async () => { await platformApi.retireFirmwareRelease(draft.item.id, accessToken); await refreshFirmwareLifecycle(draft.household.id); }); else if (draft.kind === "firmware_rollout_start") completed = await runAction("固件灰度批次已启动,服务端已为实际命中的兼容设备创建可靠升级命令。", async () => { await platformApi.startFirmwareRollout(draft.household.id, { release_id: draft.release.id, name: draft.name, target_percentage: draft.percentage, device_ids: draft.devices.map((item) => item.id) }, accessToken); setRolloutReleaseID(""); setRolloutName(""); setRolloutPercentage(10); setRolloutDeviceIDs([]); }); else if (draft.kind === "consent_withdraw") completed = await runAction("授权已撤回;如当前通话使用了该授权,平台将立即安全停止通话并通知设备。", async () => { await platformApi.withdrawConsent(draft.subjectID, draft.item.id, accessToken); }); else if (draft.kind === "member_remove") completed = await runAction("成员已移出家庭。", async () => { await platformApi.removeMember(draft.household.id, draft.item, accessToken); }); else if (draft.kind === "ownership_transfer") completed = await runAction("家庭所有权已转移。", async () => { await platformApi.transferOwnership(draft.household, draft.item.id, accessToken); await loadRoot(); }); else if (draft.kind === "member_role_change") completed = await runAction("成员角色已更新。", async () => { await platformApi.updateMemberRole(draft.household.id, draft.item, draft.targetRole, accessToken); }); else if (draft.kind === "invitation_create") completed = await runAction("成员邀请已创建;请立即安全传递一次性凭据。", async () => { const response = await platformApi.createInvitation(draft.household.id, { invitee_hint: draft.inviteeHint, role: draft.role, expires_in_hours: draft.expiresInHours }, accessToken); setIssuedInvitationToken(response.data.token); setInviteeHint(""); }); else if (draft.kind === "invitation_revoke") completed = await runAction("成员邀请已撤销,原一次性凭据不能再被接受。", async () => { await platformApi.revokeInvitation(draft.household.id, draft.item.id, accessToken); }); else if (draft.kind === "fact_confirm") completed = await runAction("事实已确认,将从后续通话开始生效。", async () => { await platformApi.confirmFact(draft.subjectID, draft.item, accessToken); }); else if (draft.kind === "recording_access_grant") completed = await runAction(`${draft.input.permission === "PLAY" ? "播放" : "下载"}授权已生效。`, async () => { await platformApi.grantRecordingAccess(draft.subjectID, draft.input, accessToken); setRecordingGrants((await platformApi.recordingAccessGrants(draft.subjectID, accessToken)).data.items); if (detail) await loadConversationMedia(detail.id); }); else if (draft.kind === "recording_access_revoke") completed = await runAction("录音访问授权已撤销,后续短期地址将不再签发。", async () => { await platformApi.revokeRecordingAccess(draft.item.id, accessToken); setRecordingGrants((await platformApi.recordingAccessGrants(draft.subjectID, accessToken)).data.items); if (detail) await loadConversationMedia(detail.id); }); else if (draft.kind === "recording_play_address" || draft.kind === "recording_download_address") completed = await runAction(draft.kind === "recording_play_address" ? "已签发本次播放地址,有效期5分钟。" : "已签发本次下载地址,有效期5分钟。", async () => { const action = draft.kind === "recording_play_address" ? "PLAY" : "DOWNLOAD"; const response = await platformApi.createMediaAccess(draft.item.id, action, draft.purpose, accessToken); if (action === "PLAY") setAudioURLs((current) => ({ ...current, [draft.item.id]: response.data.access_url })); else { const link = document.createElement("a"); link.href = response.data.access_url; link.rel = "noopener"; document.body.appendChild(link); link.click(); link.remove(); } }); else if (draft.kind === "privacy_access_request") completed = await runAction("数据访问申请已受理,后台正在盘点当前长者的数据范围。", async () => { const response = await platformApi.createPrivacyAccessRequest(draft.subjectID, draft.categories, accessToken); const deadline = Date.now() + 15000; let current = response.data; while (!["COMPLETED", "PARTIAL_FAILED"].includes(current.status) && Date.now() < deadline) { await new Promise((resolve) => window.setTimeout(resolve, 500)); current = (await platformApi.privacyRequest(current.id, accessToken)).data; } }); else if (draft.kind === "privacy_export_request") completed = await runAction("私有数据导出已生成,可在有效期内申请短期下载地址。", async () => { const response = await platformApi.createExportTask(draft.subjectID, draft.categories, accessToken); const deadline = Date.now() + 20000; let current = response.data; while (!["READY", "FAILED", "EXPIRED"].includes(current.status) && Date.now() < deadline) { await new Promise((resolve) => window.setTimeout(resolve, 500)); current = (await platformApi.exportTask(current.id, accessToken)).data; } if (current.status !== "READY") throw new Error(current.last_error || "私有数据导出未能生成"); }); else if (draft.kind === "privacy_export_download") completed = await runAction("已签发本次私有导出下载地址,请在有效期内完成受控下载。", async () => { const current = (await platformApi.exportTask(draft.item.id, accessToken)).data; if (current.status !== "READY" || !current.download_url) throw new Error("导出文件尚未就绪或已过期"); const link = document.createElement("a"); link.href = current.download_url; link.rel = "noopener noreferrer"; link.download = `subject-data-${draft.item.id}.json`; document.body.appendChild(link); link.click(); link.remove(); }); else if (draft.kind === "device_claim") completed = await runAction("设备已绑定到当前长者。", async () => { await platformApi.claimDevice({ claim_code: draft.claimCode, household_id: draft.household.id, subject_id: draft.subjectID }, accessToken); setClaimCode(""); await loadRoot(); }); else if (draft.kind === "device_diagnostics") completed = await runAction("设备诊断命令已排队,设备在线后将拉取并回传结果。", async () => { await platformApi.createDeviceCommand(draft.item.id, "diagnostics", { checks: ["audio", "network", "storage"] }, accessToken); }); else if (draft.kind === "firmware_pause") completed = await runAction("固件批次已暂停,未投递命令已取消。", async () => { const response = await platformApi.pauseFirmwareRollout(draft.item.id, accessToken); setSelectedFirmwareRollout(response.data); }); else if (draft.kind === "firmware_rollback") completed = await runAction("固件回滚批次已创建。", async () => { const response = await platformApi.rollbackFirmwareRollout(draft.item.id, draft.release.id, accessToken); setSelectedFirmwareRollout(response.data); }); else if (draft.kind === "sharing_grant_create") completed = await runAction(draft.input.login_required ? "定向登录分享已生效。" : "匿名分享已生效;访问口令只显示一次,请安全转交。", async () => { const response = await platformApi.createSharingGrant(draft.input, accessToken); setIssuedShare({ id: response.data.id, token: response.data.access_token }); setSharingGrants((await platformApi.sharingGrants(draft.subjectID, accessToken)).data.items); }); else completed = await runAction("分享已撤销,后续资源请求将被拒绝。", async () => { await platformApi.revokeSharingGrant(draft.item.id, accessToken); if (selectedSubjectID) setSharingGrants((await platformApi.sharingGrants(selectedSubjectID, accessToken)).data.items); }); if (completed) setFamilyControlDraft(undefined); } return (

FAMILY COMPANION

{customerSectionTitle(tab)}

{customerSectionDescription(tab)}

{tab === "stories" ? : null} {eventStreamState === "connected" ? "状态实时更新" : eventStreamState === "reconnecting" ? "状态重连中" : "状态连接中"} {tab !== "members" ? : null} {tab === "subjects" && canWriteSubject ? : null} {tab === "subjects" && canWriteSubject && selectedSubjectID ? : null} {tab === "devices" && canManageDevice ? : null}
{invitationAcceptOpen ?
{ if (event.currentTarget === event.target) setInvitationAcceptOpen(false); }}>
{ void acceptInvitation(event); setInvitationAcceptOpen(false); }}>

接受家庭邀请

先预览家庭、角色和有效期,确认后才会加入。

: null} {householdCreateOpen ?
{ if (event.currentTarget === event.target) setHouseholdCreateOpen(false); }}>
{ void createHousehold(event); setHouseholdCreateOpen(false); }}>

新增家庭

创建后当前账号自动成为家庭所有者。

: null} {state === "loading" ? : null} {state === "error" ? void loadRoot()} /> : null} {state === "ready" && households.length === 0 ? setHouseholdCreateOpen(true)} actionLabel="新增家庭" /> : null} {actionMessage ?
{actionMessage}
: null} {actionError ?
{actionError}
: null} {familyControlDraft ?
void submitFamilyControl(event)}>

CONTROLLED FAMILY OPERATION

{familyControlTitle(familyControlDraft.kind)}

{familyControlToneLabel(familyControlDraft.kind)}

{familyControlTarget(familyControlDraft)}

{familyControlDraft.kind === "member_remove" || familyControlDraft.kind === "ownership_transfer" ? : familyControlDraft.kind === "member_role_change" ? <> : }
: null} {state === "ready" && selectedHousehold ? ( <> {tab === "home" ?
item.household_id === selectedHousehold.id).length} detail={`${devices.filter((item) => item.household_id === selectedHousehold.id && presence[item.id]?.online).length}台在线`} />
: null} {tab === "home" && selectedSubject ? selectSection(action === "EDIT_PROFILE" || action === "MANAGE_CONSENTS" ? "subjects" : "devices")} /> : null} {showNavigation ? : null} {subjectError ?
{subjectError}
: null} {tab === "home" ? item.household_id === selectedHousehold.id)} presence={presence} conversations={conversations} stories={stories} privacyRequests={privacyRequests} onOpen={(section) => selectSection(section)} /> : null} {tab === "members" ? setHouseholdCreateOpen(true)} onAcceptInvitation={() => setInvitationAcceptOpen(true)} onRenameHousehold={renameHousehold} inviteeHint={inviteeHint} onInviteeHint={setInviteeHint} inviteRole={inviteRole} onInviteRole={setInviteRole} issuedToken={issuedInvitationToken} onDismissToken={() => setIssuedInvitationToken(undefined)} onCreateInvitation={createInvitation} onUpdateRole={updateMemberRole} onRemove={removeMember} onRevoke={revokeInvitation} onTransfer={transferOwnership} /> : null} {tab === "subjects" ? (
{subjectCreateOpen && canWriteSubject ?
{ if (event.currentTarget === event.target) setSubjectCreateOpen(false); }}>
{ createSubject(event); setSubjectCreateOpen(false); }}>

新增长者

先建立基础档案,兴趣和交流偏好可以稍后继续完善

陪伴方式模板模板只填充建议值,保存前仍可逐项修改
{dialoguePresets.map((preset) => )}