hololake-system-architecture/product-source/hololake-native-desktop/src/modules/human-authorization-center.tsx

210 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from 'react'
import { numberedInvoke } from './numbered-ipc'
export interface DirectSessionProjection {
sessionId: string
laneId: string
clientInstanceId: string
state: 'LIVE' | 'RESUMABLE'
openedAtUnixMs: number
observedAtUnixMs: number
lastEventSequence: number
}
interface AuthorizationRequest {
requestId: string
action: 'OPEN_MAINTENANCE' | 'UNMOUNT' | 'PROMOTE_VERSION' | 'RETIRE'
targetNumber: string
targetKind: string
targetLabel: string
reason: string
impact: string
rollbackPlan: string
requesterLabel: string
state: 'PENDING_HUMAN' | 'APPROVED' | 'DENIED' | 'EXPIRED' | 'CONSUMED'
createdAtUnixMs: number
expiresAtUnixMs: number
humanNumber?: string
decidedAtUnixMs?: number
ticketId?: string
ticketExpiresAtUnixMs?: number
consumedAtUnixMs?: number
receiptHash: string
}
interface AuthorizationCenterSnapshot {
state: string
pendingCount: number
requests: AuthorizationRequest[]
purgeEnabled: boolean
}
interface GatewaySkill {
name: string
summary: string
version: string
state: string
readOnly: boolean
executionAuthority: boolean
}
interface GatewayIntegration {
name: string
summary: string
state: string
exposed: boolean
}
interface ExternalAiGatewayStatus {
state: string
exposure: 'OPEN' | 'CLOSED'
humanAuthorizationRequired: boolean
mcpTransport: string
mcpProtocolVersion: string
mcpCommand: string
directProtocol: string
directConnectorCommand: string
brokerState: string
registeredSkillCount: number
activeSkillCount: number
connectedIntegrationCount: number
integrations: GatewayIntegration[]
skills: GatewaySkill[]
}
interface Props {
brokerState: string
activeConnectionCount: number
sessions: DirectSessionProjection[]
}
const actionLabels: Record<AuthorizationRequest['action'], string> = {
OPEN_MAINTENANCE: '打开受控维护区',
UNMOUNT: '从当前频道卸载',
PROMOTE_VERSION: '登记并启用新版本',
RETIRE: '停用并保留历史坐标',
}
const stateLabels: Record<AuthorizationRequest['state'], string> = {
PENDING_HUMAN: '等待你决定', APPROVED: '已同意 · 等待执行体领取', DENIED: '已拒绝',
EXPIRED: '已过期', CONSUMED: '票据已领取一次',
}
function time(value?: number) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
}
export function HumanAuthorizationCenter({ brokerState, activeConnectionCount, sessions }: Props) {
const [snapshot, setSnapshot] = useState<AuthorizationCenterSnapshot | null>(null)
const [gateway, setGateway] = useState<ExternalAiGatewayStatus | null>(null)
const [busy, setBusy] = useState('')
const [message, setMessage] = useState('')
const [gatewayMessage, setGatewayMessage] = useState('')
const refresh = useCallback(async () => {
try {
setSnapshot(await numberedInvoke<AuthorizationCenterSnapshot>('get_authorization_center'))
setMessage('')
} catch (error) {
setSnapshot(null)
setMessage(String(error).includes('AUTHENTICATED_ACCOUNT_REQUIRED') ? '登录并完成编号验证后,授权中心才会打开。' : '授权中心当前不可读取;系统不会把未知状态当成已授权。')
}
try {
setGateway(await numberedInvoke<ExternalAiGatewayStatus>('get_external_ai_gateway_status'))
setGatewayMessage('')
} catch {
setGateway(null)
setGatewayMessage('外部编程 AI 入口当前不可读取,因此保持关闭。')
}
}, [])
useEffect(() => {
void refresh()
const timer = window.setInterval(() => { if (!document.hidden) void refresh() }, 4_000)
return () => window.clearInterval(timer)
}, [refresh])
const decide = async (requestId: string, decision: 'APPROVE' | 'DENY') => {
setBusy(requestId)
setMessage('')
try {
await numberedInvoke('decide_authorization_request', { input: { requestId, decision } })
setMessage(decision === 'APPROVE' ? '已签发一次性票据;它只对原执行会话和本次目标有效。' : '已拒绝;执行体不会获得继续通道。')
await refresh()
} catch {
setMessage('这次决定没有写入。授权状态保持不变,请刷新后再看。')
} finally {
setBusy('')
}
}
const pending = snapshot?.requests.filter((item) => item.state === 'PENDING_HUMAN') || []
const history = snapshot?.requests.filter((item) => item.state !== 'PENDING_HUMAN').slice(0, 6) || []
const connectionState = activeConnectionCount > 0 ? '正在直连' : sessions.some((item) => item.state === 'LIVE') ? '最近仍有心跳' : '当前没有在线执行体'
const sessionList = useMemo(() => sessions.slice(0, 6), [sessions])
const setGatewayExposure = async (enabled: boolean) => {
setBusy('external-ai-gateway')
setGatewayMessage('')
try {
const next = await numberedInvoke<ExternalAiGatewayStatus>('set_external_ai_gateway_exposure', { input: { enabled } })
setGateway(next)
setGatewayMessage(enabled ? '已允许本机编程 AI 发现 HoloLake持续协作仍需切换到 HoloLake 本地直连协议。' : '外部编程 AI 入口已关闭;现有 HoloLake 数据与本机会话没有被删除。')
} catch {
setGatewayMessage('入口状态没有改变。请确认编号已验证后再试。')
} finally {
setBusy('')
}
}
return <div className="human-authorization-layout">
<section className="plain-panel authorization-primary">
<header><div><span className="kicker">HUMAN DECISION</span><h2></h2><p>退</p></div><span className={pending.length ? 'status-chip attention' : 'status-chip online'}>{pending.length ? `${pending.length} 项待确认` : '没有待确认事项'}</span></header>
{pending.length === 0 ? <div className="authorization-empty"><b></b><span></span></div> : <div className="authorization-list">
{pending.map((request) => <article className="authorization-card" key={request.requestId}>
<div className="authorization-card-title"><div><small>{actionLabels[request.action]}</small><h3>{request.targetLabel}</h3><code>{request.targetNumber}</code></div><span>{stateLabels[request.state]}</span></div>
<dl><div><dt></dt><dd>{request.reason}</dd></div><div><dt></dt><dd>{request.impact}</dd></div><div><dt>退</dt><dd>{request.rollbackPlan}</dd></div><div><dt></dt><dd>{request.requesterLabel}</dd></div><div><dt></dt><dd>{time(request.expiresAtUnixMs)}</dd></div></dl>
<div className="authorization-actions"><button className="secondary-button" disabled={busy === request.requestId} onClick={() => void decide(request.requestId, 'DENY')}></button><button className="primary-button" disabled={busy === request.requestId} onClick={() => void decide(request.requestId, 'APPROVE')}>{busy === request.requestId ? '正在写入决定…' : '同意并签发一次性票据'}</button></div>
</article>)}
</div>}
{message && <p className="global-message">{message}</p>}
</section>
<section className="plain-panel collaboration-live">
<header><div><span className="kicker">LIVE COLLABORATION</span><h2> HoloLake</h2><p> Agent</p></div><span className={activeConnectionCount > 0 ? 'status-chip online' : 'status-chip'}>{connectionState}</span></header>
<div className="collaboration-summary"><div><b>{activeConnectionCount}</b><span></span></div><div><b>{sessions.length}</b><span></span></div><div><b>{brokerState === 'READY' ? '就绪' : '等待'}</b><span></span></div></div>
{sessionList.length ? <div className="session-projection-list">{sessionList.map((session) => <article key={session.sessionId}><i className={session.state === 'LIVE' ? 'live' : ''}/><div><b>{session.clientInstanceId}</b><span>{session.laneId} · {time(session.observedAtUnixMs)}</span></div><em>{session.state === 'LIVE' ? '在线' : '可续接'}</em></article>)}</div> : <div className="authorization-empty compact"><b></b><span> AI HoloLake </span></div>}
</section>
<section className="plain-panel external-ai-gateway-panel">
<header>
<div><span className="kicker">EXTERNAL PROGRAMMING AI</span><h2> AI </h2><p> MCP HoloLake HoloLake </p></div>
<span className={gateway?.exposure === 'OPEN' ? 'status-chip online' : 'status-chip'}>{gateway?.exposure === 'OPEN' ? '接口已开放' : '接口已关闭'}</span>
</header>
<label className="gateway-exposure-control">
<span><b> AI HoloLake</b><small> MCP </small></span>
<input type="checkbox" checked={gateway?.exposure === 'OPEN'} disabled={!gateway || busy === 'external-ai-gateway'} onChange={(event) => void setGatewayExposure(event.target.checked)}/>
<i aria-hidden="true"/>
</label>
<div className="gateway-summary">
<div><b>{gateway?.registeredSkillCount ?? 0}</b><span></span></div>
<div><b>{gateway?.connectedIntegrationCount ?? 0}</b><span></span></div>
<div><b>{gateway?.activeSkillCount ?? 0}</b><span></span></div>
</div>
<div className="gateway-catalog-grid">
<section>
<header><h3></h3><span></span></header>
<div className="gateway-capability-list">{gateway?.integrations.map((integration) => <article key={integration.name}><i className={integration.exposed ? 'live' : ''}/><div><b>{integration.name}</b><p>{integration.summary}</p></div><em>{integration.state}</em></article>) || <div className="authorization-empty compact"><b></b><span></span></div>}</div>
</section>
<section>
<header><h3></h3><span></span></header>
<div className="gateway-capability-list">{gateway?.skills.map((skill) => <article key={`${skill.name}:${skill.version}`}><i className={skill.state === 'ACTIVE_READONLY' ? 'live' : ''}/><div><b>{skill.name}</b><p>{skill.summary}</p></div><em>{skill.state === 'ACTIVE_READONLY' ? '已启用' : '可安装'}</em></article>) || <div className="authorization-empty compact"><b></b><span></span></div>}</div>
</section>
</div>
<details className="gateway-connection-details"><summary></summary><dl><div><dt>MCP </dt><dd>{gateway?.mcpCommand || '关闭时不暴露命令'}</dd></div><div><dt>HoloLake </dt><dd>{gateway?.directConnectorCommand || '等待本机入口'}</dd></div><div><dt></dt><dd>MCP {gateway?.directProtocol || 'HOLOLAKE_TERMINAL_LINK/3'} </dd></div></dl></details>
{gatewayMessage && <p className="gateway-message" aria-live="polite">{gatewayMessage}</p>}
</section>
{history.length > 0 && <details className="authorization-history"><summary> · {history.length}</summary><div>{history.map((request) => <article key={request.requestId}><div><b>{request.targetLabel}</b><span>{actionLabels[request.action]}</span></div><em>{stateLabels[request.state]}</em><small>{request.humanNumber ? `${request.humanNumber} 决定 · ` : ''}{time(request.decidedAtUnixMs || request.createdAtUnixMs)}</small></article>)}</div></details>}
</div>
}