diff --git a/docs/app/api/openui-cloud/chat/route.ts b/docs/app/api/openui-cloud/chat/route.ts index e4e8797f6..4dfe8f064 100644 --- a/docs/app/api/openui-cloud/chat/route.ts +++ b/docs/app/api/openui-cloud/chat/route.ts @@ -2,7 +2,8 @@ import { readOpenuiCloudConfig } from "@/lib/openui-cloud/config"; import { unavailableResponse } from "@/lib/openui-cloud/errors"; import { resolveRequestedModel } from "@/lib/openui-cloud/models"; import { hasAllowedOrigin, hasJsonContentType, readLimitedJson } from "@/lib/openui-cloud/request"; -import { artifactTool, generateSystemPrompt } from "@openuidev/thesys-server"; +import { generateSystemPrompt } from "@openuidev/lang-core"; +import { artifactTool } from "@openuidev/thesys-server"; import OpenAI from "openai"; import type { ResponseInputItem } from "openai/resources/responses/responses"; @@ -56,6 +57,7 @@ export async function POST(request: Request): Promise { { type: "image_search" }, ], instructions: generateSystemPrompt({ + cloud: true, instructions: "When creating an artifact, actively use the available web and image search tools to ground its content and visual choices.", }), diff --git a/docs/content/docs/openui-cloud/api/chat-completions.mdx b/docs/content/docs/openui-cloud/api/chat-completions.mdx index 08c9f9224..8b44cf59b 100644 --- a/docs/content/docs/openui-cloud/api/chat-completions.mdx +++ b/docs/content/docs/openui-cloud/api/chat-completions.mdx @@ -14,12 +14,12 @@ Use the `embedClient` from the [API overview](/docs/openui-cloud/api/overview), Use the server helper to have OpenUI Cloud assemble the system prompt for its built-in component library. ```ts title="server.ts" -import { generateSystemPrompt } from "@openuidev/thesys-server"; +import { generateSystemPrompt } from "@openuidev/lang-core"; const completion = await embedClient.chat.completions.create({ model: "openai/gpt-5", messages: [ - { role: "system", content: generateSystemPrompt() }, + { role: "system", content: generateSystemPrompt({ cloud: true }) }, { role: "user", content: "Compare quarterly revenue by region." }, ], }); @@ -27,7 +27,7 @@ const completion = await embedClient.chat.completions.create({ console.log(completion.choices[0].message.content); ``` -The returned message content is an OpenUI Lang program. See [Component Library](/docs/openui-cloud/build/component-library) for the built-in client library and custom component workflow. +The returned message content is an OpenUI Lang program. See [Component Library](/docs/openui-cloud/build/component-library) for the built-in client library and using your own library. ## Stream and render responses @@ -75,7 +75,7 @@ Hosted `web_search`, `image_search`, remote MCP, and artifacts-as-tool are avail ### Plain text passthrough -Use a `{provider}/{model}` model ID without the managed `generateSystemPrompt()` sentinel. OpenUI Cloud forwards your messages and system prompt without injecting a generative UI prompt or component schema. +Use a `{provider}/{model}` model ID without the managed `generateSystemPrompt({ cloud: true })` sentinel. OpenUI Cloud forwards your messages and system prompt without injecting a generative UI prompt or component schema. ```ts const completion = await embedClient.chat.completions.create({ diff --git a/docs/content/docs/openui-cloud/api/conversations.mdx b/docs/content/docs/openui-cloud/api/conversations.mdx index 442ff7cf3..eb5d83cfd 100644 --- a/docs/content/docs/openui-cloud/api/conversations.mdx +++ b/docs/content/docs/openui-cloud/api/conversations.mdx @@ -27,13 +27,13 @@ const conversation = await conversationClient.conversations.create({ Then send only the new turn to Responses and set `store: true`: ```ts -import { generateSystemPrompt } from "@openuidev/thesys-server"; +import { generateSystemPrompt } from "@openuidev/lang-core"; const response = await embedClient.responses.create({ model: "openai/gpt-5", conversation: conversation.id, input: "Compare quarterly revenue by region.", - instructions: generateSystemPrompt(), + instructions: generateSystemPrompt({ cloud: true }), store: true, stream: true, }); diff --git a/docs/content/docs/openui-cloud/api/responses.mdx b/docs/content/docs/openui-cloud/api/responses.mdx index 98d898c72..61d03d9e2 100644 --- a/docs/content/docs/openui-cloud/api/responses.mdx +++ b/docs/content/docs/openui-cloud/api/responses.mdx @@ -14,18 +14,18 @@ Use the `embedClient` from the [API overview](/docs/openui-cloud/api/overview), Use the server helper to have OpenUI Cloud assemble the system prompt for its built-in component library. ```ts title="server.ts" -import { generateSystemPrompt } from "@openuidev/thesys-server"; +import { generateSystemPrompt } from "@openuidev/lang-core"; const response = await embedClient.responses.create({ model: "openai/gpt-5", input: "Compare quarterly revenue by region.", - instructions: generateSystemPrompt(), + instructions: generateSystemPrompt({ cloud: true }), }); console.log(response.output_text); ``` -The returned `output_text` is an OpenUI Lang program. See [Component Library](/docs/openui-cloud/build/component-library) for the built-in client library and custom component workflow. +The returned `output_text` is an OpenUI Lang program. See [Component Library](/docs/openui-cloud/build/component-library) for the built-in client library and using your own library. ## Stream and render responses @@ -72,14 +72,14 @@ Chain a follow-up to an earlier response: const first = await embedClient.responses.create({ model: "openai/gpt-5", input: "Compare quarterly revenue by region.", - instructions: generateSystemPrompt(), + instructions: generateSystemPrompt({ cloud: true }), store: true, }); const followUp = await embedClient.responses.create({ model: "openai/gpt-5", input: "Focus on Europe and explain the change.", - instructions: generateSystemPrompt(), + instructions: generateSystemPrompt({ cloud: true }), previous_response_id: first.id, store: true, }); @@ -105,7 +105,7 @@ import type { Tool } from "openai/resources/responses/responses"; const response = await embedClient.responses.create({ model: "openai/gpt-5", input: "Research the market and summarize the most important changes.", - instructions: generateSystemPrompt(), + instructions: generateSystemPrompt({ cloud: true }), tools: [ { type: "web_search" }, { type: "image_search" } as unknown as Tool, @@ -129,14 +129,15 @@ For a `function` tool, execute each returned `function_call` on your server and Add `artifactTool()` to generate editable slides or reports inside the agent stream: ```ts -import { artifactTool, generateSystemPrompt } from "@openuidev/thesys-server"; +import { generateSystemPrompt } from "@openuidev/lang-core"; +import { artifactTool } from "@openuidev/thesys-server"; import type { Tool } from "openai/resources/responses/responses"; const response = await embedClient.responses.create({ model: "openai/gpt-5", conversation: threadId, input: "Create a three-slide deck on Q4 results.", - instructions: generateSystemPrompt(), + instructions: generateSystemPrompt({ cloud: true }), tools: [artifactTool({ artifacts: ["slides", "report"] }) as unknown as Tool], store: true, stream: true, diff --git a/docs/content/docs/openui-cloud/build/component-library.mdx b/docs/content/docs/openui-cloud/build/component-library.mdx index 376cfa548..9243cd481 100644 --- a/docs/content/docs/openui-cloud/build/component-library.mdx +++ b/docs/content/docs/openui-cloud/build/component-library.mdx @@ -15,18 +15,19 @@ import { chatLibrary } from "@openuidev/thesys"; ; ``` -On the backend, `generateSystemPrompt()` uses the built-in library by default. Use the `embedClient` from the [API overview](/docs/openui-cloud/api/overview); the placement of the generated instructions depends on the API: +On the backend, `generateSystemPrompt({ cloud: true })` uses the built-in library by default. Use the `embedClient` from the [API overview](/docs/openui-cloud/api/overview); the placement of the generated instructions depends on the API: ```ts -import { generateSystemPrompt } from "@openuidev/thesys-server"; +import { generateSystemPrompt } from "@openuidev/lang-core"; const response = await embedClient.responses.create({ model: "openai/gpt-5", input: "Show revenue by region as an interactive dashboard.", instructions: generateSystemPrompt({ + cloud: true, instructions: "Optional instructions for the model.", }), }); @@ -36,7 +37,7 @@ const response = await embedClient.responses.create({ ```ts -import { generateSystemPrompt } from "@openuidev/thesys-server"; +import { generateSystemPrompt } from "@openuidev/lang-core"; const completion = await embedClient.chat.completions.create({ model: "openai/gpt-5", @@ -44,6 +45,7 @@ const completion = await embedClient.chat.completions.create({ { role: "system", content: generateSystemPrompt({ + cloud: true, instructions: "Optional instructions for the model.", }), }, @@ -123,6 +125,7 @@ npx @openuidev/cli@latest generate --spec src/lib/chat-library.tsx --out ./gener model: "openai/gpt-5", input, instructions: generateSystemPrompt({ + cloud: true, instructions: "Optional instructions for the model.", + library: librarySpec, + promptOptions: { preamble: "You build dashboards for Acme operators." }, @@ -142,6 +145,7 @@ npx @openuidev/cli@latest generate --spec src/lib/chat-library.tsx --out ./gener { role: "system", content: generateSystemPrompt({ + cloud: true, instructions: "Optional instructions for the model.", + library: librarySpec, + promptOptions: { preamble: "You build dashboards for Acme operators." }, diff --git a/docs/content/docs/openui-lang/examples/agent-frameworks/vercel-ai-sdk.mdx b/docs/content/docs/openui-lang/examples/agent-frameworks/vercel-ai-sdk.mdx index ec539e69e..bd3ef3772 100644 --- a/docs/content/docs/openui-lang/examples/agent-frameworks/vercel-ai-sdk.mdx +++ b/docs/content/docs/openui-lang/examples/agent-frameworks/vercel-ai-sdk.mdx @@ -22,7 +22,7 @@ This example uses the [Vercel AI SDK](https://ai-sdk.dev/) (`streamText`, `tool( ## How it connects -The API route calls `streamText` with Cloud Completions, `generateSystemPrompt()`, and tools defined with `tool()`. When the model calls a tool, the AI SDK executes it server-side and continues — up to 5 steps. The response is a UIMessage stream. +The API route calls `streamText` with Cloud Completions, `generateSystemPrompt({ cloud: true })`, and tools defined with `tool()`. When the model calls a tool, the AI SDK executes it server-side and continues — up to 5 steps. The response is a UIMessage stream. The page passes that stream to ``: @@ -54,7 +54,7 @@ Storage is omitted, so AgentInterface uses its built-in in-memory default (wiped ```ts import { createOpenAI } from "@ai-sdk/openai"; -import { generateSystemPrompt } from "@openuidev/thesys-server"; +import { generateSystemPrompt } from "@openuidev/lang-core"; import { convertToModelMessages, stepCountIs, streamText } from "ai"; import { tools } from "@/lib/tools"; @@ -67,7 +67,7 @@ export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model: openai.chat("google/gemini-3.6-flash-free"), - system: generateSystemPrompt(), + system: generateSystemPrompt({ cloud: true }), messages: await convertToModelMessages(messages), tools, stopWhen: stepCountIs(5), diff --git a/docs/content/docs/openui-lang/quickstart.mdx b/docs/content/docs/openui-lang/quickstart.mdx index e1b0ec369..f3332ec0f 100644 --- a/docs/content/docs/openui-lang/quickstart.mdx +++ b/docs/content/docs/openui-lang/quickstart.mdx @@ -297,8 +297,9 @@ Never claim that a recipe is allergen-safe. Update the `instructions` field inside `createParams`: { + expect(emitted.startsWith(CONFIG_MARKER)).toBe(true); + const body = emitted.slice(CONFIG_MARKER.length); + const firstLine = body.split("\n", 1)[0]; + return JSON.parse(firstLine); +} + +const library = { + root: "Card", + schema: { + $defs: { + Card: { + properties: { + children: { + type: "array", + items: { anyOf: [{ $ref: "#/$defs/TextContent" }] }, + }, + title: { type: "string" }, + }, + required: ["children"], + description: "Top-level container.", + }, + TextContent: { + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, + }, + componentGroups: [ + { + name: "Content", + components: ["TextContent"], + }, + ], +} as LibrarySpec; + +describe("generateSystemPrompt — self-hosted", () => { + const spec: LibrarySpec = { + root: "Card", + components: { + Card: { signature: "Card(children: Component[])", description: "Root" }, + }, + }; + + it("renders a local prompt from a library spec", () => { + const prompt = generateSystemPrompt({ library: spec }); + expect(prompt).toContain("openui-lang"); + expect(prompt).toContain("Card(children: Component[])"); + expect(prompt.startsWith(CONFIG_MARKER)).toBe(false); + }); +}); + +describe("generateSystemPrompt({ cloud: true }) — sentinel", () => { + it("starts with the byte-exact marker", () => { + const out = generateSystemPrompt({ cloud: true }); + expect(out.slice(0, CONFIG_MARKER.length)).toBe(CONFIG_MARKER); + }); + + it("no library → JSON with only libraryVersion", () => { + const config = parseBlock(generateSystemPrompt({ cloud: true })); + expect(Object.keys(config)).toEqual(["libraryVersion"]); + expect(config.libraryVersion).toBe("0.1.0"); + }); + + it("emitted keys are a subset of Cloud config keys", () => { + const config = parseBlock(generateSystemPrompt({ cloud: true, instructions: "Be terse." })); + for (const key of Object.keys(config)) { + expect(CLOUD_CONFIG_KEYS).toContain(key); + } + }); + + it("appends extra prose after the block", () => { + const out = generateSystemPrompt({ cloud: true, instructions: "Be terse." }); + const [sentinelLine, jsonLine, ...rest] = out.split("\n"); + expect(sentinelLine).toBe("]]>openui:config"); + expect(() => JSON.parse(jsonLine)).not.toThrow(); + expect(rest.join("\n")).toBe("Be terse."); + }); + + it("no instructions → sentinel + JSON only", () => { + expect(generateSystemPrompt({ cloud: true }).split("\n")).toHaveLength(2); + }); +}); + +describe("generateSystemPrompt({ cloud: true, library })", () => { + it("emits the library under chatLibrary, dropping components", () => { + const specJson = { + ...library, + components: { + Card: { signature: "Card(children: (TextContent)[], title?: string)" }, + }, + }; + const config = parseBlock(generateSystemPrompt({ cloud: true, library: specJson })); + expect((config.chatLibrary as Record).components).toBeUndefined(); + expect(config.chatLibrary).toEqual(library); + expect(Object.keys(config)).toEqual(["chatLibrary"]); + }); + + it("systemPromptOptions rides as a sibling key", () => { + const config = parseBlock( + generateSystemPrompt({ + cloud: true, + library, + promptOptions: { preamble: "You generate UI for Acme." }, + }), + ); + expect(Object.keys(config)).toEqual(["chatLibrary", "systemPromptOptions"]); + expect(config.systemPromptOptions).toEqual({ preamble: "You generate UI for Acme." }); + }); + + it("strips Cloud-unsupported prompt flags from the wire", () => { + const config = parseBlock( + generateSystemPrompt({ + cloud: true, + library, + promptOptions: { + preamble: "Acme.", + editMode: true, + tools: ["search"], + } as CloudPromptOptions, + }), + ); + expect(config.systemPromptOptions).toEqual({ preamble: "Acme." }); + }); + + it("promptOptions without a library throws", () => { + expect(() => + generateSystemPrompt({ + cloud: true, + promptOptions: { preamble: "Nope." }, + }), + ).toThrowError(/promptOptions requires a library/); + }); + + it("invalid library throws with every issue", () => { + const broken: LibrarySpec = { + root: "Missing", + components: {}, + schema: { + $defs: { + Card: { + properties: { body: { $ref: "#/$defs/Nope" } }, + required: ["ghost"], + }, + }, + }, + }; + expect(() => generateSystemPrompt({ cloud: true, library: broken })).toThrowError( + /Invalid library/, + ); + expect(() => generateSystemPrompt({ cloud: true, library: broken })).toThrowError( + /Root component "Missing"/, + ); + expect(() => generateSystemPrompt({ cloud: true, library: broken })).toThrowError( + /Unresolvable \$ref "#\/\$defs\/Nope"/, + ); + expect(() => generateSystemPrompt({ cloud: true, library: broken })).toThrowError( + /required property "ghost"/, + ); + }); +}); diff --git a/packages/lang-core/src/parser/cloud-config.ts b/packages/lang-core/src/parser/cloud-config.ts new file mode 100644 index 000000000..7dfdff373 --- /dev/null +++ b/packages/lang-core/src/parser/cloud-config.ts @@ -0,0 +1,59 @@ +import type { CloudPromptOptions, LibrarySpec, SystemPromptOptions } from "./prompt"; +import { validateChatLibrary } from "./validate-library"; + +/** `]]>openui:config\n` — request-direction config block header. Trailing newline is part of the wire contract. */ +const CLOUD_CONFIG_MARKER = "]]>openui:config\n"; + +/** + * Wire pin for OpenUI Cloud's built-in chat library when `generateSystemPrompt({ cloud: true })` + * is called without your own library. Cloud rejects a non-numeric or too-old version. + */ +const CLOUD_CHAT_LIBRARY_VERSION = "0.1.0"; + +type CloudConfig = + | { libraryVersion: string } + | { chatLibrary: Omit; systemPromptOptions?: CloudPromptOptions }; + +function pickCloudPromptOptions( + options: SystemPromptOptions | CloudPromptOptions | undefined, +): CloudPromptOptions | undefined { + if (!options) return undefined; + const picked: CloudPromptOptions = {}; + if (options.examples) picked.examples = options.examples; + if (options.preamble) picked.preamble = options.preamble; + if (options.additionalRules) picked.additionalRules = options.additionalRules; + return Object.keys(picked).length > 0 ? picked : undefined; +} + +export function generateCloudConfig(spec: { + library?: LibrarySpec; + promptOptions?: SystemPromptOptions | CloudPromptOptions; + instructions?: string; +}): string { + let config: CloudConfig; + + if (spec.library) { + const issues = validateChatLibrary(spec.library); + if (issues.length > 0) { + throw new Error( + `[generateSystemPrompt] Invalid library: ${issues.map((i) => i.message).join(" ")}`, + ); + } + const { components: _components, ...chatLibrary } = spec.library; + const promptOptions = pickCloudPromptOptions(spec.promptOptions); + config = { + chatLibrary, + ...(promptOptions ? { systemPromptOptions: promptOptions } : {}), + }; + } else { + if (spec.promptOptions && pickCloudPromptOptions(spec.promptOptions)) { + throw new Error( + "[generateSystemPrompt] promptOptions requires a library — the built-in library ignores it.", + ); + } + config = { libraryVersion: CLOUD_CHAT_LIBRARY_VERSION }; + } + + const block = `${CLOUD_CONFIG_MARKER}${JSON.stringify(config)}`; + return spec.instructions ? `${block}\n${spec.instructions}` : block; +} diff --git a/packages/lang-core/src/parser/prompt.ts b/packages/lang-core/src/parser/prompt.ts index d6ea9a76d..28f8da017 100644 --- a/packages/lang-core/src/parser/prompt.ts +++ b/packages/lang-core/src/parser/prompt.ts @@ -1,5 +1,6 @@ import { recordSystemPromptGeneration } from "../telemetry/runtime"; import { BUILTINS, LAZY_BUILTIN_DEFS } from "./builtins"; +import { generateCloudConfig } from "./cloud-config"; import type { LibraryJSONSchema } from "./types"; // ─── PromptSpec types (JSON-serializable, no Zod deps) ────────────────────── @@ -694,22 +695,49 @@ export function generatePrompt(spec: PromptSpec): string { return parts.join("\n"); } -// ─── System prompt (library + options + instructions) ─────────────────────── +// ─── System prompt (library + options) ────────────────────────────────────── /** Prompt options for {@link generateSystemPrompt} */ export type SystemPromptOptions = Omit; -/** Object input for {@link generateSystemPrompt}. */ -export interface SystemPromptSpec { - library: LibrarySpec; - promptOptions?: SystemPromptOptions; -} +/** + * Prompt options allowed on the OpenUI Cloud wire. Extra flags (`tools`, + * `editMode`, …) are stripped — Cloud's built-in prompt assembler ignores them. + */ +export type CloudPromptOptions = Pick< + SystemPromptOptions, + "examples" | "preamble" | "additionalRules" +>; -/** Render the full system prompt for a library. */ +/** + * Object input for {@link generateSystemPrompt}. + * + * Pass `cloud: true` to emit OpenUI Cloud's managed `]]>openui:config` block + * instead of a locally generated prompt. In that mode `library` is optional — + * omit it to use Cloud's built-in chat library. `instructions` is Cloud-only + * extra prose appended after the config block. + */ +export type SystemPromptSpec = + | { + library: LibrarySpec; + promptOptions?: SystemPromptOptions; + cloud?: false; + } + | { + cloud: true; + library?: LibrarySpec; + promptOptions?: CloudPromptOptions; + instructions?: string; + }; + +/** Render the full system prompt for a library, or Cloud's managed config block when `cloud: true`. */ export function generateSystemPrompt(spec: SystemPromptSpec): string; -/** @deprecated Pass `{ library, promptOptions, instructions }` instead. Removed at the next major. */ +/** @deprecated Pass `{ library, promptOptions }` instead. Removed at the next major. */ export function generateSystemPrompt(spec: PromptSpec): string; export function generateSystemPrompt(spec: SystemPromptSpec | PromptSpec): string { + if (isCloudSpec(spec)) { + return generateCloudConfig(spec); + } if (!isSystemPromptSpec(spec)) { const prompt = generatePrompt(spec); recordSystemPromptGeneration(spec, "legacy_prompt_spec"); @@ -721,7 +749,14 @@ export function generateSystemPrompt(spec: SystemPromptSpec | PromptSpec): strin return prompt; } -// use `library` to discriminate SystemPromptSpec from the deprecated base-PromptSpec -function isSystemPromptSpec(spec: SystemPromptSpec | PromptSpec): spec is SystemPromptSpec { - return "library" in spec && typeof (spec as SystemPromptSpec).library === "object"; +function isCloudSpec( + spec: SystemPromptSpec | PromptSpec, +): spec is Extract { + return "cloud" in spec && (spec as { cloud?: unknown }).cloud === true; +} + +function isSystemPromptSpec( + spec: SystemPromptSpec | PromptSpec, +): spec is Extract { + return "library" in spec && typeof (spec as { library?: unknown }).library === "object"; } diff --git a/packages/lang-core/src/parser/validate-library.ts b/packages/lang-core/src/parser/validate-library.ts new file mode 100644 index 000000000..32af9aa6e --- /dev/null +++ b/packages/lang-core/src/parser/validate-library.ts @@ -0,0 +1,182 @@ +/** + * Structural validator for your own library. + * Hand-rolled walker — reports every issue in one pass. + */ +import type { LibrarySpec } from "./prompt"; + +export interface ChatLibraryIssue { + code: + | "invalid-shape" + | "root-not-found" + | "unresolved-ref" + | "unknown-group-component" + | "invalid-required"; + message: string; + /** e.g. "$defs/Card/properties/children/items" */ + path?: string; +} + +const REF_PATTERN = /^#\/\$defs\/(.+)$/; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Recursively collect every `$ref` and check it resolves within `$defs`. */ +function collectRefIssues( + node: unknown, + path: string, + defNames: Set, + issues: ChatLibraryIssue[], +): void { + if (Array.isArray(node)) { + node.forEach((item, i) => collectRefIssues(item, `${path}/${i}`, defNames, issues)); + return; + } + if (!isPlainObject(node)) return; + + const ref = node["$ref"]; + if (typeof ref === "string") { + const match = REF_PATTERN.exec(ref); + if (!match || !defNames.has(match[1] as string)) { + issues.push({ + code: "unresolved-ref", + message: `Unresolvable $ref "${ref}" at ${path} — refs must be "#/$defs/" pointing at a component in $defs.`, + path, + }); + } + } + + for (const [key, value] of Object.entries(node)) { + if (key === "$ref") continue; + collectRefIssues(value, `${path}/${key}`, defNames, issues); + } +} + +/** + * Structural validation of your own library. + * Returns ALL issues found (empty array = valid). + */ +export function validateChatLibrary(library: LibrarySpec): ChatLibraryIssue[] { + const issues: ChatLibraryIssue[] = []; + + if (!isPlainObject(library)) { + return [ + { + code: "invalid-shape", + message: "chatLibrary must be an object.", + }, + ]; + } + + const { root, schema, componentGroups } = library; + + if (root !== undefined && (typeof root !== "string" || root.length === 0)) { + issues.push({ + code: "invalid-shape", + message: "chatLibrary.root, when present, must be a non-empty string.", + path: "root", + }); + } + + if (!isPlainObject(schema)) { + issues.push({ + code: "invalid-shape", + message: "chatLibrary.schema must be an object with a $defs map of component schemas.", + path: "schema", + }); + return issues; + } + + const defs = schema.$defs; + if (!isPlainObject(defs) || Object.keys(defs).length === 0) { + issues.push({ + code: "invalid-shape", + message: "chatLibrary.schema.$defs must be a non-empty object keyed by component name.", + path: "schema/$defs", + }); + return issues; + } + + const defNames = new Set(Object.keys(defs)); + + if (typeof root === "string" && root.length > 0 && !defNames.has(root)) { + issues.push({ + code: "root-not-found", + message: `Root component "${root}" was not found in schema.$defs. Available components: ${[...defNames].join(", ")}.`, + path: "root", + }); + } + + for (const [name, def] of Object.entries(defs)) { + const defPath = `$defs/${name}`; + if (!isPlainObject(def)) { + issues.push({ + code: "invalid-shape", + message: `${defPath} must be an object component schema.`, + path: defPath, + }); + continue; + } + + const properties = def["properties"]; + if (properties !== undefined && !isPlainObject(properties)) { + issues.push({ + code: "invalid-shape", + message: `${defPath}/properties must be an object.`, + path: `${defPath}/properties`, + }); + } + + const required = def["required"]; + if (required !== undefined) { + if (!Array.isArray(required) || required.some((r) => typeof r !== "string")) { + issues.push({ + code: "invalid-required", + message: `${defPath}/required must be an array of property names.`, + path: `${defPath}/required`, + }); + } else { + const propKeys = new Set(isPlainObject(properties) ? Object.keys(properties) : []); + for (const r of required) { + if (!propKeys.has(r)) { + issues.push({ + code: "invalid-required", + message: `${defPath} lists required property "${r}" that is not in properties.`, + path: `${defPath}/required`, + }); + } + } + } + } + + collectRefIssues(properties, `${defPath}/properties`, defNames, issues); + } + + for (const group of componentGroups ?? []) { + if ( + !isPlainObject(group) || + typeof group.name !== "string" || + !Array.isArray(group.components) + ) { + issues.push({ + code: "invalid-shape", + message: + "Each componentGroups entry must be {name: string, components: string[], notes?: string[]}.", + path: "componentGroups", + }); + continue; + } + for (const comp of group.components) { + if (typeof comp !== "string" || !defNames.has(comp)) { + issues.push({ + code: "unknown-group-component", + message: `Component group "${group.name}" references unknown component "${String(comp)}".`, + path: "componentGroups", + }); + } + } + } + + return issues; +}