Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion docs/.vuepress/components/Chat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
<div class="iframe-container" ref="iframeContainer">
<iframe
:src="iframeUrl"
:key="iframeUrl"
class="chat-iframe"
frameborder="0"
allow="clipboard-read; clipboard-write; fullscreen"
Expand All @@ -79,15 +80,23 @@
</template>

<script>
import { chatState } from "../composables/useChat";

const CHAT_BASE_URL = "https://chatbot.cloudlinux.com/docs/tuxcare";
const CHAT_ORIGIN = "https://chatbot.cloudlinux.com";

export default {
data() {
return {
showChat: false,
isLoading: true,
iframeUrl: "https://chatbot.cloudlinux.com/docs/tuxcare",
iframeUrl: CHAT_BASE_URL,
windowWidth: 0, // Changed from window.innerWidth to avoid SSR error
showTooltip: true,
tooltipDismissDuration: 2 * 60 * 60 * 1000, // 2 hours in milliseconds
// Held until the iframe finishes loading, then sent via postMessage as
// a backup path in case the chatbot doesn't honor `?prompt=`.
pendingPrompt: null,
};
},
computed: {
Expand All @@ -102,9 +111,16 @@ export default {
window.addEventListener("resize", this.handleResize);
this.handleResize(); // Set initial windowWidth on client-side
this.updateTooltipVisibility();
// Listen for "Ask AI" clicks on code blocks — bumping chatState.trigger
// is how those buttons request the chat to open with a prefilled prompt.
this.unwatchTrigger = this.$watch(
() => chatState.trigger,
() => this.openWithPrompt(chatState.prompt)
);
},
beforeUnmount() {
window.removeEventListener("resize", this.handleResize);
if (this.unwatchTrigger) this.unwatchTrigger();
},
methods: {
toggleChat() {
Expand All @@ -118,6 +134,32 @@ export default {
},
onIframeLoad() {
this.isLoading = false;
if (this.pendingPrompt) {
const iframe = this.$refs.iframeContainer?.querySelector("iframe");
if (iframe?.contentWindow) {
try {
iframe.contentWindow.postMessage(
{ type: "prefill", prompt: this.pendingPrompt },
CHAT_ORIGIN
);
} catch (_) {
/* targetOrigin mismatch or iframe not ready — ignore */
}
}
this.pendingPrompt = null;
}
},
openWithPrompt(prompt) {
if (!prompt) return;
// Forces the iframe to (re)load with the prompt seeded as a query param.
// The :key="iframeUrl" binding on the iframe ensures Vue re-mounts it
// even when only the query changes.
this.iframeUrl =
CHAT_BASE_URL + "?prompt=" + encodeURIComponent(prompt);
this.pendingPrompt = prompt;
this.isLoading = true;
if (!this.showChat) this.showChat = true;
this.dismissTooltip();
},
dismissTooltip() {
const currentTime = new Date().getTime();
Expand Down
36 changes: 36 additions & 0 deletions docs/.vuepress/components/CodeTabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
</div>

<div class="tab-content code-block-wrapper">
<button class="ask-ai-button" @click="askAi" aria-label="Ask AI about this code" title="Ask AI about this code">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M11.5 2.5a.5.5 0 0 1 .96 0l1.36 4.06a3 3 0 0 0 1.92 1.92l4.06 1.36a.5.5 0 0 1 0 .96l-4.06 1.36a3 3 0 0 0-1.92 1.92l-1.36 4.06a.5.5 0 0 1-.96 0l-1.36-4.06a3 3 0 0 0-1.92-1.92L4.16 11.3a.5.5 0 0 1 0-.96l4.06-1.36a3 3 0 0 0 1.92-1.92zM18 16a.4.4 0 0 1 .76 0l.42 1.27a1.5 1.5 0 0 0 .95.95l1.27.42a.4.4 0 0 1 0 .76l-1.27.42a1.5 1.5 0 0 0-.95.95l-.42 1.27a.4.4 0 0 1-.76 0l-.42-1.27a1.5 1.5 0 0 0-.95-.95l-1.27-.42a.4.4 0 0 1 0-.76l1.27-.42a1.5 1.5 0 0 0 .95-.95z"/>
</svg>
</button>

<button class="copy-button" @click="copyCode" aria-label="Copy code">
<img v-if="!copied" src="/images/copy.webp" width="16" height="16" alt="Copy" />
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
Expand All @@ -21,6 +27,8 @@
</template>

<script>
import { openChatWithPrompt, buildCodePrompt } from '../composables/useChat'

export default {
name: 'CodeTabs',
props: {
Expand Down Expand Up @@ -57,6 +65,10 @@ export default {
this.copied = true
setTimeout(() => (this.copied = false), 2000)
})
},
askAi() {
const text = this.tabs[this.activeTab].content
openChatWithPrompt(buildCodePrompt(text))
}
}
}
Expand Down Expand Up @@ -190,6 +202,30 @@ code {
opacity: 1;
}

.ask-ai-button {
position: absolute;
top: 0.5rem;
right: 2.5rem;
background: none;
border: none;
cursor: pointer;
padding: 0.2rem;
z-index: 10;
}

.ask-ai-button svg {
width: 20px;
height: 20px;
fill: #ccc;
opacity: 0.6;
transition: fill 0.2s, opacity 0.2s;
}

.ask-ai-button:hover svg {
fill: #1994f9;
opacity: 1;
}

.language-bash {
font-size: 0.85em;
padding: 0;
Expand Down
17 changes: 16 additions & 1 deletion docs/.vuepress/components/GlobalCopyCode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
<script setup>
import { nextTick, onBeforeUnmount, onMounted, watch } from "vue";
import { useRoute } from "vue-router";
import { openChatWithPrompt, buildCodePrompt } from "../composables/useChat";

const route = useRoute();
let observer = null;

const COPY_SVG = `<img src="/images/copy.webp" width="16" height="16" alt="Copy" />`;
const CHECK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>`;
const ASK_AI_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11.5 2.5a.5.5 0 0 1 .96 0l1.36 4.06a3 3 0 0 0 1.92 1.92l4.06 1.36a.5.5 0 0 1 0 .96l-4.06 1.36a3 3 0 0 0-1.92 1.92l-1.36 4.06a.5.5 0 0 1-.96 0l-1.36-4.06a3 3 0 0 0-1.92-1.92L4.16 11.3a.5.5 0 0 1 0-.96l4.06-1.36a3 3 0 0 0 1.92-1.92zM18 16a.4.4 0 0 1 .76 0l.42 1.27a1.5 1.5 0 0 0 .95.95l1.27.42a.4.4 0 0 1 0 .76l-1.27.42a1.5 1.5 0 0 0-.95.95l-.42 1.27a.4.4 0 0 1-.76 0l-.42-1.27a1.5 1.5 0 0 0-.95-.95l-1.27-.42a.4.4 0 0 1 0-.76l1.27-.42a1.5 1.5 0 0 0 .95-.95z"/></svg>`;

function injectCopyButtons() {
const codeBlocks = document.querySelectorAll(
Expand All @@ -22,6 +24,20 @@ function injectCopyButtons() {
if (!wrapper || wrapper.querySelector(".global-copy-btn")) return;
if (wrapper.closest(".code-block-wrapper")) return;

wrapper.style.position = "relative";

const askBtn = document.createElement("button");
askBtn.className = "global-ask-ai-btn";
askBtn.setAttribute("aria-label", "Ask AI about this code");
askBtn.setAttribute("title", "Ask AI about this code");
askBtn.innerHTML = ASK_AI_SVG;
askBtn.addEventListener("click", () => {
const code = pre.querySelector("code");
if (!code) return;
openChatWithPrompt(buildCodePrompt(code.innerText));
});
wrapper.appendChild(askBtn);

const btn = document.createElement("button");
btn.className = "global-copy-btn";
btn.setAttribute("aria-label", "Copy code");
Expand All @@ -37,7 +53,6 @@ function injectCopyButtons() {
}, 2000);
});

wrapper.style.position = "relative";
wrapper.appendChild(btn);

if (!wrapper.querySelector(".code-fade-mask")) {
Expand Down
33 changes: 33 additions & 0 deletions docs/.vuepress/composables/useChat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { reactive } from 'vue'

// Shared reactive state used to coordinate "open the chatbot with a
// prefilled prompt" between code-block buttons and Chat.vue.
//
// The chatbot at https://chatbot.cloudlinux.com/docs/tuxcare is expected to
// read a `?prompt=` query parameter on load and seed the conversation with it.
// As a belt-and-suspenders backup we also `postMessage` the prompt to the
// iframe after it loads, in case the chatbot has a postMessage listener.
// If neither path is wired up on the chatbot side the iframe still opens as
// today — the prefill is simply ignored, no regression.
export const chatState = reactive({
prompt: '',
// Bumped each time a code-block button asks Chat.vue to open. Watching
// this counter lets the same prompt be re-fired without value-change races.
trigger: 0,
})

export function openChatWithPrompt(prompt) {
chatState.prompt = prompt || ''
chatState.trigger += 1
}

// Build the prompt text sent to the chatbot when a reader clicks "Ask AI"
// on a code block. Keep it short — long prompts hit URL length limits.
export function buildCodePrompt(snippet) {
const safeSnippet = (snippet || '').slice(0, 4000)
let url = ''
let title = 'this page'
if (typeof window !== 'undefined') url = window.location.href
if (typeof document !== 'undefined' && document.title) title = document.title
return `I'm reading the TuxCare docs page "${title}" (${url}). Please explain this code:\n\n\`\`\`\n${safeSnippet}\n\`\`\``
}
23 changes: 23 additions & 0 deletions docs/.vuepress/styles/theme.styl
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,29 @@ badge[type="danger"]
&:hover img
opacity 1

// "Ask AI" button injected to the left of the copy button. Click sends the
// snippet + page URL/title to Chat.vue via the shared `chatState` store.
.global-ask-ai-btn
position absolute
top 0.5rem
right 2.5rem
background none
border none
cursor pointer
padding 0.2rem
z-index 10
line-height 0
svg
fill #ccc
width 20px
height 20px
opacity 0.6
transition fill 0.3s, opacity 0.3s

&:hover svg
fill #1994f9
opacity 1

// Reserve a gutter on the right of long code lines so they don't run under
// the copy button, and fade the trailing edge into the block background so
// truncated content is visually obvious. Users can scroll horizontally to read it.
Expand Down