mirror of
https://github.com/docker/docs.git
synced 2026-03-27 14:28:47 +07:00
Merge pull request #23709 from dvdksn/gordon
site: use gordon for ask ai
This commit is contained in:
@@ -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 *));
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
706
layouts/partials/gordon-chat.html
Normal file
706
layouts/partials/gordon-chat.html
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user