mirror of
https://github.com/docker/docs.git
synced 2026-11-04 10:01:00 +00:00
## 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
1023 lines
34 KiB
HTML
1023 lines
34 KiB
HTML
<!-- Gordon AI Chat Panel -->
|
|
<script>
|
|
window.GORDON_BASE_URL = {{ if eq hugo.Environment "production" -}}
|
|
'https://ai-backend-service.docker.com'
|
|
{{- else if getenv "HUGO_GORDON_URL" -}}
|
|
'{{ getenv "HUGO_GORDON_URL" }}'
|
|
{{- 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="{
|
|
isLoading: false,
|
|
error: null,
|
|
messages: $persist([]).using(sessionStorage).as('gordon-messages'),
|
|
currentQuestion: '',
|
|
threadId: $persist(null).using(sessionStorage).as('gordon-threadId'),
|
|
includePageContext: $persist(true).using(sessionStorage).as('gordon-includePageContext'),
|
|
maxTurnsPerThread: 10,
|
|
|
|
init() {
|
|
// Clean up any streaming messages that might be persisted
|
|
this.messages = this.messages.filter(m => !m.isStreaming)
|
|
|
|
// Watch for store changes to focus input
|
|
this.$watch('$store.gordon.isOpen', (isOpen) => {
|
|
if (isOpen) {
|
|
this.$nextTick(() => {
|
|
this.$refs.input?.focus()
|
|
})
|
|
}
|
|
})
|
|
|
|
// Watch for query from store and populate input
|
|
this.$watch('$store.gordon.query', (query) => {
|
|
if (query) {
|
|
this.currentQuestion = query
|
|
const shouldAutoSubmit = this.$store.gordon.autoSubmit
|
|
this.$nextTick(() => {
|
|
if (shouldAutoSubmit) {
|
|
this.askQuestion()
|
|
} else {
|
|
this.$refs.input?.focus()
|
|
this.$refs.input?.select()
|
|
}
|
|
})
|
|
// Clear the store query and autoSubmit flag after using them
|
|
this.$store.gordon.query = ''
|
|
this.$store.gordon.autoSubmit = false
|
|
}
|
|
})
|
|
},
|
|
|
|
getTurnCount() {
|
|
return this.messages.filter(m => m.role === 'user').length
|
|
},
|
|
|
|
getRemainingTurns() {
|
|
return this.maxTurnsPerThread - this.getTurnCount()
|
|
},
|
|
|
|
isThreadLimitReached() {
|
|
return this.getTurnCount() >= this.maxTurnsPerThread
|
|
},
|
|
|
|
shouldShowCountdown() {
|
|
const remaining = this.getRemainingTurns()
|
|
return remaining > 0 && remaining <= 3
|
|
},
|
|
|
|
async askQuestion() {
|
|
const question = this.currentQuestion.trim()
|
|
if (!question || this.isLoading || this.isThreadLimitReached()) {
|
|
return
|
|
}
|
|
|
|
// Add user message to UI
|
|
this.messages.push({
|
|
role: 'user',
|
|
content: question
|
|
})
|
|
|
|
this.currentQuestion = ''
|
|
// Reset textarea height
|
|
this.$nextTick(() => {
|
|
if (this.$refs.input) {
|
|
this.$refs.input.style.height = 'auto'
|
|
}
|
|
})
|
|
this.isLoading = true
|
|
this.error = null
|
|
|
|
// Add placeholder for assistant response
|
|
const responseIndex = this.messages.length
|
|
this.messages.push({
|
|
role: 'assistant',
|
|
content: '',
|
|
isStreaming: true,
|
|
questionAnswerId: null,
|
|
feedback: null,
|
|
copied: false
|
|
})
|
|
|
|
this.$nextTick(() => {
|
|
this.$refs.messagesContainer?.scrollTo({
|
|
top: this.$refs.messagesContainer.scrollHeight,
|
|
behavior: 'smooth'
|
|
})
|
|
})
|
|
|
|
try {
|
|
await this.streamGordonResponse(responseIndex)
|
|
} catch (err) {
|
|
// Only set error if messages weren't cleared
|
|
if (this.messages.length > 0) {
|
|
if (err.message === 'RATE_LIMIT_EXCEEDED') {
|
|
this.error = 'You\'ve exceeded your question quota for the day. Please come back tomorrow.'
|
|
} else {
|
|
this.error = 'Failed to get response. Please try again.'
|
|
}
|
|
}
|
|
console.error('Gordon API error:', err)
|
|
// Only try to remove message if it still exists
|
|
if (this.messages[responseIndex]) {
|
|
this.messages.splice(responseIndex, 1)
|
|
}
|
|
} finally {
|
|
this.isLoading = false
|
|
}
|
|
},
|
|
|
|
getSessionId() {
|
|
let sessionId = sessionStorage.getItem('gordon-session-id')
|
|
if (!sessionId) {
|
|
sessionId = `docs-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
sessionStorage.setItem('gordon-session-id', sessionId)
|
|
}
|
|
return sessionId
|
|
},
|
|
|
|
async streamGordonResponse(responseIndex) {
|
|
|
|
// Build API request from messages, excluding the streaming placeholder
|
|
// The placeholder is at responseIndex, so we take everything before it
|
|
const conversationMessages = this.messages.slice(0, responseIndex).map((msg, i) => {
|
|
const message = {
|
|
role: msg.role,
|
|
content: msg.content
|
|
}
|
|
|
|
// Add copilot_references to the last message (most recent user question)
|
|
if (i === responseIndex - 1) {
|
|
message.copilot_references = [
|
|
{
|
|
data: {
|
|
origin: 'docs-website',
|
|
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 }}
|
|
})
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
return message
|
|
})
|
|
|
|
const isNewConversation = !this.threadId
|
|
const payload = {
|
|
messages: conversationMessages,
|
|
...(this.threadId && { thread_uuid: this.threadId })
|
|
}
|
|
|
|
const response = await fetch(window.GORDON_BASE_URL + '/public/ask', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 429) {
|
|
throw new Error('RATE_LIMIT_EXCEEDED')
|
|
}
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
}
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
const chunk = decoder.decode(value, { stream: true })
|
|
const lines = chunk.split('\n')
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim() || !line.startsWith('data: ')) continue
|
|
|
|
const data = line.slice(6)
|
|
if (data === '[DONE]') continue
|
|
|
|
try {
|
|
const parsed = JSON.parse(data)
|
|
|
|
// Capture thread_id for new conversations
|
|
if (parsed.thread_id) {
|
|
if (isNewConversation) {
|
|
this.threadId = parsed.thread_id // $persist auto-saves to sessionStorage
|
|
} else if (parsed.thread_id !== this.threadId) {
|
|
console.warn('Backend returned unexpected thread_id:', parsed.thread_id)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Capture question_answer_id for feedback
|
|
if (parsed.question_answer_id) {
|
|
this.messages[responseIndex].questionAnswerId = parsed.question_answer_id
|
|
continue
|
|
}
|
|
|
|
if (parsed.choices && parsed.choices[0]?.delta?.content) {
|
|
const content = parsed.choices[0].delta.content
|
|
this.messages[responseIndex].content += content
|
|
|
|
this.$nextTick(() => {
|
|
const container = this.$refs.messagesContainer
|
|
if (container) {
|
|
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100
|
|
if (isNearBottom) {
|
|
container.scrollTop = container.scrollHeight
|
|
}
|
|
}
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to parse SSE data:', e)
|
|
}
|
|
}
|
|
}
|
|
|
|
this.messages[responseIndex].isStreaming = false
|
|
},
|
|
|
|
clearChat() {
|
|
if (confirm('Clear all messages?')) {
|
|
this.messages = [] // $persist auto-saves empty array
|
|
this.threadId = null // $persist auto-removes from sessionStorage
|
|
this.error = null
|
|
}
|
|
},
|
|
|
|
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) {
|
|
const message = this.messages[messageIndex]
|
|
|
|
if (message.feedback === feedbackType) {
|
|
return
|
|
}
|
|
|
|
// Clear any previous error
|
|
message.feedbackError = null
|
|
|
|
if (!message.questionAnswerId) {
|
|
message.feedbackError = 'Unable to submit feedback'
|
|
console.error('No question_answer_id available for feedback')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(window.GORDON_BASE_URL + '/feedback', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: message.questionAnswerId,
|
|
feedback: feedbackType
|
|
})
|
|
})
|
|
|
|
if (response.ok) {
|
|
message.feedback = feedbackType // $persist auto-saves
|
|
} else {
|
|
message.feedbackError = 'Unable to submit feedback'
|
|
console.error('Failed to submit feedback:', response.status, response.statusText)
|
|
}
|
|
} catch (err) {
|
|
message.feedbackError = 'Unable to submit feedback'
|
|
console.error('Error submitting feedback:', err)
|
|
}
|
|
},
|
|
|
|
async copyAnswer(messageIndex) {
|
|
const message = this.messages[messageIndex]
|
|
if (!message.content) return
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(await this.getCopyText(message.content))
|
|
message.copied = true
|
|
this.$nextTick(() => {
|
|
setTimeout(() => {
|
|
message.copied = false
|
|
}, 2000)
|
|
})
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err)
|
|
}
|
|
}
|
|
}"
|
|
x-cloak
|
|
@keydown.escape.window="$store.gordon.close()"
|
|
>
|
|
<!-- Overlay backdrop -->
|
|
<div
|
|
x-show="$store.gordon.isOpen"
|
|
x-transition.opacity.duration.300ms
|
|
@click="$store.gordon.close()"
|
|
class="fixed inset-0 z-40 bg-black/50"
|
|
></div>
|
|
|
|
<!-- Chat panel sliding in from right -->
|
|
<div
|
|
id="gordon-chat"
|
|
x-show="$store.gordon.isOpen"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="translate-x-full"
|
|
x-transition:enter-end="translate-x-0"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="translate-x-0"
|
|
x-transition:leave-end="translate-x-full"
|
|
class="fixed top-0 right-0 z-50 flex h-screen w-full flex-col overflow-hidden overscroll-contain rounded-none bg-white shadow-2xl transition-all duration-200 md:w-[min(80ch,90vw)] dark:bg-gray-950"
|
|
>
|
|
<!-- Header -->
|
|
<div
|
|
class="z-10 flex items-center justify-between bg-blue-500 px-6 py-3 shadow-lg/30 dark:shadow-lg/60"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
{{ partialCached "icon" "images/gordon-logo.svg" "images/gordon-logo.svg" }}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<div data-tooltip-wrapper class="relative">
|
|
<button
|
|
@click="clearChat()"
|
|
aria-label="Start a new chat"
|
|
class="cursor-pointer rounded p-2 text-white transition-colors enabled:hover:bg-white/15 disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="messages.length === 0"
|
|
>
|
|
<span class="icon-svg">
|
|
{{ partialCached "icon" "arrow-path" "arrow-path" }}
|
|
</span>
|
|
</button>
|
|
<div
|
|
id="gordon-tooltip"
|
|
data-tooltip-body
|
|
class="absolute top-0 left-0 hidden rounded-sm bg-gray-900 p-2 text-sm whitespace-nowrap text-white"
|
|
role="tooltip"
|
|
x-show="messages.length > 0"
|
|
>
|
|
Start a new chat
|
|
<div
|
|
data-tooltip-arrow
|
|
class="absolute h-2 w-2 rotate-45 bg-gray-900"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
@click="$store.gordon.close()"
|
|
title="Close chat drawer"
|
|
class="cursor-pointer rounded p-2 text-white transition-colors hover:bg-white/15"
|
|
aria-label="Close chat"
|
|
>
|
|
<span class="icon-svg">
|
|
{{ partialCached "icon" "x-mark" "x-mark" }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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; }"
|
|
>
|
|
<!-- Welcome message when empty -->
|
|
<template x-if="messages.length === 0">
|
|
<div
|
|
class="my-auto flex flex-col items-center gap-2 px-2 pt-4 pb-2 text-center"
|
|
>
|
|
<div
|
|
class="mx-auto mb-2"
|
|
style="width: clamp(80px, 35%, 160px)"
|
|
aria-hidden="true"
|
|
>
|
|
{{ partialCached "icon" "images/gordon-robot.svg" "images/gordon-robot.svg" }}
|
|
</div>
|
|
<div class="flex w-full flex-col items-center justify-start gap-3">
|
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
What can I help you with?
|
|
</h3>
|
|
<p class="max-w-sm text-lg text-gray-600 dark:text-gray-400">
|
|
I'm Gordon, your AI assistant for Docker and documentation
|
|
questions.
|
|
</p>
|
|
<div class="w-full text-left">
|
|
<p
|
|
class="text-sm font-bold text-gray-600 uppercase dark:text-gray-400"
|
|
>
|
|
Try asking
|
|
</p>
|
|
</div>
|
|
{{- $gordonSuggestions := slice
|
|
(dict
|
|
"title" "Get started with Docker"
|
|
"question" "Help me get started with Docker. What should I do first?"
|
|
"icon" "rocket-launch")
|
|
(dict
|
|
"title" "Docker Hardened Images"
|
|
"question" "How do Docker Hardened Images work?"
|
|
"icon" "icons/dhi.svg")
|
|
(dict
|
|
"title" "MCP Toolkit"
|
|
"question" "What is MCP Toolkit?"
|
|
"icon" "icons/toolkit.svg")
|
|
(dict
|
|
"title" "Create an org"
|
|
"question" "How do I create an org?"
|
|
"icon" "icons/headset.svg")
|
|
-}}
|
|
<div class="grid w-full grid-cols-2 gap-2">
|
|
{{- range $gordonSuggestions }}
|
|
<button
|
|
@click="currentQuestion = {{ .question | jsonify }}; askQuestion()"
|
|
class="cursor-pointer rounded-xl border border-gray-200 bg-white p-3 transition hover:scale-[1.02] hover:border-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-blue-500"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<span
|
|
class="icon-svg shrink-0 rounded-lg bg-blue-100 p-1 text-blue-500 dark:bg-gray-700 dark:text-blue-400"
|
|
>
|
|
{{ partialCached "icon" .icon .icon }}
|
|
</span>
|
|
<p class="text-sm font-medium">{{ .title }}</p>
|
|
</div>
|
|
</button>
|
|
{{- end }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Messages -->
|
|
<div x-show="messages.length > 0" class="relative flex flex-col gap-4">
|
|
<template x-for="(message, index) in messages" :key="index">
|
|
<div
|
|
:class="message.role === 'user' ? 'flex justify-end' : 'flex justify-start'"
|
|
>
|
|
<div
|
|
:class="message.role === 'user' ? '' : 'w-full'"
|
|
class="flex max-w-full flex-col gap-2"
|
|
>
|
|
<div
|
|
: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">
|
|
<span
|
|
class="inline-block h-2 w-2 animate-bounce rounded-full bg-current"
|
|
style="animation-delay: 0ms"
|
|
></span>
|
|
<span
|
|
class="inline-block h-2 w-2 animate-bounce rounded-full bg-current"
|
|
style="animation-delay: 150ms"
|
|
></span>
|
|
<span
|
|
class="inline-block h-2 w-2 animate-bounce rounded-full bg-current"
|
|
style="animation-delay: 300ms"
|
|
></span>
|
|
</div>
|
|
</template>
|
|
<template x-if="message.content">
|
|
<div x-html="formatMessageContent(message)"></div>
|
|
</template>
|
|
</div>
|
|
<!-- Feedback buttons for assistant messages -->
|
|
<template
|
|
x-if="message.role === 'assistant' && !message.isStreaming"
|
|
>
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex items-center gap-3 text-xs">
|
|
<!-- "Was this helpful?" prompt with gordon icon -->
|
|
<div
|
|
class="flex items-center gap-1.5 text-gray-600 dark:text-gray-400"
|
|
>
|
|
<span class="icon-svg icon-sm">
|
|
{{ partialCached "icon" "gordon" "gordon" }}
|
|
</span>
|
|
<span class="font-medium">Was this helpful?</span>
|
|
</div>
|
|
|
|
<!-- Feedback buttons group -->
|
|
<div class="flex items-center gap-2">
|
|
<!-- Thumbs up - Helpful -->
|
|
<button
|
|
@click="submitFeedback(index, 'positive')"
|
|
:class="message.feedback === 'positive'
|
|
? 'bg-green-100 text-green-700 ring ring-green-500 dark:bg-green-900/50 dark:text-green-400 dark:ring-green-600'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-green-50 hover:text-green-600 hover:ring hover:ring-green dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-green-900/30 dark:hover:text-green-400 dark:hover:ring-green'"
|
|
class="flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 transition-all duration-200"
|
|
title="Helpful"
|
|
>
|
|
<span class="icon-svg icon-sm transition-transform">
|
|
{{ partialCached "icon" "hand-thumb-up" "hand-thumb-up" }}
|
|
</span>
|
|
<span class="hidden font-medium sm:inline"
|
|
>Helpful</span
|
|
>
|
|
</button>
|
|
|
|
<!-- Thumbs down - Not quite -->
|
|
<button
|
|
@click="submitFeedback(index, 'negative')"
|
|
:class="message.feedback === 'negative'
|
|
? 'bg-red-100 text-red-700 ring ring-red-500 dark:bg-red-900/50 dark:text-red-400 dark:ring-red-600'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-red-50 hover:text-red-600 hover:ring hover:ring-red dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-red-900/30 dark:hover:text-red-400 dark:hover:ring-red'"
|
|
class="flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 transition-all duration-200"
|
|
title="Not quite"
|
|
>
|
|
<span class="icon-svg icon-sm transition-transform">
|
|
{{ partialCached "icon" "hand-thumb-down" "hand-thumb-down" }}
|
|
</span>
|
|
<span class="hidden font-medium sm:inline"
|
|
>Not quite</span
|
|
>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Separator -->
|
|
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
|
|
|
|
<!-- Copy button -->
|
|
<button
|
|
@click="copyAnswer(index)"
|
|
class="hover:ring-blue flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-gray-600 transition-all duration-200 hover:bg-blue-50 hover:text-blue-700 hover:ring dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-blue-900/30 dark:hover:text-blue-300 dark:hover:ring-blue-400"
|
|
:title="message.copied ? 'Copied!' : 'Copy answer'"
|
|
>
|
|
<span
|
|
x-show="message.copied !== true"
|
|
class="icon-svg icon-sm"
|
|
>
|
|
{{ partialCached "icon" "document-duplicate" "document-duplicate" }}
|
|
</span>
|
|
<span
|
|
x-show="message.copied === true"
|
|
class="icon-svg icon-sm"
|
|
>
|
|
{{ partialCached "icon" "check-circle" "check-circle" }}
|
|
</span>
|
|
<span class="hidden font-medium sm:inline">Copy</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Error message -->
|
|
<template x-if="message.feedbackError">
|
|
<div
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 translate-y-1"
|
|
x-transition:enter-end="opacity-100 translate-y-0"
|
|
class="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400"
|
|
>
|
|
<span x-text="message.feedbackError"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Error message -->
|
|
<template x-if="error">
|
|
<div
|
|
class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
|
|
>
|
|
<p class="text-sm" x-text="error"></p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Countdown warning when approaching limit -->
|
|
<template x-if="shouldShowCountdown()">
|
|
<div
|
|
class="rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400"
|
|
>
|
|
<p class="text-sm">
|
|
<span x-text="getRemainingTurns()"></span>
|
|
<span
|
|
x-text="getRemainingTurns() === 1 ? 'question' : 'questions'"
|
|
></span>
|
|
remaining in this thread.
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Thread limit warning -->
|
|
<template x-if="isThreadLimitReached()">
|
|
<div
|
|
class="rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
|
>
|
|
<p class="mb-3 text-sm">
|
|
You've reached the maximum of
|
|
<span x-text="maxTurnsPerThread"></span> questions per thread. For
|
|
better answer quality, start a new thread.
|
|
</p>
|
|
<button
|
|
@click="clearChat()"
|
|
class="cursor-pointer rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
|
>
|
|
Start a new thread
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Input area -->
|
|
<form @submit.prevent="askQuestion()" class="space-y-2 p-4 pt-0">
|
|
<div class="relative w-full">
|
|
<div class="relative flex-1 self-stretch">
|
|
<textarea
|
|
id="question"
|
|
x-ref="input"
|
|
x-model="currentQuestion"
|
|
@input="$el.style.height = 'auto'; $el.style.height = Math.min(160, $el.scrollHeight) + 'px'"
|
|
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); askQuestion() }"
|
|
placeholder="Ask a question about Docker..."
|
|
rows="3"
|
|
:disabled="isLoading || isThreadLimitReached()"
|
|
class="block min-h-[3rem] w-full resize-none rounded-lg border border-gray-300 bg-white p-3 leading-normal focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none enabled:hover:border-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-white dark:focus:border-blue-400"
|
|
></textarea>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
:disabled="!currentQuestion.trim() || isLoading || isThreadLimitReached()"
|
|
:title="isLoading ? 'Sending...' : (isThreadLimitReached() ? 'Thread limit reached. Start a new thread.' : 'Send question')"
|
|
class="absolute top-2 right-2 cursor-pointer rounded p-2 text-blue-500 transition-colors enabled:hover:bg-blue-500/15 disabled:cursor-not-allowed disabled:text-gray-500"
|
|
>
|
|
<template x-if="!isLoading">
|
|
<span class="icon-svg">
|
|
{{ partialCached "icon" "paper-airplane" "paper-airplane" }}
|
|
</span>
|
|
</template>
|
|
<template x-if="isLoading">
|
|
<span class="icon-svg animate-spin">
|
|
{{ partialCached "icon" "arrow-path" "arrow-path" }}
|
|
</span>
|
|
</template>
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="flex items-center justify-between"
|
|
x-data="{ showTooltip: false }"
|
|
>
|
|
<div class="relative">
|
|
<!-- Tooltip -->
|
|
<div
|
|
x-show="showTooltip"
|
|
x-transition:enter="transition ease-out duration-100"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-75"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
class="absolute bottom-full left-0 mb-2 w-56 rounded-lg bg-gray-900 p-2.5 text-xs text-white shadow-lg dark:bg-gray-700"
|
|
style="display: none;"
|
|
>
|
|
<div class="relative">
|
|
<p>
|
|
When enabled, Gordon considers the current page you're viewing
|
|
to provide more relevant answers.
|
|
</p>
|
|
<!-- Arrow -->
|
|
<div
|
|
class="absolute -bottom-3 left-4 h-2 w-2 rotate-45 bg-gray-900 dark:bg-gray-700"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<a
|
|
href="https://github.com/docker/docs/issues/23966"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-xs text-gray-500 underline hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
|
>
|
|
Share feedback
|
|
</a>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Disclaimer -->
|
|
<div
|
|
class="bg-blue-50 px-4 py-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
>
|
|
Answers are generated based on the documentation.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Styles for Gordon chat -->
|
|
<style>
|
|
/* Code block styles */
|
|
#gordon-chat pre {
|
|
background: #0d1117;
|
|
border-radius: 0.25rem;
|
|
padding: 0;
|
|
margin: 0.5rem 0;
|
|
overflow-x: auto;
|
|
white-space: pre;
|
|
}
|
|
|
|
#gordon-chat pre code {
|
|
background: #0d1117;
|
|
color: #c9d1d9;
|
|
padding: 1rem;
|
|
display: block;
|
|
font-family: "Roboto Mono", monospace;
|
|
font-size: 0.875rem;
|
|
line-height: 1.5;
|
|
white-space: pre;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
#gordon-chat pre code * {
|
|
white-space: pre;
|
|
}
|
|
|
|
/* Inline code styling (not in pre blocks) */
|
|
#gordon-chat .prose code.not-prose {
|
|
background-color: rgb(229 231 235);
|
|
color: rgb(17 24 39);
|
|
padding: 0.2em 0.4em;
|
|
border-radius: 0.25rem;
|
|
font-family: "Roboto Mono", monospace;
|
|
font-size: 0.875em;
|
|
}
|
|
|
|
.dark #gordon-chat .prose code.not-prose {
|
|
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>
|