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,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()
})
})

View file

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