From 5dd91b2b1680a8712681b2c5ee161426798f2b84 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sat, 12 Sep 2026 13:21:43 -0700 Subject: [PATCH 1/2] feat: enable desktop generative UI and interactive components --- .env.example | 10 +- CHANGELOG.md | 10 + agent-bot/src/history.ts | 6 + agent-bot/tests/history.test.ts | 42 +- agent-langgraph-agui/src/main.py | 11 +- agent-langgraph-agui/src/tool_runtime.py | 22 +- .../tests/test_tool_protocol.py | 45 ++ agent-langgraph/src/history.ts | 10 +- agent-langgraph/tests/history.test.ts | 37 +- app/package.json | 1 + app/src/components/channels/channel-chat.tsx | 199 +++++---- app/src/components/component-preview.tsx | 141 ++++-- app/src/components/gallery/form.tsx | 417 ++++++++++++++++++ app/src/components/gallery/table.tsx | 202 +++++++++ app/src/lib/copilot/a2ui.css | 63 +++ app/src/lib/copilot/a2ui.tsx | 41 ++ app/src/lib/copilot/provider.tsx | 3 + .../routes/_authed/admin/components/$name.tsx | 7 +- .../routes/_authed/admin/components/index.tsx | 7 +- app/src/routes/_authed/admin/playground.tsx | 173 ++++++-- .../settings/components-gallery/$name.tsx | 2 +- .../settings/components-gallery/index.tsx | 5 +- app/tests/a2ui.test.tsx | 120 +++++ app/tests/channel-history-refresh.test.tsx | 91 +++- app/tests/component-preview.test.tsx | 161 +++++++ app/tests/gallery-components.test.tsx | 284 ++++++++++++ app/tests/playground-json.test.ts | 51 +++ bun.lock | 1 + docs/configuration.md | 47 +- server/src/config.ts | 23 +- server/src/copilot.ts | 3 + server/tests/config.test.ts | 31 +- server/tests/health.test.ts | 16 +- 33 files changed, 2017 insertions(+), 265 deletions(-) create mode 100644 app/src/components/gallery/form.tsx create mode 100644 app/src/components/gallery/table.tsx create mode 100644 app/src/lib/copilot/a2ui.css create mode 100644 app/src/lib/copilot/a2ui.tsx create mode 100644 app/tests/a2ui.test.tsx create mode 100644 app/tests/component-preview.test.tsx create mode 100644 app/tests/gallery-components.test.tsx create mode 100644 app/tests/playground-json.test.ts diff --git a/.env.example b/.env.example index 46693e636..fb9c6e746 100644 --- a/.env.example +++ b/.env.example @@ -22,11 +22,7 @@ SERVER_PORT=3001 TENANT_PACKAGE_DIR=../examples/fintech # Let a Bot answer with an interface it wrote itself: markup, styles and a script it generates for # that one answer, streamed into the transcript and rendered in a sandboxed iframe with no -# same-origin access to this app. Off unless you set this to `true` or `1`. -# -# Asked for rather than inherited, unlike most of the switches in this file. It decides whether a -# model may put code it wrote on somebody's screen, so a deployment should choose it rather than -# acquire it by upgrading — including a deployment that builds its default branch automatically. +# same-origin access to this app. On by default; set this to `false` or `0` to opt out. # # This is not the component catalogue. A component is something this deployment holds and an # administrator grants per Bot; this has nothing to grant, because the Bot writes it on the spot and @@ -35,8 +31,8 @@ TENANT_PACKAGE_DIR=../examples/fintech # # What the interface can reach is what the sandbox hands it, and this deployment hands it nothing: no # session, no same-origin access, and no route into your data. It can load libraries from a CDN, so a -# deployment that must not reach the public internet from a browser tab should leave this off. -# OPENBOT_GENERATIVE_UI=true +# deployment that must not reach the public internet from a browser tab should set this to false. +# OPENBOT_GENERATIVE_UI=false # What this deployment calls itself, when more than one shares an Intelligence project. A copy of a # deployment made for development uses the same project key, and threads are listed per Bot with # nothing to say which deployment a conversation came from. The name goes into every thread id this diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df7d57ae..e4ae1eec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Generated interfaces, tables and forms + +Generative UI is enabled by default; set `OPENBOT_GENERATIVE_UI=false` or `0` to disable it. +Bots can render A2UI interfaces, compare records in sortable tables, and collect related answers in +a form that waits for submission. LangGraph receives the component schemas needed to draw these +interfaces correctly. + +The playground rejects invalid JSON before saving or publishing, confirms successful saves, and +shows published custom components in the administrator's gallery. + ### A Bot's computer is rebuilt when it holds a token the deployment has stopped using A computer checks every caller against the `COMPUTER_TOKEN` it was created with, and holds that one diff --git a/agent-bot/src/history.ts b/agent-bot/src/history.ts index 1c7165bb1..881debfc4 100644 --- a/agent-bot/src/history.ts +++ b/agent-bot/src/history.ts @@ -17,6 +17,12 @@ export function toProviderMessages( ): OpenAI.Chat.ChatCompletionMessageParam[] { const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: COMPUTER_GUIDANCE }, + // AG-UI application context is separate from history. The A2UI catalog and tool instructions + // arrive here; omitting them leaves the model guessing component names and action schemas. + ...(input.context ?? []).map(({ description, value }) => ({ + role: "system" as const, + content: `${description}\n${value}`, + })), ]; /* diff --git a/agent-bot/tests/history.test.ts b/agent-bot/tests/history.test.ts index 9aae3b0b1..f8c1854d6 100644 --- a/agent-bot/tests/history.test.ts +++ b/agent-bot/tests/history.test.ts @@ -31,6 +31,37 @@ function withoutGuidance(messages: ReturnType) { return messages.slice(1); } +test("passes AG-UI catalog context to the model while preserving prompt and history order", () => { + const run = input([ + { id: "standing", role: "system", content: "Help with travel planning." }, + { id: "request", role: "user", content: "Draw a trip card." }, + ]); + const withoutContext = toProviderMessages(run); + const catalog = JSON.stringify({ + components: { Card: { properties: { component: { const: "Card" } } } }, + }); + run.context = [ + { description: "A2UI Component Schema", value: catalog }, + { + description: "A2UI render tool usage guide", + value: "Actions use event.name.", + }, + ]; + + expect(toProviderMessages(run)).toEqual([ + withoutContext[0], + { role: "system", content: `A2UI Component Schema\n${catalog}` }, + { + role: "system", + content: "A2UI render tool usage guide\nActions use event.name.", + }, + ...withoutContext.slice(1), + ]); + expect(run.messages).toHaveLength(2); + run.context = []; + expect(toProviderMessages(run)).toEqual(withoutContext); +}); + describe("a tool call nothing ever answered", () => { test("is answered, so the next turn is not refused outright", () => { const messages = withoutGuidance( @@ -241,13 +272,10 @@ describe("a tool call restored from the thread store", () => { ], } as never); - const withCalls = messages.find( - (message: Record) => message.tool_calls, - ) as Record; - const call = (withCalls.tool_calls as Array>)[0]; - const fn = call.function as Record; + const withCalls = messages.find((message) => message.role === "assistant"); + const fn = withCalls?.tool_calls?.[0]?.function; - expect(fn.name).toBe("computer_navigate"); - expect(fn.arguments).toBe('{"url":"https://news.ycombinator.com"}'); + expect(fn?.name).toBe("computer_navigate"); + expect(fn?.arguments).toBe('{"url":"https://news.ycombinator.com"}'); }); }); diff --git a/agent-langgraph-agui/src/main.py b/agent-langgraph-agui/src/main.py index 78c20f44c..14cd8bf7d 100644 --- a/agent-langgraph-agui/src/main.py +++ b/agent-langgraph-agui/src/main.py @@ -15,7 +15,13 @@ from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import START, MessagesState, StateGraph -from .tool_runtime import ToolAwareAgent, bind_tools, execute_tools, next_step +from .tool_runtime import ( + ToolAwareAgent, + bind_tools, + execute_tools, + model_messages, + next_step, +) TOKEN_HEADER = "x-openbot-agent-token" @@ -152,7 +158,8 @@ def _model(): async def answer(state: MessagesState): - return {"messages": [await bind_tools(_model()).ainvoke(state["messages"])]} + messages = model_messages(state["messages"]) + return {"messages": [await bind_tools(_model()).ainvoke(messages)]} builder = StateGraph(MessagesState) diff --git a/agent-langgraph-agui/src/tool_runtime.py b/agent-langgraph-agui/src/tool_runtime.py index 811b9efc5..4e52ffb8d 100644 --- a/agent-langgraph-agui/src/tool_runtime.py +++ b/agent-langgraph-agui/src/tool_runtime.py @@ -13,8 +13,8 @@ from dataclasses import dataclass, field import httpx -from ag_ui.core import EventType, RunAgentInput, RunErrorEvent, Tool -from langchain_core.messages import ToolMessage +from ag_ui.core import Context, EventType, RunAgentInput, RunErrorEvent, Tool +from langchain_core.messages import SystemMessage, ToolMessage from langgraph.graph import END from .parallel_tools import ParallelToolAgent @@ -23,6 +23,7 @@ @dataclass(frozen=True) class RunTools: tools: tuple[Tool, ...] = () + context: tuple[Context, ...] = () deployment: frozenset[str] = frozenset() assertion: str = field(default="", repr=False) @@ -45,6 +46,7 @@ async def run(self, input: RunAgentInput): assertion = props.get("openbotRun", "") context = RunTools( tools=tuple(input.tools or []), + context=tuple(input.context or []), deployment=frozenset(name for name in names if isinstance(name, str)) if isinstance(names, list) else frozenset(), @@ -81,6 +83,22 @@ async def run(self, input: RunAgentInput): _current.reset(token) +def model_messages(messages): + """Pass AG-UI application context to the model without checkpointing it. + + The maintained integration carries context separately from messages. Our + graph uses MessagesState, so its answer node must explicitly include the + current catalog/guidelines instead of silently discarding them. + """ + return [ + *[ + SystemMessage(content=f"{entry.description}\n{entry.value}") + for entry in current_tools().context + ], + *messages, + ] + + def bind_tools(model): tools = current_tools().tools if not tools: diff --git a/agent-langgraph-agui/tests/test_tool_protocol.py b/agent-langgraph-agui/tests/test_tool_protocol.py index f4f7f4569..627aedb66 100644 --- a/agent-langgraph-agui/tests/test_tool_protocol.py +++ b/agent-langgraph-agui/tests/test_tool_protocol.py @@ -232,6 +232,51 @@ def snapshot(events): ) +@pytest.mark.asyncio +async def test_a2ui_catalog_context_reaches_model_without_entering_history(boundary): + body = run_input( + [], + messages=[{ + "id": "context-request", "role": "user", "content": "Draw a trip card" + }], + ) + catalog = json.dumps({ + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "components": {"Card": {"properties": {"component": {"const": "Card"}}}}, + }) + body["context"] = [ + { + "description": ( + "A2UI Component Schema — available components for generating UI surfaces. " + "Use these component names and properties when creating A2UI operations." + ), + "value": catalog, + }, + { + "description": "A2UI render tool usage guide", + "value": "Use component: Card, not type: card. Actions use event.name.", + }, + ] + + events = await run_protocol(body) + model_messages = boundary["model"][0]["messages"] + system = [ + message["content"] for message in model_messages + if message["role"] == "system" + ] + assert any(catalog in content for content in system) + assert any("Actions use event.name." in content for content in system) + assert catalog not in json.dumps(snapshot(events)) + assert "synthetic-run-assertion" not in json.dumps(model_messages) + + # A later request on the same graph thread uses its current context, not a checkpointed catalog. + body["runId"] = str(uuid4()) + body["messages"] = [{"id": "context-next", "role": "user", "content": "Continue"}] + body["context"] = [] + await run_protocol(body) + assert catalog not in json.dumps(boundary["model"][-1]["messages"]) + + @pytest.mark.asyncio @pytest.mark.parametrize("name", ["computer_navigate", "computer_run_command"]) async def test_surface_tool_calls_end_then_consume_actual_client_result(boundary, name): diff --git a/agent-langgraph/src/history.ts b/agent-langgraph/src/history.ts index 2f9825284..3b9db862b 100644 --- a/agent-langgraph/src/history.ts +++ b/agent-langgraph/src/history.ts @@ -23,7 +23,15 @@ export { NO_ANSWER_CAME }; /** Translate the conversation AG-UI carries into LangChain's message classes. */ export function toLangChainMessages(input: RunAgentInput): BaseMessage[] { - const messages: BaseMessage[] = [new SystemMessage(COMPUTER_GUIDANCE)]; + const messages: BaseMessage[] = [ + new SystemMessage(COMPUTER_GUIDANCE), + // AG-UI carries application context separately from conversation history. CopilotKit puts + // the A2UI catalog and tool instructions here; dropping it leaves the model guessing the + // component schema and can strand the renderer on an invalid, never-painted surface. + ...(input.context ?? []).map( + ({ description, value }) => new SystemMessage(`${description}\n${value}`), + ), + ]; /* * Which calls in this history were ever answered. diff --git a/agent-langgraph/tests/history.test.ts b/agent-langgraph/tests/history.test.ts index e910c3f44..f078fb4ca 100644 --- a/agent-langgraph/tests/history.test.ts +++ b/agent-langgraph/tests/history.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { AIMessage, ToolMessage } from "@langchain/core/messages"; import type { RunAgentInput } from "@ag-ui/core"; +import { + AIMessage, + SystemMessage, + ToolMessage, +} from "@langchain/core/messages"; import { NO_ANSWER_CAME, toLangChainMessages } from "../src/history"; /** @@ -30,6 +34,37 @@ const assistantAsking = { ], }; +test("passes the caller's A2UI catalog and tool instructions to the model", () => { + // The live failure emitted `type: "card"` instead of `component: "Card"`: the model saw the + // permissive render_a2ui tool schema, but this adapter had discarded its actual catalog context. + const catalog = JSON.stringify({ + catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json", + components: { + Card: { + properties: { component: { const: "Card" }, child: { type: "string" } }, + }, + }, + }); + const instructions = + "Use flat components with component names from the catalog. Button actions use event.name and event.context."; + const run = input([ + { role: "user", content: "Show a Trip preferences card." }, + ]); + run.context = [ + { description: "A2UI Component Schema", value: catalog }, + { description: "A2UI render tool usage guide", value: instructions }, + ]; + const messages = toLangChainMessages(run); + const system = messages.filter((message) => message instanceof SystemMessage); + expect(system.map((message) => message.content)).toContain( + `A2UI Component Schema\n${catalog}`, + ); + expect(system.map((message) => message.content)).toContain( + `A2UI render tool usage guide\n${instructions}`, + ); + expect(messages.at(-1)?.content).toBe("Show a Trip preferences card."); +}); + describe("history with a tool call nobody answered", () => { test("closes it, so the next turn is not rejected", () => { const messages = toLangChainMessages( diff --git a/app/package.json b/app/package.json index 02b4f07e6..dbb1c8faf 100644 --- a/app/package.json +++ b/app/package.json @@ -17,6 +17,7 @@ "@ag-ui/core": "0.0.59", "@base-ui/react": "^1.6.0", "@better-auth/sso": "^1.7.1", + "@copilotkit/a2ui-renderer": "1.70.1", "@copilotkit/react-core": "1.70.1", "@fontsource-variable/inter": "^5.3.0", "@shadcn/react": "^0.3.0", diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 2fc3c6610..d4ef437ac 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -1,6 +1,7 @@ import type { Message } from "@ag-ui/core"; import { type Attachment, + CopilotChatConfigurationProvider, UseAgentUpdate, useAgent, useCopilotKit, @@ -235,8 +236,9 @@ export function ChannelChat({ const { copilotkit } = useCopilotKit(); // Mentions are scoped to the channel's permitted agents. const { data: agentProfiles } = useQuery(agentListQueryOptions()); + const channelAgentId = `channel:${channel.id}`; const { agent, isReady } = useAgent({ - agentId: `channel:${channel.id}`, + agentId: channelAgentId, runtimeAgentId, threadId: channel.threadId, updates: [ @@ -797,101 +799,108 @@ export function ChannelChat({ }, []); return ( - - 0} - // The `/` menu exposes only skills granted to this Bot. - commands={skillCommands} - // Readiness is handled by `say`; deletion is the only disabled-chat state. - disabled={!channel.active} - messages={transcriptMessages(agent.messages, seed)} - notice={ + // Activity renderers resolve their agent through this SDK context. It must match useAgent's + // channel instance so an action continues this thread instead of looking for a default agent. + + + - {historyNotice ? ( -

- {historyNotice} -

- ) : null} - {channel.active ? null : ( -

- This coworker has been deleted. The conversation stays readable, - but it can no longer reply. -

- )} - - } - onSubmit={async (draft) => { - // `draft.agentId` carries the @mentioned coworker, but nothing routes on it yet: this - // channel is pinned to one `runtimeAgentId` for the life of its thread, so honouring a - // per-message mention is a change to that binding, not to the composer. - // - // `commandIds` are the `/` chips that survived into the send, in the order they were - // typed. Resolved against the same list the menu was built from, so a chip left over from - // a skill that has since been revoked resolves to nothing rather than to a stale - // instruction — the menu is refetched, and this reads from it. - const skillInstructions = draft.commandIds - .map( - (id) => - skillCommands.find((command) => command.id === id)?.prompt, - ) - .filter((instruction): instruction is string => - Boolean(instruction), - ); - - await say(draft.text, skillInstructions, draft.attachments); - }} - /** - * Stop through the core so the abort signal reaches frontend tools; `say` repairs any - * unanswered tool call before the next turn. - */ - onStop={() => { - awaitingReply.current = false; - copilotkit.stopAgent({ agent }); - }} - /* - * The turn, not the run. A browser action ends one run and starts another, and telling the - * conversation it is idle in between is what would drain a parked correction into the - * middle of an answer: a second turn racing the first on one thread, with a fabricated - * result stitched over a tool call that is still executing. - */ - pending={agent.isRunning || turnsInFlight > 0} - /* - * A channel outlives its turns, so it is the screen where waiting is worth offering. A - * correction typed mid-answer is held here, in this tab, and runs as one follow-up turn the - * moment this one is over — including when it is over because somebody pressed the button - * above. - */ - queueWhileBusy - restoring={restoring} - /* - * The run, not the turn. Stop reaches a run through the core's abort controller, and that - * controller does not exist until `say` has finished waiting for the runtime agent — so - * this is the one place the narrower fact is the honest one to draw a button from. - */ - stoppable={agent.isRunning || runsInFlight > 0} - /* - * At the END OF THE TRANSCRIPT rather than above the composer, which is where this used to - * be. A turn that ends without an answer leaves a gap exactly where the reply was going to - * appear, and the person is already looking at it; an explanation in the composer area is a - * different part of the screen from the thing it explains. - * - * `runError` carries whatever ended the turn, in that thing's own words. A Bot that stopped - * streaming says so, because the deployment's stall watchdog writes that sentence into the - * run before closing it; see server/src/channels/stall-guard.ts. - */ - stopped={runError ?? undefined} - /> -
+ busy={agent.isRunning || turnsInFlight > 0} + // The `/` menu exposes only skills granted to this Bot. + commands={skillCommands} + // Readiness is handled by `say`; deletion is the only disabled-chat state. + disabled={!channel.active} + messages={transcriptMessages(agent.messages, seed)} + notice={ + /* + * Two things can be worth saying at once — a deleted coworker and a history with holes in + * it — and they are independent, so neither is an `else` for the other. + */ + <> + {historyNotice ? ( +

+ {historyNotice} +

+ ) : null} + {channel.active ? null : ( +

+ This coworker has been deleted. The conversation stays + readable, but it can no longer reply. +

+ )} + + } + onSubmit={async (draft) => { + // `draft.agentId` carries the @mentioned coworker, but nothing routes on it yet: this + // channel is pinned to one `runtimeAgentId` for the life of its thread, so honouring a + // per-message mention is a change to that binding, not to the composer. + // + // `commandIds` are the `/` chips that survived into the send, in the order they were + // typed. Resolved against the same list the menu was built from, so a chip left over from + // a skill that has since been revoked resolves to nothing rather than to a stale + // instruction — the menu is refetched, and this reads from it. + const skillInstructions = draft.commandIds + .map( + (id) => + skillCommands.find((command) => command.id === id)?.prompt, + ) + .filter((instruction): instruction is string => + Boolean(instruction), + ); + + await say(draft.text, skillInstructions, draft.attachments); + }} + /** + * Stop through the core so the abort signal reaches frontend tools; `say` repairs any + * unanswered tool call before the next turn. + */ + onStop={() => { + awaitingReply.current = false; + copilotkit.stopAgent({ agent }); + }} + /* + * The turn, not the run. A browser action ends one run and starts another, and telling the + * conversation it is idle in between is what would drain a parked correction into the + * middle of an answer: a second turn racing the first on one thread, with a fabricated + * result stitched over a tool call that is still executing. + */ + pending={agent.isRunning || turnsInFlight > 0} + /* + * A channel outlives its turns, so it is the screen where waiting is worth offering. A + * correction typed mid-answer is held here, in this tab, and runs as one follow-up turn the + * moment this one is over — including when it is over because somebody pressed the button + * above. + */ + queueWhileBusy + restoring={restoring} + /* + * The run, not the turn. Stop reaches a run through the core's abort controller, and that + * controller does not exist until `say` has finished waiting for the runtime agent — so + * this is the one place the narrower fact is the honest one to draw a button from. + */ + stoppable={agent.isRunning || runsInFlight > 0} + /* + * At the END OF THE TRANSCRIPT rather than above the composer, which is where this used to + * be. A turn that ends without an answer leaves a gap exactly where the reply was going to + * appear, and the person is already looking at it; an explanation in the composer area is a + * different part of the screen from the thing it explains. + * + * `runError` carries whatever ended the turn, in that thing's own words. A Bot that stopped + * streaming says so, because the deployment's stall watchdog writes that sentence into the + * run before closing it; see server/src/channels/stall-guard.ts. + */ + stopped={runError ?? undefined} + /> +
+ ); } diff --git a/app/src/components/component-preview.tsx b/app/src/components/component-preview.tsx index ea2999a97..9d55d0d1f 100644 --- a/app/src/components/component-preview.tsx +++ b/app/src/components/component-preview.tsx @@ -1,5 +1,111 @@ -import { useEffect, useRef, useState } from "react"; +import { OpenGenerativeUIActivityRenderer } from "@copilotkit/react-core/v2"; +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { galleryComponent } from "@/lib/copilot/gallery-registry"; +import { + type SandboxedRecord, + sandboxedListQueryOptions, +} from "@/lib/sandboxed/queries"; + +/** Sample arguments belong to the administrator's working copy, so only Admin fetches them. */ +export function AdminComponentPreview({ + name, + kind, +}: { + name: string; + kind: string; +}) { + const sandboxed = useQuery({ + ...sandboxedListQueryOptions(), + enabled: kind === "sandboxed", + }); + if (kind === "sandboxed" && sandboxed.isPending) return null; + if (kind === "sandboxed" && sandboxed.error) { + return Preview could not be loaded.; + } + return ( + component.name === name)} + /> + ); +} + +function PreviewNotice({ children }: { children: ReactNode }) { + return ( +
+ {/* The artwork stays pale in both themes, so this text must stay dark. */} +

{children}

+
+ ); +} + +export function ComponentPreview({ + name, + kind, + sandboxed, + fill = PREVIEW_FILL, +}: { + name: string; + kind?: string; + sandboxed?: SandboxedRecord; + fill?: number; +}) { + const entry = galleryComponent(name); + if (entry?.preview) { + return ( + + + + ); + } + + if (sandboxed?.name === name) { + if (!sandboxed.published || sandboxed.publishedHtml === null) { + return ( + + Publish in the playground to see a preview. + + ); + } + const content = { + css: sandboxed.publishedCss ?? "", + cssComplete: true, + html: [sandboxed.publishedHtml], + htmlComplete: true, + jsFunctions: `window.__args = ${JSON.stringify(sandboxed.sampleArguments)};\n${sandboxed.publishedJsFunctions ?? ""}`, + jsFunctionsComplete: true, + generating: false, + }; + return ( + +
+ +
+
+ ); + } + + return ( + + {entry + ? "This one is only drawn in a conversation." + : kind === "sandboxed" + ? "Published in the playground." + : "This build cannot draw this."} + + ); +} /** * The width a gallery component is given to lay out against, standing in for the conversation @@ -40,14 +146,13 @@ export const PREVIEW_FILL = 0.85; * card here has a real note field and real Approve and Decline buttons; nothing in Admin should be * able to reach them, and a screen reader should not find a second set of them on the page. */ -export function ComponentPreview({ - name, - fill = PREVIEW_FILL, +function FittedPreview({ + children, + fill, }: { - name: string; - fill?: number; + children: ReactNode; + fill: number; }) { - const entry = galleryComponent(name); const box = useRef(null); const content = useRef(null); const [scale, setScale] = useState(0); @@ -88,26 +193,6 @@ export function ComponentPreview({ return () => observer.disconnect(); }, [fill]); - if (!entry?.preview) { - return ( -
- {/* - * A fixed dark grey rather than `text-muted-foreground`. That token lightens in the dark - * theme — 0.5 to 0.708 — while the artwork behind this text does not: it is the same pale - * gradient either way, with no dark variant. Following the theme therefore moved the text - * towards its background exactly when it needed to move away from it. - */} -

- {entry - ? "This one is only drawn in a conversation." - : "This build cannot draw this."} -

-
- ); - } - - const { Component } = entry; - return ( ); diff --git a/app/src/components/gallery/form.tsx b/app/src/components/gallery/form.tsx new file mode 100644 index 000000000..75d106510 --- /dev/null +++ b/app/src/components/gallery/form.tsx @@ -0,0 +1,417 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { GalleryComponent } from "@/lib/copilot/gallery-registry"; +import { Badge, GalleryFrame } from "./frame"; + +const Field = z + .object({ + id: z + .string() + .regex(/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/) + .describe("Unique key returned with the answer"), + label: z.string().trim().min(1).max(100), + type: z.enum(["text", "email", "number", "select", "textarea"]), + required: z.boolean().optional(), + help: z.string().max(250).optional(), + options: z + .array(z.string().trim().min(1).max(100)) + .min(1) + .max(20) + .optional() + .describe("Required for select fields"), + min: z.number().optional().describe("Minimum for a number field"), + max: z.number().optional().describe("Maximum for a number field"), + }) + .refine( + (field) => + (field.type !== "select" || Boolean(field.options?.length)) && + (!field.options || + new Set(field.options).size === field.options.length) && + (field.min === undefined || + field.max === undefined || + field.min <= field.max), + "Select fields need unique options; minimum must not exceed maximum", + ); + +export const FormCardArgs = z + .object({ + title: z.string().trim().min(1).max(120), + description: z.string().max(500).optional(), + fields: z.array(Field).min(1).max(8), + submitLabel: z.string().trim().min(1).max(40).optional(), + }) + .refine( + (form) => + new Set(form.fields.map((field) => field.id)).size === form.fields.length, + "Field IDs must be unique", + ); + +const Submitted = z.object({ + status: z.literal("submitted"), + values: z.record(z.string(), z.union([z.string(), z.number()])), +}); +type FormArgs = z.infer; +type FormAnswer = z.infer; +type Respond = ( + answer: FormAnswer | { status: "invalid"; message: string }, +) => Promise; + +function isRespond(value: unknown): value is Respond { + return typeof value === "function"; +} +function readAnswer(result: unknown): FormAnswer | null { + try { + const parsed = Submitted.safeParse( + typeof result === "string" ? JSON.parse(result) : result, + ); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +/** Decision render props remain wrapped, just like askApproval and askChoice. */ +export function FormCard(props: Record) { + const parsed = FormCardArgs.safeParse(props.args); + if (props.status === "inProgress") { + return ( + +

+ The assistant is putting the questions together. +

+
+ ); + } + if (!parsed.success) { + return ( + + ); + } + return ( + + ); +} + +/** A malformed tool call must release the suspended run so the Bot can repair its questions. */ +function InvalidForm({ + complete, + respond, +}: { + complete: boolean; + respond?: Respond; +}) { + const attempted = useRef(false); + const [failed, setFailed] = useState(false); + const [returned, setReturned] = useState(complete); + const returnToBot = useCallback(async () => { + if (!respond || attempted.current || complete) return; + attempted.current = true; + setFailed(false); + try { + await respond({ + status: "invalid", + message: + "The form could not be displayed because its questions were invalid. Send a corrected form with unique field IDs, valid types, and options for each select field.", + }); + setReturned(true); + } catch { + attempted.current = false; + setFailed(true); + } + }, [respond, complete]); + useEffect(() => { + void returnToBot(); + }, [returnToBot]); + return ( + +

+ {returned + ? "The form had invalid questions. The Bot can send a corrected form." + : "The form has invalid questions and could not be displayed."} +

+ {failed && ( + + )} +
+ ); +} + +function FormEntry({ + args, + complete, + result, + respond, +}: { + args: FormArgs; + complete: boolean; + result: FormAnswer | null; + respond?: Respond; +}) { + const prefix = useId(); + const [values, setValues] = useState(new Map()); + const [errors, setErrors] = useState(new Map()); + const [failure, setFailure] = useState(false); + const [sending, setSending] = useState(false); + const [submitted, setSubmitted] = useState(null); + const sendingRef = useRef(false); + const answer = result ?? submitted; + const finished = complete || answer !== null; + + async function submit(form: HTMLFormElement) { + if (!respond || sendingRef.current || finished) return; + const problems = new Map(); + const entries: [string, string | number][] = []; + for (const field of args.fields) { + const control = form.elements.namedItem(field.id); + if (control instanceof HTMLInputElement && control.validity.badInput) { + problems.set(field.id, "Enter a valid number."); + continue; + } + const value = (values.get(field.id) ?? "").trim(); + if (!value) { + if (field.required) + problems.set(field.id, `${field.label} is required.`); + continue; + } + if (value.length > 2000) + problems.set(field.id, "Use 2,000 characters or fewer."); + else if (field.type === "email" && !z.email().safeParse(value).success) + problems.set(field.id, "Enter a valid email address."); + else if (field.type === "select" && !field.options?.includes(value)) + problems.set(field.id, "Choose one of the listed options."); + else if (field.type === "number") { + const number = Number(value); + if (!Number.isFinite(number)) + problems.set(field.id, "Enter a valid number."); + else if (field.min !== undefined && number < field.min) + problems.set(field.id, `Enter ${field.min} or more.`); + else if (field.max !== undefined && number > field.max) + problems.set(field.id, `Enter ${field.max} or less.`); + } + entries.push([field.id, field.type === "number" ? Number(value) : value]); + } + setErrors(problems); + if (problems.size) { + const firstId = problems.keys().next().value; + const control = firstId ? form.elements.namedItem(firstId) : null; + if (control instanceof HTMLElement) control.focus(); + return; + } + sendingRef.current = true; + setSending(true); + setFailure(false); + const response: FormAnswer = { + status: "submitted", + values: Object.fromEntries(entries), + }; + try { + await respond(response); + setSubmitted(response); + } catch { + sendingRef.current = false; + setFailure(true); + } finally { + setSending(false); + } + } + + return ( + + {finished ? "Submitted" : "Waiting on you"} + + } + > + {finished ? ( +
+ {answer ? ( +
+ {args.fields.map((field) => ( +
+
{field.label}
+
+ {Object.hasOwn(answer.values, field.id) + ? answer.values[field.id] + : "Not provided"} +
+
+ ))} +
+ ) : ( +

+ This form has been completed. Its saved answers are unavailable. +

+ )} +
+ ) : ( +
{ + event.preventDefault(); + void submit(event.currentTarget); + }} + className="space-y-4" + > +

+ Fields marked * are required. Your answers will be shared with the + Bot. +

+ {args.fields.map((field) => { + const id = `${prefix}-${field.id}`; + const error = errors.get(field.id); + const shared = { + id, + name: field.id, + required: field.required, + disabled: sending || !respond, + value: values.get(field.id) ?? "", + "aria-invalid": Boolean(error), + "aria-describedby": + [field.help ? `${id}-help` : "", error ? `${id}-error` : ""] + .filter(Boolean) + .join(" ") || undefined, + }; + const change = (value: string) => { + setValues((current) => new Map(current).set(field.id, value)); + setErrors((current) => { + const next = new Map(current); + next.delete(field.id); + return next; + }); + }; + return ( +
+ + {field.type === "textarea" ? ( +