镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/website/content/docs/chat/guides/generative-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ description: How the generative UI example authors a dashboard spec as message c

An agent can return the interface rather than describe it. It writes a declarative json-render specification as the assistant message content, and the `<chat>` composition detects that content, parses it while it is still streaming, and mounts it through the `views` registry you supply. The running example is an airline operations dashboard, and this guide walks the three files that build it.

This default Angular guide describes progressive rendering. Select **React** in Example UI for the experimental native preview, which publishes confirmed layouts and data together after saved-state verification.

The detection and the rendering are adapter-neutral. This page uses `@threadplane/langgraph`; the same registry and the same store work over the [AG-UI adapter](/docs/ag-ui/guides/json-render), where only the way the backend delivers the data differs.

## What the demo does
Expand Down Expand Up @@ -43,12 +45,12 @@ The rest of `wrap_spec_into_ai` replaces both messages, keeping their ids so Lan
The tool message becomes a short placeholder and the assistant message takes the specification JSON, stripped of a code fence if the model wrapped one around it.

<Callout type="info" title="Why the message and not the state">
The specification travels as message content because that is what the client classifies. `DashboardState` in this example adds no fields of its own to `MessagesState`: the layout lives in the message, and the numbers arrive as the state updates below.
The specification travels as message content because that is what the client classifies. `DashboardState` extends `MessagesState` with persisted dashboard data and completion evidence. The layout stays in the message. The same tool data is saved in the dashboard field and sent through the custom updates below, which keep the Angular views progressive.
</Callout>

### Sending the tool data as state updates

The data tools return their results as tool messages, which the browser would otherwise only see as tool output. `emit_state` walks this turn's messages back to the most recent user message, turns each known tool result into JSON Pointer paths, and writes them to the stream as one custom event named `state_update`.
The data tools return their results as tool messages, which the browser would otherwise only see as tool output. `emit_state` processes successful known tool results from the current turn in chronological order, with the newest result for each dashboard slice winning. It turns each result into JSON Pointer paths, and writes them to the stream as one custom event named `state_update`.

<ExampleCode file="graph.py" region="emit-state" title="graph.py — emit_state" />

Expand Down Expand Up @@ -92,7 +94,7 @@ From there the content goes through `@cacheplane/partial-json`, and a `ParseTree
The data takes the other path. The LangGraph adapter surfaces a custom event named `state_update` on the agent's event stream, and the composition writes its payload straight into the render store, keys included. Because `emit_state` already emits JSON Pointer keys, a binding of `{ "$state": "/on_time/value" }` resolves the moment the event lands.

<Callout type="info" title="Pointers, not top-level keys">
This is the one place the LangGraph path differs from AG-UI. An AG-UI agent puts its data in graph state, and the composition maps each top-level key `k` of any adapter's `state()` to the pointer `/k`. Here the graph names the pointers itself, so a nested value like `/on_time/value` needs no state field to hold it.
This is the one place the LangGraph path differs from AG-UI. An AG-UI agent puts its data in graph state, and the composition maps each top-level key `k` of any adapter's `state()` to the pointer `/k`. Here the graph names the pointers itself in its custom `state_update` event. A nested value such as `/on_time/value` updates the Angular store directly, while the graph also persists that data in its owned `dashboard` field for saved-state confirmation.
</Callout>

## Writing a view component
Expand Down
206 changes: 206 additions & 0 deletions apps/website/e2e/react-chat-generative-ui-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { test, expect, type Page } from '@playwright/test';
import { fromPageOrReactPreview } from './fixtures/react-preview-requests';
const route = '/docs/chat/guides/generative-ui';
const reactFrame = /(?:localhost:4627|chat\/generative-ui\/react)/;
const observations = new WeakMap<
Page,
{ requests: string[]; errors: string[] }
>();
test.beforeEach(async ({ page }) => {
const observed = { requests: [] as string[], errors: [] as string[] };
observations.set(page, observed);
page.on('request', (request) => {
if (
['fetch', 'xhr'].includes(request.resourceType()) &&
/\/threads(?:\/|$)/.test(new URL(request.url()).pathname) &&
fromPageOrReactPreview(request, reactFrame)
)
observed.requests.push(request.method());
});
page.on('pageerror', (error) => observed.errors.push(error.message));
});
test.afterEach(async ({ page }) => {
expect(observations.get(page)?.requests).toEqual([]);
expect(observations.get(page)?.errors).toEqual([]);
});
async function hydrated(page: Page, mode: 'docs' | 'code' | 'run') {
await expect(page.locator('[data-workspace-shell]')).toHaveAttribute(
'data-hydrated',
'true'
);
await expect(page.locator('[data-workspace-shell]')).toHaveAttribute(
'data-workspace-mode',
{ docs: 'Docs', code: 'Code', run: 'Run' }[mode]
);
}
async function emptyRun(page: Page, draft = '') {
await hydrated(page, 'run');
await expect(page.getByLabel('Example UI')).toHaveValue('react');
await expect(page).toHaveURL(/(?:\?|&)mode=run(?:&|$)/);
await expect(page.locator('iframe')).toBeVisible();
await expect(page.locator('iframe')).toHaveAttribute('src', reactFrame);
const frame = page.frameLocator('iframe');
await expect(frame.getByRole('status')).toHaveText('Ready.');
await expect(
frame
.getByRole('region', { name: 'Conversation', exact: true })
.locator('article')
).toHaveCount(0);
await expect(frame.getByLabel('Message', { exact: true })).toHaveValue(draft);
}
test('native Chat Generative UI keeps canonical Docs, Code and Run aligned', async ({
page,
}, testInfo) => {
await page.goto(route + '?frontend=react', { waitUntil: 'domcontentloaded' });
await hydrated(page, 'docs');
await expect(page.getByLabel('Example UI')).toHaveValue('react');
await expect(
page.getByRole('heading', {
name: 'React chat generative UI preview',
exact: true,
})
).toBeVisible();
await expect(
page.locator(
'[data-example-file="cockpit/chat/generative-ui/react/src/application.ts"]'
)
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('react-chat-generative-ui-docs-desktop.png'),
});
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: testInfo.outputPath('react-chat-generative-ui-docs-mobile.png'),
});
await page.setViewportSize({ width: 1280, height: 720 });
await page
.locator('[data-workspace-desktop-navigation]')
.getByRole('button', { name: 'Code', exact: true })
.click();
await hydrated(page, 'code');
for (const name of [
'app.tsx',
'application.ts',
'connection.ts',
'dashboard-views.tsx',
'dashboard-data.ts',
'dashboard-spec.ts',
'tool-evidence.ts',
'observation.ts',
'terminal.ts',
'main.tsx',
'styles.css',
])
await expect(
page
.getByRole('complementary', { name: 'File tree' })
.getByRole('button', { name, exact: true })
).toBeVisible();
await expect(page.getByRole('tabpanel')).toContainText(
'createConnectedApplication'
);
await expect(page).toHaveURL(/(?:\?|&)mode=code(?:&|$)/);
await page
.locator('[data-workspace-desktop-navigation]')
.getByRole('button', { name: /^Run(?:,|$)/ })
.click();
await emptyRun(page);
await page.screenshot({
path: testInfo.outputPath('react-chat-generative-ui-run-desktop.png'),
});
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByLabel('Example UI')).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth
)
).toBe(true);
expect(
await page
.frameLocator('iframe')
.locator('html')
.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true);
await page.screenshot({
path: testInfo.outputPath('react-chat-generative-ui-run-mobile.png'),
});
});
test('the canonical generative-ui guide keeps Angular as its default frontend', async ({
page,
}) => {
await page.goto(route, { waitUntil: 'domcontentloaded' });
await hydrated(page, 'docs');
await expect(page.getByLabel('Example UI')).toHaveValue('angular');
await expect(
page.getByRole('heading', {
name: 'React chat generative UI preview',
exact: true,
})
).toHaveCount(0);
await page.getByLabel('Example UI').selectOption('react');
await expect(
page.getByRole('heading', {
name: 'React chat generative UI preview',
exact: true,
})
).toBeVisible();
await expect(page).toHaveURL(/frontend=react/);
});
test('a direct React run link mounts an empty conversation without creating a thread', async ({
page,
}) => {
await page.goto(route + '?frontend=react&mode=run', {
waitUntil: 'domcontentloaded',
});
await emptyRun(page);
});
test('history preserves the mounted React owner and unsent draft without runtime requests', async ({
page,
}) => {
await page.goto(route + '?frontend=react&mode=run', {
waitUntil: 'domcontentloaded',
});
await emptyRun(page);
const mountedFrame = await page.locator('iframe').elementHandle();
await page
.frameLocator('iframe')
.getByLabel('Message', { exact: true })
.fill('Unsent history draft');
await expect(
page.frameLocator('iframe').getByLabel('Message', { exact: true })
).toHaveValue('Unsent history draft');
const before = [...(observations.get(page)?.requests ?? [])];
await page
.locator('[data-workspace-desktop-navigation]')
.getByRole('button', { name: 'Code', exact: true })
.click();
await hydrated(page, 'code');
await expect(page).toHaveURL(/(?:\?|&)mode=code(?:&|$)/);
await page.goBack({ waitUntil: 'domcontentloaded' });
await emptyRun(page, 'Unsent history draft');
expect(
await page
.locator('iframe')
.evaluate((frame, original) => frame === original, mountedFrame)
).toBe(true);
expect(observations.get(page)?.requests).toEqual(before);
});
test('reload clears an unsent React draft without runtime requests', async ({
page,
}) => {
await page.goto(route + '?frontend=react&mode=run', {
waitUntil: 'domcontentloaded',
});
await emptyRun(page);
const before = [...(observations.get(page)?.requests ?? [])];
await page
.frameLocator('iframe')
.getByLabel('Message', { exact: true })
.fill('Unsent reload draft');
await expect(
page.frameLocator('iframe').getByLabel('Message', { exact: true })
).toHaveValue('Unsent reload draft');
await page.reload({ waitUntil: 'domcontentloaded' });
await emptyRun(page);
expect(observations.get(page)?.requests).toEqual(before);
});
7 changes: 7 additions & 0 deletions apps/website/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,13 @@ export const createWebsitePlaywrightConfig = (
reuseExistingServer,
timeout: 180_000,
},
{
command: 'npx nx run cockpit-chat-generative-ui-python:smoke && node scripts/react-cockpit/serve.mjs chat-generative-ui --no-parent',
cwd: '../..',
url: 'http://127.0.0.1:4627',
reuseExistingServer,
timeout: 180_000,
},
{
command: 'npx nx run cockpit-chat-timeline-python:smoke && node scripts/react-cockpit/serve.mjs chat-timeline --no-parent',
cwd: '../..',
Expand Down
3 changes: 2 additions & 1 deletion apps/website/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@
"cockpit-chat-tool-calls-react",
"cockpit-chat-subagents-react",
"cockpit-chat-threads-react",
"cockpit-chat-timeline-react"
"cockpit-chat-timeline-react",
"cockpit-chat-generative-ui-react"
]
}
],
Expand Down
11 changes: 11 additions & 0 deletions apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ const route = (library: string, section: string, slug: string, mode?: string) =>
} as never);

describe('unified docs workspace route', () => {
it('selects the native Generative UI article and source assets on the canonical guide', async () => {
const tree = await route('chat', 'guides', 'generative-ui');
const workspace = findElement(tree, WebsiteWorkspace as ComponentType<never>);
expect(workspace?.props.reactDocsSlot).toBeTruthy();
const slot = workspace?.props.reactDocsSlot;
expect(isValidElement<ElementProps>(slot) && slot.props.exampleCode?.assetPaths).toEqual(
expect.arrayContaining(['app.tsx', 'application.ts', 'connection.ts', 'dashboard-views.tsx', 'dashboard-data.ts', 'dashboard-spec.ts', 'tool-evidence.ts', 'observation.ts', 'terminal.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/chat/generative-ui/react/src/${file}`))
);
expect(isValidElement(slot) && typeof slot.type === 'function' && slot.type.name).toBe('ReactChatGenerativeUiPreview');
expect(findElement(slot, ReactChatSubagentsPreview as ComponentType<never>)).toBeNull();
});
it('selects the native Timeline article and source assets on the canonical guide', async () => {
const tree = await route('chat', 'components', 'chat-trace');
const workspace = findElement(tree, WebsiteWorkspace as ComponentType<never>);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ReactRepeatLoopsPreview } from '../../../../../components/docs/ReactRep
import { ReactStateManagementPreview } from '../../../../../components/docs/ReactStateManagementPreview';
import { ReactComputedFunctionsPreview } from '../../../../../components/docs/ReactComputedFunctionsPreview';
import { ReactChatMessagesPreview } from '../../../../../components/docs/ReactChatMessagesPreview';
import { ReactChatGenerativeUiPreview } from '../../../../../components/docs/ReactChatGenerativeUiPreview';
import { ReactChatTimelinePreview } from '../../../../../components/docs/ReactChatTimelinePreview';
import { ReactChatThreadsPreview } from '../../../../../components/docs/ReactChatThreadsPreview';
import { ReactChatSubagentsPreview } from '../../../../../components/docs/ReactChatSubagentsPreview';
Expand Down Expand Up @@ -283,7 +284,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
(library === 'langgraph' ||
(library === 'render' && ((section === 'guides' && ['specs','state-store','repeat-loops','registry'].includes(slug)) || (section === 'api' && ['render-spec-component','provide-render'].includes(slug)))) ||
(library === 'ag-ui' && ((section === 'reference' && slug === 'event-mapping') || (section === 'guides' && ['interrupts','tool-views','json-render','subagents'].includes(slug)))) ||
(library === 'chat' && ((section === 'guides' && slug === 'thread-routing') || slug === 'client-tools' || (section === 'concepts' && slug === 'message-model') || (section === 'components' && ['chat-trace', 'chat-input', 'chat-interrupt-panel', 'chat-tool-calls', 'chat-subagent-card'].includes(slug))))) ? (
(library === 'chat' && ((section === 'guides' && ['thread-routing', 'generative-ui'].includes(slug)) || slug === 'client-tools' || (section === 'concepts' && slug === 'message-model') || (section === 'components' && ['chat-trace', 'chat-input', 'chat-interrupt-panel', 'chat-tool-calls', 'chat-subagent-card'].includes(slug))))) ? (
library === 'render' ? (
slug === 'provide-render' ? <ReactComputedFunctionsPreview exampleCode={getExampleCodeContext(workspacePage.frontendVariants.react)} /> :
slug === 'render-spec-component' ? <ReactElementRenderingPreview exampleCode={getExampleCodeContext(workspacePage.frontendVariants.react)} /> :
Expand All @@ -297,6 +298,8 @@ export default async function DocsPage({ params }: DocsRouteProps) {
slug === 'tool-views' ? <ReactAgUiToolViewsPreview exampleCode={getExampleCodeContext(workspacePage.frontendVariants.react)} /> :
slug === 'interrupts' ? <ReactAgUiInterruptsPreview exampleCode={getExampleCodeContext(workspacePage.frontendVariants.react)} /> :
<ReactAgUiStreamingPreview exampleCode={getExampleCodeContext(workspacePage.frontendVariants.react)} />
) : library === 'chat' && section === 'guides' && slug === 'generative-ui' ? (
<ReactChatGenerativeUiPreview exampleCode={getExampleCodeContext(workspacePage.frontendVariants.react)} />
) : library === 'chat' && section === 'components' && slug === 'chat-trace' ? (
<ReactChatTimelinePreview exampleCode={getExampleCodeContext(workspacePage.frontendVariants.react)} />
) : library === 'chat' && section === 'guides' && slug === 'thread-routing' ? (
Expand Down
Loading
Loading