feat: add hot-pluggable language shell UI system
This commit is contained in:
parent
621b714449
commit
43578222c2
15 changed files with 592 additions and 5 deletions
|
|
@ -0,0 +1,57 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DEFAULT_LANGUAGE_WORLD_UI_PLUGIN } from '@/lib/defaultLanguageWorldUiPlugin'
|
||||
import { HotPluggableLanguageShell } from './HotPluggableLanguageShell'
|
||||
|
||||
describe('HotPluggableLanguageShell', () => {
|
||||
it('renders host truth through a registered package and delegates actions', () => {
|
||||
const submitGoal = vi.fn()
|
||||
const answerConfirmation = vi.fn()
|
||||
render(<HotPluggableLanguageShell
|
||||
plugin={DEFAULT_LANGUAGE_WORLD_UI_PLUGIN}
|
||||
state={{
|
||||
status: '我正在理解并规划',
|
||||
confirmationQuestion: '这会公开发布,是否继续?',
|
||||
receipt: '尚未执行。',
|
||||
evidence: 'boundary=public_release',
|
||||
}}
|
||||
bridge={{ submitGoal, answerConfirmation }}
|
||||
/>)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('language goal'), { target: { value: '发布这个版本' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '同意继续' }))
|
||||
|
||||
expect(submitGoal).toHaveBeenCalledWith('发布这个版本')
|
||||
expect(answerConfirmation).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('我正在理解并规划')
|
||||
expect(screen.getByText('尚未执行。')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hot-swaps copy and structure when the package changes', () => {
|
||||
const next = structuredClone(DEFAULT_LANGUAGE_WORLD_UI_PLUGIN)
|
||||
next.manifest.id = 'bingshuo.language-world'
|
||||
next.layout.children = [{
|
||||
kind: 'stack',
|
||||
children: [
|
||||
{ kind: 'text', text: '冰朔,今天从哪里继续?' },
|
||||
{ kind: 'language_input', action: 'goal.submit', text: '进入' },
|
||||
{ kind: 'task_status' },
|
||||
{ kind: 'reality_boundary_confirmation', action: 'confirmation.answer', approveText: '继续', declineText: '停下' },
|
||||
{ kind: 'human_receipt' },
|
||||
{ kind: 'evidence_toggle', action: 'evidence.expand', text: '依据' },
|
||||
],
|
||||
}]
|
||||
const props = {
|
||||
state: { status: '等待目标', confirmationQuestion: null, receipt: null },
|
||||
bridge: { submitGoal: vi.fn(), answerConfirmation: vi.fn() },
|
||||
}
|
||||
const view = render(<HotPluggableLanguageShell plugin={DEFAULT_LANGUAGE_WORLD_UI_PLUGIN} {...props} />)
|
||||
|
||||
view.rerender(<HotPluggableLanguageShell plugin={next} {...props} />)
|
||||
|
||||
expect(screen.getByText('冰朔,今天从哪里继续?')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '进入' })).toBeInTheDocument()
|
||||
expect(document.querySelector('[data-ui-plugin="bingshuo.language-world"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { HoloLakeUiPlugin, UiPluginNode } from '@/lib/uiPluginSystem'
|
||||
import { applyUiPluginTokens, validateUiPlugin } from '@/lib/uiPluginSystem'
|
||||
|
||||
export type LanguageShellViewState = {
|
||||
status: string
|
||||
confirmationQuestion: string | null
|
||||
receipt: string | null
|
||||
evidence?: string | null
|
||||
}
|
||||
|
||||
export type LanguageShellBridge = {
|
||||
submitGoal(goal: string): void
|
||||
answerConfirmation(approved: boolean): void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
plugin: HoloLakeUiPlugin
|
||||
state: LanguageShellViewState
|
||||
bridge: LanguageShellBridge
|
||||
}
|
||||
|
||||
export function HotPluggableLanguageShell({ plugin, state, bridge }: Props) {
|
||||
const validation = validateUiPlugin(plugin)
|
||||
const [goal, setGoal] = useState('')
|
||||
const [evidenceOpen, setEvidenceOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!validation.ok) return
|
||||
const root = document.documentElement.style
|
||||
const names = applyUiPluginTokens(plugin, root)
|
||||
return () => names.forEach((name) => root.removeProperty(name))
|
||||
}, [plugin, validation.ok])
|
||||
|
||||
if (!validation.ok) throw new Error(`UI_PLUGIN_REJECTED:${validation.errors.join(',')}`)
|
||||
|
||||
const renderNode = (node: UiPluginNode, path: string): React.ReactNode => {
|
||||
const children = node.children?.map((child, index) => renderNode(child, `${path}.${index}`))
|
||||
switch (node.kind) {
|
||||
case 'shell': return <main key={path} data-ui-plugin={plugin.manifest.id}>{children}</main>
|
||||
case 'stack': return <section key={path} className="flex flex-col gap-4">{children}</section>
|
||||
case 'row': return <div key={path} className="flex items-center gap-3">{children}</div>
|
||||
case 'text': return <p key={path}>{node.text}</p>
|
||||
case 'language_input': return (
|
||||
<form key={path} className="flex gap-3" onSubmit={(event) => { event.preventDefault(); bridge.submitGoal(goal) }}>
|
||||
<Input value={goal} onChange={(event) => setGoal(event.target.value)} aria-label="language goal" />
|
||||
<Button type="submit">{node.text}</Button>
|
||||
</form>
|
||||
)
|
||||
case 'task_status': return <p key={path} role="status">{state.status}</p>
|
||||
case 'reality_boundary_confirmation': return state.confirmationQuestion ? (
|
||||
<section key={path} aria-label="reality boundary confirmation">
|
||||
<p>{state.confirmationQuestion}</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => bridge.answerConfirmation(true)}>{node.approveText}</Button>
|
||||
<Button variant="outline" onClick={() => bridge.answerConfirmation(false)}>{node.declineText}</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : <span key={path} hidden />
|
||||
case 'human_receipt': return state.receipt ? <p key={path}>{state.receipt}</p> : <span key={path} hidden />
|
||||
case 'evidence_toggle': return (
|
||||
<section key={path}>
|
||||
<Button variant="ghost" onClick={() => setEvidenceOpen((open) => !open)}>{node.text}</Button>
|
||||
{evidenceOpen && state.evidence ? <pre>{state.evidence}</pre> : null}
|
||||
</section>
|
||||
)
|
||||
case 'button': return <Button key={path}>{node.text}</Button>
|
||||
}
|
||||
}
|
||||
|
||||
return renderNode(plugin.layout, 'root')
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { HoloLakeUiPlugin } from './uiPluginSystem'
|
||||
|
||||
export const DEFAULT_LANGUAGE_WORLD_UI_PLUGIN: HoloLakeUiPlugin = {
|
||||
manifest: {
|
||||
schema: 'hololake.ui-plugin/v1',
|
||||
id: 'guanghu.default-language-world',
|
||||
version: '1.0.0',
|
||||
target: 'language_shell',
|
||||
requestedCapabilities: ['goal.submit', 'confirmation.answer', 'evidence.expand'],
|
||||
},
|
||||
tokens: {
|
||||
'--hl-world-background': '#07111f',
|
||||
'--hl-world-foreground': '#f6f1df',
|
||||
'--hl-world-accent': '#d7bd78',
|
||||
},
|
||||
layout: {
|
||||
kind: 'shell',
|
||||
children: [{
|
||||
kind: 'stack',
|
||||
children: [
|
||||
{ kind: 'text', text: '你想让世界发生什么?' },
|
||||
{ kind: 'language_input', action: 'goal.submit', text: '开始' },
|
||||
{ kind: 'task_status' },
|
||||
{ kind: 'reality_boundary_confirmation', action: 'confirmation.answer', approveText: '同意继续', declineText: '暂不继续' },
|
||||
{ kind: 'human_receipt' },
|
||||
{ kind: 'evidence_toggle', action: 'evidence.expand', text: '查看依据' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateUiPlugin, type HoloLakeUiPlugin } from './uiPluginSystem'
|
||||
|
||||
type Registry = {
|
||||
schema: string
|
||||
plugins: Array<{ id: string; version: string; path: string }>
|
||||
}
|
||||
|
||||
describe('registered UI plugins', () => {
|
||||
it('resolve to valid packages with matching immutable identities', () => {
|
||||
const root = resolve(process.cwd(), 'ui-plugins')
|
||||
const registry = JSON.parse(readFileSync(resolve(root, 'registry.json'), 'utf8')) as Registry
|
||||
expect(registry.schema).toBe('hololake.ui-plugin-registry/v1')
|
||||
expect(registry.plugins.length).toBeGreaterThan(0)
|
||||
|
||||
for (const entry of registry.plugins) {
|
||||
const plugin = JSON.parse(readFileSync(resolve(root, entry.path), 'utf8')) as HoloLakeUiPlugin
|
||||
expect(plugin.manifest.id).toBe(entry.id)
|
||||
expect(plugin.manifest.version).toBe(entry.version)
|
||||
expect(validateUiPlugin(plugin)).toEqual({ ok: true, errors: [] })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createUiPluginRuntime,
|
||||
validateUiPlugin,
|
||||
type HoloLakeUiPlugin,
|
||||
} from './uiPluginSystem'
|
||||
|
||||
const validPlugin = (): HoloLakeUiPlugin => ({
|
||||
manifest: {
|
||||
schema: 'hololake.ui-plugin/v1',
|
||||
id: 'ice-lake.language-world',
|
||||
version: '1.0.0',
|
||||
target: 'language_shell',
|
||||
requestedCapabilities: ['goal.submit', 'confirmation.answer', 'evidence.expand'],
|
||||
},
|
||||
tokens: {
|
||||
'--hl-world-background': '#07111f',
|
||||
'--hl-world-foreground': '#f6f1df',
|
||||
'--hl-world-accent': '#d7bd78',
|
||||
},
|
||||
layout: {
|
||||
kind: 'shell',
|
||||
children: [
|
||||
{ kind: 'language_input', action: 'goal.submit', text: '开始' },
|
||||
{ kind: 'task_status' },
|
||||
{ kind: 'reality_boundary_confirmation', action: 'confirmation.answer', approveText: '继续', declineText: '暂停' },
|
||||
{ kind: 'human_receipt' },
|
||||
{ kind: 'evidence_toggle', action: 'evidence.expand', text: '查看依据' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
describe('uiPluginSystem', () => {
|
||||
it('accepts a complete declarative language-shell package', () => {
|
||||
expect(validateUiPlugin(validPlugin())).toEqual({ ok: true, errors: [] })
|
||||
})
|
||||
|
||||
it('rejects packages that hide a required human surface', () => {
|
||||
const plugin = validPlugin()
|
||||
plugin.layout.children = plugin.layout.children?.filter((node) => node.kind !== 'human_receipt')
|
||||
|
||||
expect(validateUiPlugin(plugin)).toEqual({
|
||||
ok: false,
|
||||
errors: ['REQUIRED_SURFACE_MISSING:human_receipt'],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects internal controls and undeclared actions', () => {
|
||||
const plugin = validPlugin()
|
||||
plugin.layout.children?.push({ kind: 'node_identity' as never })
|
||||
plugin.layout.children?.push({ kind: 'button', action: 'repository.push' as never })
|
||||
|
||||
expect(validateUiPlugin(plugin).errors).toEqual([
|
||||
'SYSTEM_INTERNAL_SURFACE_FORBIDDEN:node_identity',
|
||||
'ACTION_NOT_ALLOWED:repository.push',
|
||||
])
|
||||
})
|
||||
|
||||
it('switches complete packages atomically and can roll back', () => {
|
||||
const first = validPlugin()
|
||||
const second = validPlugin()
|
||||
second.manifest.id = 'bingshuo.language-world'
|
||||
const runtime = createUiPluginRuntime(first)
|
||||
const listener = vi.fn()
|
||||
runtime.subscribe(listener)
|
||||
|
||||
expect(runtime.activate(second).ok).toBe(true)
|
||||
expect(runtime.current().manifest.id).toBe('bingshuo.language-world')
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.rollback()).toBe(true)
|
||||
expect(runtime.current().manifest.id).toBe('ice-lake.language-world')
|
||||
})
|
||||
|
||||
it('keeps the last-known-good package when a candidate is invalid', () => {
|
||||
const first = validPlugin()
|
||||
const invalid = validPlugin()
|
||||
invalid.layout.children = []
|
||||
const runtime = createUiPluginRuntime(first)
|
||||
|
||||
expect(runtime.activate(invalid).ok).toBe(false)
|
||||
expect(runtime.current()).toBe(first)
|
||||
})
|
||||
|
||||
it('rejects unsafe token names and values', () => {
|
||||
const plugin = validPlugin()
|
||||
plugin.tokens['background'] = 'red'
|
||||
plugin.tokens['--hl-world-image'] = 'url(javascript:alert(1))'
|
||||
|
||||
expect(validateUiPlugin(plugin).errors).toEqual([
|
||||
'TOKEN_NAME_NOT_ALLOWED:background',
|
||||
'TOKEN_VALUE_UNSAFE:--hl-world-image',
|
||||
])
|
||||
})
|
||||
})
|
||||
150
product-source/hololake-platform/src/lib/uiPluginSystem.ts
Normal file
150
product-source/hololake-platform/src/lib/uiPluginSystem.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { DEFAULT_HUMAN_SURFACE, SYSTEM_INTERNAL_SURFACE } from './languageOperatingModel'
|
||||
|
||||
export const HOLOLAKE_UI_PLUGIN_SCHEMA = 'hololake.ui-plugin/v1' as const
|
||||
|
||||
export type UiPluginCapability =
|
||||
| 'goal.submit'
|
||||
| 'confirmation.answer'
|
||||
| 'evidence.expand'
|
||||
|
||||
export type UiPluginNodeKind =
|
||||
| 'shell'
|
||||
| 'stack'
|
||||
| 'row'
|
||||
| 'text'
|
||||
| 'button'
|
||||
| typeof DEFAULT_HUMAN_SURFACE[number]
|
||||
| 'evidence_toggle'
|
||||
|
||||
export type UiPluginNode = {
|
||||
kind: UiPluginNodeKind
|
||||
id?: string
|
||||
text?: string
|
||||
approveText?: string
|
||||
declineText?: string
|
||||
action?: UiPluginCapability
|
||||
children?: UiPluginNode[]
|
||||
}
|
||||
|
||||
export type HoloLakeUiPlugin = {
|
||||
manifest: {
|
||||
schema: typeof HOLOLAKE_UI_PLUGIN_SCHEMA
|
||||
id: string
|
||||
version: string
|
||||
target: 'language_shell'
|
||||
requestedCapabilities: UiPluginCapability[]
|
||||
}
|
||||
tokens: Record<string, string>
|
||||
layout: UiPluginNode
|
||||
}
|
||||
|
||||
export type UiPluginValidation = { ok: boolean; errors: string[] }
|
||||
|
||||
const ALLOWED_ACTIONS = new Set<UiPluginCapability>([
|
||||
'goal.submit',
|
||||
'confirmation.answer',
|
||||
'evidence.expand',
|
||||
])
|
||||
|
||||
const SAFE_TOKEN_NAME = /^--hl-world-[a-z0-9-]+$/
|
||||
const UNSAFE_TOKEN_VALUE = /(?:javascript:|expression\s*\(|@import|<\/?script)/i
|
||||
|
||||
function walk(node: UiPluginNode, visit: (node: UiPluginNode) => void): void {
|
||||
visit(node)
|
||||
node.children?.forEach((child) => walk(child, visit))
|
||||
}
|
||||
|
||||
export function validateUiPlugin(plugin: HoloLakeUiPlugin): UiPluginValidation {
|
||||
const errors: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
if (plugin.manifest.schema !== HOLOLAKE_UI_PLUGIN_SCHEMA) errors.push('SCHEMA_NOT_SUPPORTED')
|
||||
if (!/^[a-z0-9][a-z0-9.-]+$/.test(plugin.manifest.id)) errors.push('PLUGIN_ID_INVALID')
|
||||
if (!/^\d+\.\d+\.\d+$/.test(plugin.manifest.version)) errors.push('PLUGIN_VERSION_INVALID')
|
||||
|
||||
walk(plugin.layout, (node) => {
|
||||
seen.add(node.kind)
|
||||
if ((SYSTEM_INTERNAL_SURFACE as readonly string[]).includes(node.kind)) {
|
||||
errors.push(`SYSTEM_INTERNAL_SURFACE_FORBIDDEN:${node.kind}`)
|
||||
}
|
||||
const actionAllowed = !node.action || ALLOWED_ACTIONS.has(node.action)
|
||||
if (node.action && !actionAllowed) {
|
||||
errors.push(`ACTION_NOT_ALLOWED:${node.action}`)
|
||||
}
|
||||
if (node.action && actionAllowed && !plugin.manifest.requestedCapabilities.includes(node.action)) {
|
||||
errors.push(`ACTION_NOT_DECLARED:${node.action}`)
|
||||
}
|
||||
if (node.kind === 'language_input' && !node.text?.trim()) errors.push('COPY_REQUIRED:language_input')
|
||||
if (node.kind === 'reality_boundary_confirmation'
|
||||
&& (!node.approveText?.trim() || !node.declineText?.trim())) {
|
||||
errors.push('COPY_REQUIRED:reality_boundary_confirmation')
|
||||
}
|
||||
if (node.kind === 'evidence_toggle' && !node.text?.trim()) errors.push('COPY_REQUIRED:evidence_toggle')
|
||||
})
|
||||
|
||||
for (const surface of DEFAULT_HUMAN_SURFACE) {
|
||||
if (!seen.has(surface)) errors.push(`REQUIRED_SURFACE_MISSING:${surface}`)
|
||||
}
|
||||
|
||||
for (const capability of plugin.manifest.requestedCapabilities) {
|
||||
if (!ALLOWED_ACTIONS.has(capability)) errors.push(`CAPABILITY_NOT_ALLOWED:${capability}`)
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(plugin.tokens)) {
|
||||
if (!SAFE_TOKEN_NAME.test(name)) errors.push(`TOKEN_NAME_NOT_ALLOWED:${name}`)
|
||||
if (UNSAFE_TOKEN_VALUE.test(value)) errors.push(`TOKEN_VALUE_UNSAFE:${name}`)
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
export type UiPluginRuntime = {
|
||||
current(): HoloLakeUiPlugin
|
||||
activate(candidate: HoloLakeUiPlugin): UiPluginValidation
|
||||
rollback(): boolean
|
||||
subscribe(listener: (plugin: HoloLakeUiPlugin) => void): () => void
|
||||
}
|
||||
|
||||
export function createUiPluginRuntime(initial: HoloLakeUiPlugin): UiPluginRuntime {
|
||||
const initialValidation = validateUiPlugin(initial)
|
||||
if (!initialValidation.ok) throw new Error(initialValidation.errors.join(','))
|
||||
|
||||
let active = initial
|
||||
let previous: HoloLakeUiPlugin | null = null
|
||||
const listeners = new Set<(plugin: HoloLakeUiPlugin) => void>()
|
||||
const publish = () => listeners.forEach((listener) => listener(active))
|
||||
|
||||
return {
|
||||
current: () => active,
|
||||
activate(candidate) {
|
||||
const result = validateUiPlugin(candidate)
|
||||
if (!result.ok) return result
|
||||
previous = active
|
||||
active = candidate
|
||||
publish()
|
||||
return result
|
||||
},
|
||||
rollback() {
|
||||
if (!previous) return false
|
||||
const replacement = previous
|
||||
previous = active
|
||||
active = replacement
|
||||
publish()
|
||||
return true
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function applyUiPluginTokens(
|
||||
plugin: HoloLakeUiPlugin,
|
||||
target: Pick<CSSStyleDeclaration, 'setProperty' | 'removeProperty'>,
|
||||
previousTokenNames: readonly string[] = [],
|
||||
): string[] {
|
||||
previousTokenNames.forEach((name) => target.removeProperty(name))
|
||||
Object.entries(plugin.tokens).forEach(([name, value]) => target.setProperty(name, value))
|
||||
return Object.keys(plugin.tokens)
|
||||
}
|
||||
Loading…
Reference in a new issue