2026-08-19 18:24:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 22:52:10 +08:00
|
|
|
|
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[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 18:24:50 +08:00
|
|
|
|
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)
|
2026-08-19 22:52:10 +08:00
|
|
|
|
const [gateway, setGateway] = useState<ExternalAiGatewayStatus | null>(null)
|
2026-08-19 18:24:50 +08:00
|
|
|
|
const [busy, setBusy] = useState('')
|
|
|
|
|
|
const [message, setMessage] = useState('')
|
2026-08-19 22:52:10 +08:00
|
|
|
|
const [gatewayMessage, setGatewayMessage] = useState('')
|
2026-08-19 18:24:50 +08:00
|
|
|
|
|
|
|
|
|
|
const refresh = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSnapshot(await numberedInvoke<AuthorizationCenterSnapshot>('get_authorization_center'))
|
|
|
|
|
|
setMessage('')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setSnapshot(null)
|
|
|
|
|
|
setMessage(String(error).includes('AUTHENTICATED_ACCOUNT_REQUIRED') ? '登录并完成编号验证后,授权中心才会打开。' : '授权中心当前不可读取;系统不会把未知状态当成已授权。')
|
|
|
|
|
|
}
|
2026-08-19 22:52:10 +08:00
|
|
|
|
try {
|
|
|
|
|
|
setGateway(await numberedInvoke<ExternalAiGatewayStatus>('get_external_ai_gateway_status'))
|
|
|
|
|
|
setGatewayMessage('')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setGateway(null)
|
|
|
|
|
|
setGatewayMessage('外部编程 AI 入口当前不可读取,因此保持关闭。')
|
|
|
|
|
|
}
|
2026-08-19 18:24:50 +08:00
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
2026-08-19 22:52:10 +08:00
|
|
|
|
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('')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 18:24:50 +08:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-08-19 22:52:10 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-08-19 18:24:50 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
}
|