Merge pull request #23709 from dvdksn/gordon

site: use gordon for ask ai
This commit is contained in:
David Karlsson
2026-01-13 15:42:43 +01:00
committed by GitHub
12 changed files with 839 additions and 57 deletions

View File

@@ -41,5 +41,6 @@
@import "syntax-dark.css";
@import "syntax-light.css";
@import "components.css";
@import "highlight-github-dark.css";
@variant dark (&:where(.dark, .dark *));

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -2,12 +2,106 @@ import Alpine from 'alpinejs'
import collapse from '@alpinejs/collapse'
import persist from '@alpinejs/persist'
import focus from '@alpinejs/focus'
import { marked } from 'marked'
import hljs from 'highlight.js/lib/core'
// Import languages relevant to Docker docs
import bash from 'highlight.js/lib/languages/bash'
import dockerfile from 'highlight.js/lib/languages/dockerfile'
import yaml from 'highlight.js/lib/languages/yaml'
import json from 'highlight.js/lib/languages/json'
import javascript from 'highlight.js/lib/languages/javascript'
import python from 'highlight.js/lib/languages/python'
import go from 'highlight.js/lib/languages/go'
window.Alpine = Alpine
Alpine.plugin(collapse)
Alpine.plugin(persist)
Alpine.plugin(focus)
// Register highlight.js languages
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('sh', bash)
hljs.registerLanguage('shell', bash)
hljs.registerLanguage('console', bash)
hljs.registerLanguage('dockerfile', dockerfile)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('yml', yaml)
hljs.registerLanguage('json', json)
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('js', javascript)
hljs.registerLanguage('python', python)
hljs.registerLanguage('py', python)
hljs.registerLanguage('go', go)
hljs.registerLanguage('golang', go)
// Configure marked to escape HTML in text tokens only (not code blocks)
marked.use({
walkTokens(token) {
// Escape HTML in text and HTML tokens, preserve code blocks
if (token.type === 'text' || token.type === 'html') {
const text = token.text || token.raw
const escaped = text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
if (token.text) token.text = escaped
if (token.raw) token.raw = escaped
}
}
})
// Add $markdown magic for rendering markdown with syntax highlighting
Alpine.magic('markdown', () => {
return (content) => {
if (!content) return ''
const html = marked(content)
// Parse and highlight code blocks
const div = document.createElement('div')
div.innerHTML = html
// Handle code blocks (pre > code)
div.querySelectorAll('pre').forEach((pre) => {
// Add not-prose to prevent Tailwind Typography styling
pre.classList.add('not-prose')
const code = pre.querySelector('code')
if (code) {
// Preserve the original text with newlines
const codeText = code.textContent
// Clear and set as plain text first to preserve structure
code.textContent = codeText
// Now apply highlight.js which will work with the text nodes
hljs.highlightElement(code)
}
})
// Handle inline code elements (not in pre blocks)
div.querySelectorAll('code:not(pre code)').forEach((code) => {
code.classList.add('not-prose')
})
return div.innerHTML
}
})
// Stores
Alpine.store("showSidebar", false)
Alpine.store('gordon', {
isOpen: false,
query: '',
toggle() {
this.isOpen = !this.isOpen
},
open(query) {
this.isOpen = true
if (query) this.query = query
},
close() {
this.isOpen = false
}
})
Alpine.start()

View File

@@ -271,6 +271,9 @@ module:
# Mount the icon files to assets so we can access them with resources.Get
- source: node_modules/@material-symbols/svg-400/rounded
target: assets/icons
# Mount highlight.js theme for Gordon chat syntax highlighting
- source: node_modules/highlight.js/styles/github-dark.css
target: assets/css/highlight-github-dark.css
imports:

View File

@@ -8,6 +8,7 @@
class="dark:bg-navbar-bg-dark bg-navbar-bg flex flex-col items-center text-base text-black dark:text-white"
>
{{ partial "header.html" . }}
{{ partial "gordon-chat.html" . }}
<main class="relative flex w-full max-w-[1920px]">
<!-- Sidebar -->
<div

View File

@@ -15,6 +15,7 @@
</svg>
</div>
{{ partial "header.html" . }}
{{ partial "gordon-chat.html" . }}
<main class="flex w-full flex-col items-stretch gap-20 self-center pt-20">
<div class="grid w-full grid-cols-1 items-center gap-20 px-4 lg:grid-cols-2 xl:w-[1200px] self-center">
<div class="bg-pattern-blue relative rounded-sm drop-shadow z-10"

View File

@@ -0,0 +1,706 @@
<!-- 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 }};
</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
this.$nextTick(() => {
this.$refs.input?.focus()
this.$refs.input?.select()
})
// Clear the store query after using it
this.$store.gordon.query = ''
}
})
},
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',
...(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(content) {
return this.$markdown(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(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 rounded-lg bg-white shadow-2xl transition-all duration-200 md:top-2 md:right-2 md:h-[calc(100vh-1rem)] md:w-[min(80ch,90vw)] dark:bg-gray-900"
>
<!-- Header -->
<div
class="flex items-center justify-between rounded-t-lg border-b border-gray-200 bg-blue-600 px-6 py-3 dark:border-gray-700"
>
<div class="flex items-center gap-3">
{{ partial "utils/svg.html" "images/ask-ai-logo.svg" }}
</div>
<div class="flex items-center gap-2">
<button
@click="clearChat()"
title="Clear chat"
class="cursor-pointer rounded p-2 text-white/80 transition-colors hover:bg-blue-500 hover:text-white"
:disabled="messages.length === 0"
:class="{ 'opacity-50 cursor-not-allowed': messages.length === 0 }"
>
<span class="icon-svg">
{{ partialCached "icon" "refresh" "refresh" }}
</span>
</button>
<button
@click="$store.gordon.close()"
class="cursor-pointer rounded p-2 text-white/80 transition-colors hover:bg-blue-500 hover:text-white"
title="Close chat"
aria-label="Close chat"
>
<span class="icon-svg">
{{ partialCached "icon" "close" "close" }}
</span>
</button>
</div>
</div>
<!-- Messages container -->
<div
x-ref="messagesContainer"
class="flex-1 space-y-4 p-6"
:class="{ 'overflow-y-auto': messages.length > 0 }"
>
<!-- Welcome message when empty -->
<template x-if="messages.length === 0">
<div
class="flex h-full flex-col items-center justify-center text-center"
>
<div class="mb-4 rounded-full bg-blue-100 p-4 dark:bg-blue-900">
<span class="icon-svg text-blue-600 dark:text-blue-400">
{{ partialCached "icon" "icons/sparkle.svg" "icons/sparkle.svg" }}
</span>
</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
Ask me about Docker
</h3>
<p class="max-w-sm text-gray-600 dark:text-gray-400">
Get instant answers to your Docker questions. I can help with
commands, concepts, troubleshooting, and best practices.
</p>
<div class="mt-8 flex flex-col items-center gap-3 text-sm">
<p class="mb-1 text-gray-500 dark:text-gray-400">Try asking:</p>
<button
@click="currentQuestion = 'How do Docker Hardened Images work?'; askQuestion()"
class="cursor-pointer rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-gray-700 transition-opacity hover:opacity-70 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
How do Docker Hardened Images work?
</button>
<button
@click="currentQuestion = 'What is MCP Toolkit?'; askQuestion()"
class="cursor-pointer rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-gray-700 transition-opacity hover:opacity-70 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
What is MCP Toolkit?
</button>
<button
@click="currentQuestion = 'How do I create an org?'; askQuestion()"
class="cursor-pointer rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-gray-700 transition-opacity hover:opacity-70 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
How do I create an org?
</button>
</div>
</div>
</template>
<!-- Messages -->
<template x-for="(message, index) in messages" :key="index">
<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' ? '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"
>
<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.content)"></div>
</template>
</div>
<!-- Feedback buttons for assistant messages -->
<template
x-if="message.role === 'assistant' && !message.isStreaming"
>
<div class="flex items-center gap-2 text-xs">
<div class="flex items-center gap-1">
<!-- Copy button -->
<button
@click="copyAnswer(index)"
class="cursor-pointer rounded bg-gray-100 p-1.5 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
:title="message.copied ? 'Copied!' : 'Copy answer'"
>
<span
x-show="message.copied !== true"
class="icon-svg icon-sm"
>
{{ partialCached "icon" "content_copy" "content_copy" }}
</span>
<span
x-show="message.copied === true"
class="icon-svg icon-sm"
>
{{ partialCached "icon" "check_circle" "check_circle" }}
</span>
</button>
<!-- Thumbs up -->
<button
@click="submitFeedback(index, 'positive')"
:class="message.feedback === 'positive'
? 'bg-green-200 text-green-700 dark:bg-green-900/50 dark:text-green-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'"
class="cursor-pointer rounded p-1.5"
title="Helpful"
>
<span class="icon-svg icon-sm">
{{ partialCached "icon" "thumb_up" "thumb_up" }}
</span>
</button>
<!-- Thumbs down -->
<button
@click="submitFeedback(index, 'negative')"
:class="message.feedback === 'negative'
? 'bg-red-200 text-red-700 dark:bg-red-900/50 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'"
class="cursor-pointer rounded p-1.5"
title="Not helpful"
>
<span class="icon-svg icon-sm">
{{ partialCached "icon" "thumb_down" "thumb_down" }}
</span>
</button>
</div>
<template x-if="message.feedback">
<span
:class="message.feedback === 'positive' ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'"
>
Thanks for your feedback!
</span>
</template>
<template x-if="message.feedbackError">
<span
class="text-red-600 dark:text-red-400"
x-text="message.feedbackError"
></span>
</template>
</div>
</template>
</div>
</div>
</template>
<!-- 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 -->
<div class="border-t border-gray-200 p-4 dark:border-gray-700">
<form @submit.prevent="askQuestion()" class="space-y-2">
<div class="flex items-center gap-2">
<div class="relative flex-1 self-stretch">
<textarea
x-ref="input"
x-model="currentQuestion"
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); askQuestion() }"
placeholder="Ask a question about Docker..."
rows="1"
:disabled="isLoading || isThreadLimitReached()"
class="block min-h-[3rem] w-full resize-none rounded-lg border border-gray-300 p-3 leading-normal focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-blue-400"
:class="{ 'cursor-not-allowed': isThreadLimitReached() }"
></textarea>
</div>
<button
type="submit"
:disabled="!currentQuestion.trim() || isLoading || isThreadLimitReached()"
:title="isLoading ? 'Sending...' : (isThreadLimitReached() ? 'Thread limit reached. Start a new thread.' : 'Send question')"
class="cursor-pointer rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<template x-if="!isLoading">
<span class="icon-svg">
{{ partialCached "icon" "send" "send" }}
</span>
</template>
<template x-if="isLoading">
<span class="icon-svg animate-spin">
{{ partialCached "icon" "progress_activity" "progress_activity" }}
</span>
</template>
</button>
</div>
<div class="flex items-center justify-between" x-data="{ showTooltip: false }">
<div class="relative">
<button
@click="includePageContext = !includePageContext"
@mouseenter="showTooltip = true"
@mouseleave="showTooltip = false"
type="button"
:class="includePageContext
? 'bg-blue-100 text-blue-600 dark:bg-blue-800 dark:text-blue-200'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
class="cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition-colors hover:opacity-80"
>
<span class="flex items-center gap-1">
<template x-if="includePageContext">
<span class="icon-svg icon-xs">
{{ partialCached "icon" "link" "link" }}
</span>
</template>
<template x-if="!includePageContext">
<span class="icon-svg icon-xs">
{{ partialCached "icon" "link_off" "link_off" }}
</span>
</template>
<span>Context</span>
</span>
</button>
<!-- 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>
</div>
<!-- Disclaimer -->
<div
class="rounded-b-lg border-t border-gray-200 bg-blue-50 px-4 py-3 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
>
This is a custom LLM for answering questions about Docker. Answers are
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);
}
</style>

View File

@@ -51,59 +51,6 @@
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
</script>
{{ end }}
{{/* kapa.ai widget */}}
<script
async
src="https://widget.kapa.ai/kapa-widget.bundle.js"
data-button-hide="true"
data-font-family="Roboto Flex,sans-serif"
data-modal-disclaimer-bg-color="#e5f2fc"
data-modal-disclaimer-text-color="#086dd7"
data-modal-disclaimer="This is a custom LLM for answering questions about Docker. Answers are based on the contents of the documentation. Rate the answers to let us know what you think!"
data-kapa-branding-text="powered by [kapa.ai](https://www.kapa.ai) and Docker"
data-modal-header-bg-color="#1d63ed"
data-modal-image-height="25px"
data-modal-image-width="181px"
data-modal-title=""
data-modal-override-open-class="open-kapa-widget"
data-modal-ask-ai-input-placeholder="Ask me a question about Docker…"
data-modal-title-color="#fff"
data-project-color="#1d63ed"
data-project-logo="/assets/images/logo-icon-white.svg"
data-project-name="Docker"
data-user-analytics-fingerprint-enabled="true"
data-bot-protection-mechanism="hcaptcha"
data-website-id="{{ site.Params.kapa.id }}"
data-modal-open-on-command-k="true"
data-modal-border-radius="6px"
></script>
<script>
// Define askAI function to work with search-page-input
window.askAI = function(inputId = "search-page-input") {
const searchInput = document.querySelector("#" + inputId);
const query = searchInput ? searchInput.value.trim() : "";
if (query && window.Kapa) {
window.Kapa.open({
mode: "ai",
query: query,
submit: false
});
} else if (window.Kapa) {
window.Kapa.open({
mode: "ai"
});
}
};
// Add event listener for the Ask AI button
document.addEventListener('click', function(event) {
if (event.target.closest('.open-kapa-widget')) {
event.preventDefault();
window.askAI("search-page-input");
}
});
</script>
{{/* preload Roboto Flex as it's a critical font: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload */}}
<link
href="/assets/fonts/RobotoFlex.woff2"

View File

@@ -48,7 +48,8 @@
<div id="buttons" class="flex min-w-0 items-center justify-end flex-shrink-0">
<div class="flex items-center gap-2">
<button
@click="open = false"
x-data
@click="$store.gordon.toggle()"
class="cursor-pointer flex items-center gap-2 p-2 rounded-lg bg-blue-700 border border-blue-500 text-white transition-colors focus:outline-none focus:ring focus:ring-blue-400 shimmer open-kapa-widget"
>
<span class="icon-svg">

View File

@@ -70,8 +70,11 @@
{{- $emptyState := `
<div>
Start typing to search or try
<button onclick="askAI('search-bar-input')" class="link">Ask AI</button
>.
<button @click="
$store.gordon.open($refs.searchBarInput.value.trim());
$refs.searchBarInput.value = '';
open = false;
" class="link">Ask AI</button>.
</div>
` }} {{- $emptyState | safe.HTML }}
<!-- results -->

23
package-lock.json generated
View File

@@ -17,6 +17,8 @@
"@tailwindcss/cli": "^4.1.6",
"@tailwindcss/typography": "^0.5.15",
"alpinejs": "^3.14.3",
"highlight.js": "^11.11.1",
"marked": "^17.0.0",
"tailwindcss": "^4.1.6"
},
"devDependencies": {
@@ -976,6 +978,15 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -1367,6 +1378,18 @@
"url": "https://github.com/sponsors/DavidAnson"
}
},
"node_modules/marked": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.0.tgz",
"integrity": "sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",

View File

@@ -22,6 +22,8 @@
"@tailwindcss/cli": "^4.1.6",
"@tailwindcss/typography": "^0.5.15",
"alpinejs": "^3.14.3",
"highlight.js": "^11.11.1",
"marked": "^17.0.0",
"tailwindcss": "^4.1.6"
},
"devDependencies": {