mirror of
https://github.com/docker/docs.git
synced 2026-11-04 10:01:00 +00:00
Docs: answer with cards (steps, tables, notes, follow-up questions) in Gordon chat (#26452)
## Description Gordon answers everything in plain markdown, even when the answer is a procedure or a comparison. Those answers are harder to scan than they need to be. This PR lets Gordon put a small card inside its answer when that helps: - **Steps** for procedures, with commands in code boxes - **Tables** for comparisons (limits, plans, options) - **Notes** for warnings, active incidents or tips - **Code snippets** for config files - **Follow-up questions** as chips. Selecting one asks it as your next question. ``` You can containerize it with docker init, then run it with Compose. ┌───────────────────────────────────────────────┐ │ Containerize a Node.js app │ │ ① Generate the Docker assets │ │ [ docker init ] │ │ ② Build and run the app │ │ [ docker compose up --build ] │ │ ▌ Use Docker Hardened Images for security │ │ ( How do I add a database service? ) │ └───────────────────────────────────────────────┘ Sources: - https://docs.docker.com/guides/nodejs/ ``` Short answers, explanations and greetings stay plain markdown. ### How it works ``` chat ── "I can draw cards" (ui: "openui-v1") ──▶ Gordon chat ◀── markdown answer, with at most one ```openui-lang block ── Gordon chat: draws the block as a card, everything else as markdown ``` - The chat sends one extra field with each question. Only then does Gordon's backend tell the model it may add a card. Without the field, answers are the same as today. - A card is a fenced block of [OpenUI Lang](https://www.openui.com/docs/openui-lang) inside the normal answer text, so the conversation history and stored messages stay plain text. - The chat draws the block with its own styles. Anything it doesn't recognize is left out, and if the card code can't load, the block shows as a plain code block. ### Visual change to all answers Gordon's answers are no longer inside a grey bubble. A framed card inside the grey bubble looked like a box in a box. Answers now sit on the panel background at full width, and the card stands out as its own block. Your own messages keep the blue bubble. <!-- screenshots: before / after, light and dark --> ### Rollout - Cards are **on in local builds and deploy previews**, and **off in production**. - To turn them on in production, set `params.gordon.cards: true` in `hugo.yaml`. - Gordon's backend already supports this, so no other change is needed. ### Other notes - **Safety:** card text from the model is only ever inserted as text, never as HTML. - **Page weight:** the card code (about 120 KB gzipped) is a separate bundle. It's only downloaded when an answer first contains a card, never on page load. - **Copy button:** copies the answer with cards written out as markdown (numbered steps, a table, `> [!NOTE]`), not the raw card code. Follow-up chips are left out. - **Instructions Gordon gets:** the prompt lives entirely in Gordon's backend, which injects it when the chat sends `ui: "openui-v1"`. This repo only has the component list the chat draws (`assets/js/gordon-cards/components.mjs`). The backend's card prompt describes the same components, so when adding or changing one, the backend's prompt changes with it. - **New dependencies:** `@openuidev/lang-core` (the OpenUI Lang parser) and `zod`, pinned. Its usage telemetry never runs in a browser, and `.npmrc` already blocks install scripts. ### How to test 1. Run `docker compose watch` and open http://localhost:1313. Local builds use Gordon's staging backend. 2. Open Gordon from the header and ask: - **Should show a card:** "How do I containerize my Node.js app?", "What are the Docker Hub pull limits?", "How do I set up a registry mirror?" - **Should stay plain text:** "What is Docker Scout?", "hi" 3. Select a follow-up chip. It should ask that question. 4. Select **Copy** on an answer with a card. The pasted text should be markdown. 5. Reload the page. Cards should come back from the conversation history. 6. Check dark mode. 7. In the browser's Network tab, check: - each question to `/public/ask` includes `"ui": "openui-v1"`; - `gordon-cards.js` only loads after the first answer with a card. ## Related issues or tickets <!-- none public --> ## Reviews - [x] Technical review - [ ] Editorial review - [ ] Product review
This commit is contained in:
1 parent
fc5b3aaf1f
commit
02b412b178
10 files changed
+865
-10
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 <pre><code> 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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
+66
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }};
|
||||
</script>
|
||||
<div
|
||||
x-data="{
|
||||
@@ -157,6 +163,7 @@
|
||||
email: 'docs@docker.com',
|
||||
uuid: this.getSessionId(),
|
||||
action: 'AskGordon',
|
||||
...(window.GORDON_CARDS_ENABLED && { ui: 'openui-v1' }),
|
||||
...(this.includePageContext && {
|
||||
page_url: window.location.href,
|
||||
{{ with .Title }}page_title: {{ . | jsonify }}{{ end }}
|
||||
@@ -254,8 +261,30 @@
|
||||
}
|
||||
},
|
||||
|
||||
formatMessageContent(content) {
|
||||
return this.$markdown(content)
|
||||
formatMessageContent(message) {
|
||||
return this.$markdown(message.content, { streaming: message.isStreaming === true })
|
||||
},
|
||||
|
||||
askFollowUp(event) {
|
||||
const chip = event.target.closest('[data-gordon-follow-up]')
|
||||
if (!chip || this.isLoading || this.isThreadLimitReached()) {
|
||||
return
|
||||
}
|
||||
this.currentQuestion = chip.dataset.gordonFollowUp
|
||||
this.askQuestion()
|
||||
},
|
||||
|
||||
async getCopyText(content) {
|
||||
if (!content.includes('```openui-lang')) {
|
||||
return content
|
||||
}
|
||||
try {
|
||||
const cards = await this.$gordonCards()
|
||||
return cards.gordonAnswerToMarkdown(content)
|
||||
} catch (err) {
|
||||
console.error('Gordon cards:', err)
|
||||
return content
|
||||
}
|
||||
},
|
||||
|
||||
async submitFeedback(messageIndex, feedbackType) {
|
||||
@@ -301,7 +330,7 @@
|
||||
if (!message.content) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content)
|
||||
await navigator.clipboard.writeText(await this.getCopyText(message.content))
|
||||
message.copied = true
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
@@ -385,6 +414,7 @@
|
||||
<!-- Messages container -->
|
||||
<div
|
||||
x-ref="messagesContainer"
|
||||
@click="askFollowUp($event)"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 overflow-y-auto overscroll-contain p-6"
|
||||
style="scrollbar-width: none; &::-webkit-scrollbar: { display: none; }"
|
||||
>
|
||||
@@ -460,10 +490,13 @@
|
||||
<div
|
||||
:class="message.role === 'user' ? 'flex justify-end' : 'flex justify-start'"
|
||||
>
|
||||
<div class="flex max-w-full flex-col gap-2">
|
||||
<div
|
||||
:class="message.role === 'user' ? '' : 'w-full'"
|
||||
class="flex max-w-full flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
:class="message.role === 'user' ? 'bg-blue-500 dark:bg-blue-800 text-white' : 'max-w-none bg-gray-100 dark:bg-gray-800'"
|
||||
class="prose prose-sm dark:prose-invert rounded-lg px-4"
|
||||
:class="message.role === 'user' ? 'rounded-lg bg-blue-500 px-4 text-white dark:bg-blue-800' : 'max-w-none'"
|
||||
class="prose prose-sm dark:prose-invert"
|
||||
>
|
||||
<template x-if="!message.content && message.isStreaming">
|
||||
<div class="flex gap-1 py-3">
|
||||
@@ -482,7 +515,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="message.content">
|
||||
<div x-html="formatMessageContent(message.content)"></div>
|
||||
<div x-html="formatMessageContent(message)"></div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Feedback buttons for assistant messages -->
|
||||
@@ -750,4 +783,240 @@
|
||||
background-color: rgb(55 65 81);
|
||||
color: rgb(229 231 235);
|
||||
}
|
||||
|
||||
/* Cards Gordon draws in an answer (assets/js/gordon-cards). Plain CSS rather than Tailwind
|
||||
classes: the cards are built in JavaScript, and Tailwind only sees classes in rendered HTML. */
|
||||
#gordon-chat .gordon-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(200, 207, 218, 1);
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
color: rgba(30, 33, 41, 1);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card {
|
||||
border-color: rgba(67, 76, 95, 1);
|
||||
background: rgba(30, 33, 41, 1);
|
||||
color: rgba(231, 234, 239, 1);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-description {
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: 0.851rem;
|
||||
color: rgba(86, 101, 129, 1);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-description {
|
||||
color: rgba(169, 180, 198, 1);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-steps {
|
||||
counter-reset: gordon-step;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-step {
|
||||
counter-increment: gordon-step;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-step::before {
|
||||
content: counter(gordon-step);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(37, 96, 255, 1);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-step-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-step-detail {
|
||||
margin: 0;
|
||||
font-size: 0.851rem;
|
||||
color: rgba(86, 101, 129, 1);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-step-detail {
|
||||
color: rgba(169, 180, 198, 1);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-callout {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-left: 4px solid;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.851rem;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-callout p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-callout-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-callout-info {
|
||||
border-color: rgba(37, 96, 255, 1);
|
||||
background: rgba(217, 229, 252, 1);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-callout-success {
|
||||
border-color: rgba(45, 149, 104, 1);
|
||||
background: rgba(235, 249, 238, 1);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-callout-warning {
|
||||
border-color: rgba(235, 156, 0, 1);
|
||||
background: rgba(255, 245, 219, 1);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-callout-destructive {
|
||||
border-color: rgba(228, 12, 44, 1);
|
||||
background: rgba(255, 223, 223, 1);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-callout-info {
|
||||
background: rgba(0, 29, 86, 0.6);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-callout-success {
|
||||
background: rgba(23, 75, 50, 0.6);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-callout-warning {
|
||||
background: rgba(133, 73, 0, 0.45);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-callout-destructive {
|
||||
background: rgba(137, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-table table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.851rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-table th,
|
||||
#gordon-chat .gordon-card-table td {
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid rgba(231, 234, 239, 1);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-table th,
|
||||
.dark #gordon-chat .gordon-card-table td {
|
||||
border-bottom-color: rgba(67, 76, 95, 1);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-follow-ups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-follow-up {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid rgba(37, 96, 255, 1);
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: rgba(37, 96, 255, 1);
|
||||
font-size: 0.851rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-follow-up:hover {
|
||||
background: rgba(217, 229, 252, 1);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-follow-up {
|
||||
border-color: rgba(123, 164, 244, 1);
|
||||
color: rgba(123, 164, 244, 1);
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-follow-up:hover {
|
||||
background: rgba(0, 29, 86, 1);
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-loading span {
|
||||
display: block;
|
||||
height: 0.875rem;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(200, 207, 218, 1);
|
||||
animation: gordon-card-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-loading span:first-child {
|
||||
width: 33%;
|
||||
}
|
||||
|
||||
#gordon-chat .gordon-card-loading span:last-child {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.dark #gordon-chat .gordon-card-loading span {
|
||||
background: rgba(67, 76, 95, 1);
|
||||
}
|
||||
|
||||
@keyframes gordon-card-pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Generated
+46
-1
@@ -14,6 +14,7 @@
|
||||
"@alpinejs/persist": "3.15.8",
|
||||
"@docker/marlin-sdk-web-public": "0.6.0",
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"@openuidev/lang-core": "1.0.1",
|
||||
"@tailwindcss/cli": "4.2.1",
|
||||
"@tailwindcss/typography": "0.5.19",
|
||||
"alpinejs": "3.15.8",
|
||||
@@ -22,7 +23,8 @@
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.15.0",
|
||||
"micromark": "4.0.2",
|
||||
"tailwindcss": "4.2.1"
|
||||
"tailwindcss": "4.2.1",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
@@ -214,6 +216,25 @@
|
||||
"@chevrotain/types": "~11.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@openuidev/lang-core": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@openuidev/lang-core/-/lang-core-1.0.1.tgz",
|
||||
"integrity": "sha512-4vBa7FRM6C4v4aCvBAmAtTgFWiEQiNTazG9h4Xy9fC6P/p6+9kZu4RdTyLToBEvdxrYn/gDy/PakEH8PU3lUcw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ci-info": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": ">=1.0.0",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@modelcontextprotocol/sdk": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
||||
@@ -1252,6 +1273,21 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/ci-info": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
|
||||
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
@@ -3060,6 +3096,15 @@
|
||||
"bin": {
|
||||
"uuidv7": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -19,6 +19,7 @@
|
||||
"@alpinejs/persist": "3.15.8",
|
||||
"@docker/marlin-sdk-web-public": "0.6.0",
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"@openuidev/lang-core": "1.0.1",
|
||||
"@tailwindcss/cli": "4.2.1",
|
||||
"@tailwindcss/typography": "0.5.19",
|
||||
"alpinejs": "3.15.8",
|
||||
@@ -27,7 +28,8 @@
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.15.0",
|
||||
"micromark": "4.0.2",
|
||||
"tailwindcss": "4.2.1"
|
||||
"tailwindcss": "4.2.1",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
|
||||
Reference in new issue
Block a user