diff --git a/assets/js/gordon-cards/components.mjs b/assets/js/gordon-cards/components.mjs new file mode 100644 index 0000000000..689627bd3c --- /dev/null +++ b/assets/js/gordon-cards/components.mjs @@ -0,0 +1,121 @@ +// The components Gordon may draw in a ```openui-lang card in a docs chat answer (OpenUI Lang, see +// https://www.openui.com/docs/openui-lang): each one's name, what the model is told it's for, and +// its props. render.js draws each of them. Gordon's backend tells the model about these same +// components, so when adding or changing one here, the backend's card prompt must change with it: +// a component the model writes but the chat can't draw is dropped, content and all. +// +// OpenUI Lang passes props positionally, in each schema's key order. Reordering or removing a key +// changes the meaning of cards already in a conversation: only ever append optional keys. +// +// Children come before the parents that reference them: OpenUI names a parent's child types by +// looking their schemas up among the components registered with it. + +import { createLibrary, defineComponent } from '@openuidev/lang-core' +import { z } from 'zod/v4' + +// The fence language that marks a markdown code block in Gordon's answer as a card. +export const GORDON_CARD_FENCE_LANGUAGE = 'openui-lang' + +// Sent to the Gordon backend as `ui` in the request context to say the chat can draw cards. The +// backend only teaches the model to write them when it's present. +export const GORDON_CARD_UI_CAPABILITY = 'openui-v1' + +// Every card's `root` is a `Card`. +export const GORDON_CARD_ROOT = 'Card' + +const tableProps = z.object({ + columns: z.array(z.string()), + rows: z.array(z.array(z.union([z.string(), z.number()]))), +}) + +const stepProps = z.object({ + title: z.string(), + detail: z.string().optional(), + command: z.string().optional(), +}) + +const stepsProps = z.object({ + items: z.array(stepProps), +}) + +const calloutProps = z.object({ + variant: z.enum(['info', 'success', 'warning', 'destructive']), + title: z.string(), + text: z.string().optional(), +}) + +const codeProps = z.object({ + code: z.string(), + language: z.string().optional(), +}) + +const followUpsProps = z.object({ + items: z.array(z.string()), +}) + +const cardProps = z.object({ + title: z.string(), + children: z.array( + z.union([stepsProps, calloutProps, tableProps, codeProps, followUpsProps]), + ), + description: z.string().optional(), +}) + +export const gordonCardComponents = [ + { + name: 'Table', + description: + 'A table. rows is a list of rows; each row has one cell per column, in column order. A cell is a string or a number.', + props: tableProps, + }, + { + name: 'Step', + description: + 'One step of a procedure: a short title, an optional detail sentence, and an optional shell command to run.', + props: stepProps, + }, + { + name: 'Steps', + description: 'A numbered procedure.', + props: stepsProps, + }, + { + name: 'Callout', + description: + 'A highlighted note: a cause, a warning, an active incident or a result worth noticing.', + props: calloutProps, + }, + { + name: 'Code', + description: + 'A code or config snippet, e.g. a Dockerfile, a compose.yaml or a daemon.json excerpt.', + props: codeProps, + }, + { + name: 'FollowUps', + description: + 'Up to 3 short follow-up questions the user is likely to ask next, as clickable chips. Last child of the Card.', + props: followUpsProps, + }, + { + name: 'Card', + description: + 'The root of every block: a titled container for the other components, stacked vertically.', + props: cardProps, + }, +] + +// The OpenUI library for the components above. Only their schemas matter: render.js draws the +// parsed tree itself, so no component gets a renderer here. +export function createGordonCardLibrary() { + return createLibrary({ + root: GORDON_CARD_ROOT, + components: gordonCardComponents.map((component) => + defineComponent({ ...component, component: renderNothing }), + ), + }) +} + +function renderNothing() { + return null +} diff --git a/assets/js/gordon-cards/index.js b/assets/js/gordon-cards/index.js new file mode 100644 index 0000000000..894487c30e --- /dev/null +++ b/assets/js/gordon-cards/index.js @@ -0,0 +1,8 @@ +// Entry point of the gordon-cards.js bundle. It's built apart from scripts.js and only loaded by +// the Gordon chat when an answer first contains a card (see loadGordonCards in src/alpine.js), so +// the OpenUI parser doesn't weigh on every docs page. + +import { renderGordonCard } from './render.mjs' +import { gordonAnswerToMarkdown } from './to-markdown.mjs' + +window.GordonCards = { renderGordonCard, gordonAnswerToMarkdown } diff --git a/assets/js/gordon-cards/parse.mjs b/assets/js/gordon-cards/parse.mjs new file mode 100644 index 0000000000..532a696629 --- /dev/null +++ b/assets/js/gordon-cards/parse.mjs @@ -0,0 +1,51 @@ +import { createParser } from '@openuidev/lang-core' + +import { createGordonCardLibrary, GORDON_CARD_ROOT } from './components.mjs' + +let parser + +// The card in a ```openui-lang block, as an element tree ({ type: 'element', typeName, props }), +// or null if nothing in it parses into a Card (yet, while it streams). Components that aren't in +// components.mjs, unresolved references and unused statements are left out. +export function parseGordonCard(code) { + parser ??= createParser( + createGordonCardLibrary().toJSONSchema(), + GORDON_CARD_ROOT, + ) + return parser.parse(code).root +} + +// The props of a parsed child component, or undefined if `value` isn't one (yet). Props are +// model output, possibly half-streamed, so nothing about their shape can be assumed. +export function readElement(value) { + if ( + typeof value !== 'object' || + value === null || + value.type !== 'element' || + typeof value.props !== 'object' || + value.props === null + ) { + return undefined + } + return { typeName: value.typeName, props: value.props } +} + +export function readArray(value) { + return Array.isArray(value) ? value : [] +} + +export function readString(value) { + return typeof value === 'string' ? value : undefined +} + +export function readCell(value) { + return typeof value === 'string' || typeof value === 'number' + ? String(value) + : '' +} + +export const CALLOUT_VARIANTS = ['info', 'success', 'warning', 'destructive'] + +export function readCalloutVariant(value) { + return CALLOUT_VARIANTS.includes(value) ? value : 'info' +} diff --git a/assets/js/gordon-cards/render.mjs b/assets/js/gordon-cards/render.mjs new file mode 100644 index 0000000000..3fca1e12bc --- /dev/null +++ b/assets/js/gordon-cards/render.mjs @@ -0,0 +1,161 @@ +import { + parseGordonCard, + readArray, + readCalloutVariant, + readCell, + readElement, + readString, +} from './parse.mjs' + +// Draws a ```openui-lang block from Gordon's answer as a card element, or returns null if nothing +// in it parses into a card (yet). Text from the model only ever goes in through textContent and +// setAttribute, never as HTML. Code blocks come out as
for the chat's usual syntax
+// highlighting, and follow-up chips as buttons carrying data-gordon-follow-up, which the chat
+// turns into the user's next question.
+export function renderGordonCard(code) {
+ const root = parseGordonCard(code)
+ return root === null ? null : renderCard(root.props)
+}
+
+function renderCard(props) {
+ const card = element('section', 'gordon-card not-prose')
+ const title = readString(props.title)
+ const description = readString(props.description)
+ if (title !== undefined) {
+ card.append(element('h4', 'gordon-card-title', title))
+ }
+ if (description !== undefined) {
+ card.append(element('p', 'gordon-card-description', description))
+ }
+ for (const child of readArray(props.children)) {
+ const rendered = renderChild(child)
+ if (rendered !== undefined) {
+ card.append(rendered)
+ }
+ }
+ return card
+}
+
+function renderChild(node) {
+ const child = readElement(node)
+ if (child === undefined) {
+ return undefined
+ }
+ switch (child.typeName) {
+ case 'Steps':
+ return renderSteps(child.props)
+ case 'Callout':
+ return renderCallout(child.props)
+ case 'Table':
+ return renderTable(child.props)
+ case 'Code':
+ return renderCode(
+ readString(child.props.code) ?? '',
+ readString(child.props.language),
+ )
+ case 'FollowUps':
+ return renderFollowUps(child.props)
+ default:
+ return undefined
+ }
+}
+
+function renderSteps(props) {
+ const list = element('ol', 'gordon-card-steps')
+ for (const item of readArray(props.items)) {
+ const step = readElement(item)
+ if (step === undefined) {
+ continue
+ }
+ const title = readString(step.props.title)
+ const detail = readString(step.props.detail)
+ const command = readString(step.props.command)
+ const entry = element('li', 'gordon-card-step')
+ if (title !== undefined) {
+ entry.append(element('p', 'gordon-card-step-title', title))
+ }
+ if (detail !== undefined) {
+ entry.append(element('p', 'gordon-card-step-detail', detail))
+ }
+ if (command !== undefined) {
+ entry.append(renderCode(command, 'bash'))
+ }
+ list.append(entry)
+ }
+ return list
+}
+
+function renderCallout(props) {
+ const variant = readCalloutVariant(props.variant)
+ const callout = element(
+ 'div',
+ `gordon-card-callout gordon-card-callout-${variant}`,
+ )
+ const title = readString(props.title)
+ const text = readString(props.text)
+ if (title !== undefined) {
+ callout.append(element('p', 'gordon-card-callout-title', title))
+ }
+ if (text !== undefined) {
+ callout.append(element('p', 'gordon-card-callout-text', text))
+ }
+ return callout
+}
+
+function renderTable(props) {
+ const wrapper = element('div', 'gordon-card-table')
+ const table = element('table')
+ const head = element('thead')
+ const headRow = element('tr')
+ for (const column of readArray(props.columns)) {
+ headRow.append(element('th', undefined, readCell(column)))
+ }
+ head.append(headRow)
+ const body = element('tbody')
+ for (const row of readArray(props.rows)) {
+ const bodyRow = element('tr')
+ for (const cell of readArray(row)) {
+ bodyRow.append(element('td', undefined, readCell(cell)))
+ }
+ body.append(bodyRow)
+ }
+ table.append(head, body)
+ wrapper.append(table)
+ return wrapper
+}
+
+function renderCode(code, language) {
+ const pre = element('pre', 'not-prose')
+ const codeElement = element('code', undefined, code)
+ if (language !== undefined && /^[\w+-]+$/.test(language)) {
+ codeElement.className = `language-${language}`
+ }
+ pre.append(codeElement)
+ return pre
+}
+
+function renderFollowUps(props) {
+ const list = element('div', 'gordon-card-follow-ups')
+ const questions = readArray(props.items)
+ .map(readString)
+ .filter((question) => question !== undefined && question.trim() !== '')
+ .slice(0, 3)
+ for (const question of questions) {
+ const chip = element('button', 'gordon-card-follow-up', question)
+ chip.type = 'button'
+ chip.setAttribute('data-gordon-follow-up', question)
+ list.append(chip)
+ }
+ return list
+}
+
+function element(tag, className, text) {
+ const node = document.createElement(tag)
+ if (className !== undefined) {
+ node.className = className
+ }
+ if (text !== undefined) {
+ node.textContent = text
+ }
+ return node
+}
diff --git a/assets/js/gordon-cards/to-markdown.mjs b/assets/js/gordon-cards/to-markdown.mjs
new file mode 100644
index 0000000000..3918c42299
--- /dev/null
+++ b/assets/js/gordon-cards/to-markdown.mjs
@@ -0,0 +1,127 @@
+import { GORDON_CARD_FENCE_LANGUAGE } from './components.mjs'
+import {
+ parseGordonCard,
+ readArray,
+ readCalloutVariant,
+ readCell,
+ readElement,
+ readString,
+} from './parse.mjs'
+
+// Gordon's answer with each ```openui-lang card written out as plain markdown, for where the
+// answer is read as text rather than drawn: what the Copy button copies. Follow-up chips are left
+// out, as they only make sense in the chat. A block that doesn't parse into a card is kept as is.
+export function gordonAnswerToMarkdown(answer) {
+ return answer.replace(CARD_FENCE, (block, code) => {
+ const root = parseGordonCard(code)
+ return root === null ? block : cardToMarkdown(root.props)
+ })
+}
+
+const CARD_FENCE = new RegExp(
+ `^\`\`\`${GORDON_CARD_FENCE_LANGUAGE}[^\\S\\n]*\\n([\\s\\S]*?)^\`\`\`[^\\S\\n]*$`,
+ 'gm',
+)
+
+const CALLOUT_ALERTS = {
+ info: 'NOTE',
+ success: 'TIP',
+ warning: 'WARNING',
+ destructive: 'CAUTION',
+}
+
+function cardToMarkdown(props) {
+ const title = readString(props.title)
+ const description = readString(props.description)
+ const children = readArray(props.children)
+ .map(componentToMarkdown)
+ .filter((markdown) => markdown !== undefined)
+ return [
+ title === undefined ? undefined : `**${title}**`,
+ description,
+ ...children,
+ ]
+ .filter((part) => part !== undefined)
+ .join('\n\n')
+}
+
+function componentToMarkdown(node) {
+ const child = readElement(node)
+ if (child === undefined) {
+ return undefined
+ }
+ switch (child.typeName) {
+ case 'Steps':
+ return stepsToMarkdown(child.props)
+ case 'Callout':
+ return calloutToMarkdown(child.props)
+ case 'Table':
+ return tableToMarkdown(child.props)
+ case 'Code':
+ return codeToMarkdown(
+ readString(child.props.code) ?? '',
+ readString(child.props.language),
+ )
+ default:
+ return undefined
+ }
+}
+
+function stepsToMarkdown(props) {
+ return readArray(props.items)
+ .map(readElement)
+ .filter((step) => step !== undefined)
+ .map(({ props: step }, index) => {
+ const detail = readString(step.detail)
+ const command = readString(step.command)
+ return [
+ `${index + 1}. ${readString(step.title) ?? ''}`,
+ ...(detail === undefined ? [] : [` ${detail}`]),
+ ...(command === undefined
+ ? []
+ : [indent(codeToMarkdown(command, 'bash'), ' ')]),
+ ].join('\n')
+ })
+ .join('\n')
+}
+
+function calloutToMarkdown(props) {
+ const title = readString(props.title)
+ const text = readString(props.text)
+ return [
+ `> [!${CALLOUT_ALERTS[readCalloutVariant(props.variant)]}]`,
+ ...(title === undefined ? [] : [`> **${title}**`]),
+ ...(text === undefined ? [] : [`> ${text}`]),
+ ].join('\n')
+}
+
+function tableToMarkdown(props) {
+ const columns = readArray(props.columns).map(toTableCell)
+ const rows = readArray(props.rows).map((row) =>
+ readArray(row).map(toTableCell),
+ )
+ return [
+ toTableRow(columns),
+ toTableRow(columns.map(() => '---')),
+ ...rows.map(toTableRow),
+ ].join('\n')
+}
+
+function codeToMarkdown(code, language) {
+ return [`\`\`\`${language ?? ''}`, code, '```'].join('\n')
+}
+
+function toTableRow(cells) {
+ return `| ${cells.join(' | ')} |`
+}
+
+function toTableCell(value) {
+ return readCell(value).replaceAll('|', '\\|').replaceAll('\n', ' ')
+}
+
+function indent(text, prefix) {
+ return text
+ .split('\n')
+ .map((line) => prefix + line)
+ .join('\n')
+}
diff --git a/assets/js/src/alpine.js b/assets/js/src/alpine.js
index c314dc641c..3868e9a096 100644
--- a/assets/js/src/alpine.js
+++ b/assets/js/src/alpine.js
@@ -51,9 +51,72 @@ marked.use({
}
})
+// Gordon's answers can contain ```openui-lang cards. The code that draws them is a separate
+// bundle (assets/js/gordon-cards), loaded the first time a card shows up.
+Alpine.store('gordonCards', { ready: false, failed: false })
+
+let gordonCardsLoading
+
+function loadGordonCards() {
+ gordonCardsLoading ??= new Promise((resolve, reject) => {
+ const script = document.createElement('script')
+ script.src = window.GORDON_CARDS_SRC
+ script.onload = () => {
+ Alpine.store('gordonCards').ready = true
+ resolve(window.GordonCards)
+ }
+ script.onerror = () => {
+ // Cards then stay plain code blocks, but a later call (such as Copy) can try again.
+ gordonCardsLoading = undefined
+ script.remove()
+ Alpine.store('gordonCards').failed = true
+ reject(new Error('Failed to load gordon-cards.js'))
+ }
+ document.head.append(script)
+ })
+ return gordonCardsLoading
+}
+
+Alpine.magic('gordonCards', () => loadGordonCards)
+
+// Swaps each ```openui-lang block for the card it describes. Until the card code has loaded, or
+// while a block is still streaming in and doesn't parse yet, it shows a placeholder. If the card
+// code can't load, or a finished block isn't a card, the block stays a plain code block.
+function replaceGordonCards(div, { streaming }) {
+ const blocks = div.querySelectorAll('pre > code.language-openui-lang')
+ if (blocks.length === 0) return
+
+ const cards = Alpine.store('gordonCards')
+ if (!cards.ready && !cards.failed) {
+ loadGordonCards().catch((err) => console.error('Gordon cards:', err))
+ }
+ blocks.forEach((code) => {
+ const pre = code.parentElement
+ if (cards.ready) {
+ const card = window.GordonCards.renderGordonCard(code.textContent)
+ if (card) {
+ pre.replaceWith(card)
+ } else if (streaming) {
+ pre.replaceWith(gordonCardPlaceholder())
+ }
+ } else if (!cards.failed) {
+ pre.replaceWith(gordonCardPlaceholder())
+ }
+ })
+}
+
+function gordonCardPlaceholder() {
+ const placeholder = document.createElement('div')
+ placeholder.className = 'gordon-card-loading not-prose'
+ placeholder.setAttribute('role', 'status')
+ placeholder.setAttribute('aria-label', 'Drawing card')
+ placeholder.append(document.createElement('span'), document.createElement('span'))
+ return placeholder
+}
+
// Add $markdown magic for rendering markdown with syntax highlighting
Alpine.magic('markdown', () => {
- return (content) => {
+ return (content, { streaming = false } = {}) => {
if (!content) return ''
const html = marked(content)
@@ -61,6 +124,8 @@ Alpine.magic('markdown', () => {
const div = document.createElement('div')
div.innerHTML = html
+ replaceGordonCards(div, { streaming })
+
// Handle code blocks (pre > code)
div.querySelectorAll('pre').forEach((pre) => {
// Add not-prose to prevent Tailwind Typography styling
diff --git a/hugo.yaml b/hugo.yaml
index 6ff798338c..6b8e955085 100644
--- a/hugo.yaml
+++ b/hugo.yaml
@@ -166,6 +166,12 @@ params:
# Docs repository URL
repo: https://github.com/docker/docs
+ # Gordon chat
+ gordon:
+ # Lets Gordon answer with cards (steps, tables, notes, snippets, follow-up
+ # questions) in production builds. Always on in other environments.
+ cards: false
+
# === Example versions ===
# The following parameters are used in various ways in content and templates.
# Mostly as examples in code snippets, but also in other exotic ways.
diff --git a/layouts/_partials/gordon-chat.html b/layouts/_partials/gordon-chat.html
index ad9740ef90..01ecfd8819 100644
--- a/layouts/_partials/gordon-chat.html
+++ b/layouts/_partials/gordon-chat.html
@@ -7,6 +7,12 @@
{{- else -}}
'https://ai-backend-service-stage.docker.com'
{{- end }};
+ {{- $gordonCards := resources.Get "js/gordon-cards/index.js"
+ | js.Build (dict "minify" true "targetPath" "gordon-cards.js")
+ }}
+ window.GORDON_CARDS_SRC = '{{ $gordonCards.RelPermalink }}';
+ // Lets Gordon answer with cards. Always on outside production builds.
+ window.GORDON_CARDS_ENABLED = {{ or (ne hugo.Environment "production") site.Params.gordon.cards }};