feat: add hot-pluggable language shell UI system

This commit is contained in:
冰朔 2026-08-10 12:50:28 +08:00
commit 43578222c2
15 changed files with 592 additions and 5 deletions

View file

@ -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: '查看依据' },
],
}],
},
}

View file

@ -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: [] })
}
})
})

View file

@ -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',
])
})
})

View 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)
}