Feat(nav): in-app docs assistant via GitBook embed

A nav search box (desktop, rotating preset questions) and question icon
(mobile) open the GitBook assistant with the visitor's query as its first
message. The embed script loads on demand, never at page load.

Gated on VITE_DOCS_URL at build time and configs.originalSite at runtime.
Greeting, suggestions and the sidebar action follow the UI locale; theming
follows the page color-scheme. Launcher and panel are pinned bottom-left on
the FAB baseline with a height cap so they never overflow the viewport;
DocsAssistant.vue adds a mobile backdrop and scroll lock, syncing state from
the panel since the embed fires no open/close callbacks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Fable 5 committed 2026-07-28 19:47:48 +08:00
1 parent ac3dc20d31
commit 25b47cf814
10 files changed
+358 -9

No files matched your search

+3
View File
@@ -25,6 +25,9 @@ MAXMIND_AUTO_UPDATE="false"
CAIDA_AUTO_UPDATE="false"
# SITE
VITE_SITE_URL="https://ipcheck.ing"
# DOCS SITE — origin of the GitBook docs site powering the in-app
# assistant and the Help Center link. Empty disables both.
VITE_DOCS_URL=""
# CURL API
VITE_CURL_IPV4_DOMAIN=""
VITE_CURL_IPV6_DOMAIN=""
+5 -3
View File
@@ -5,6 +5,7 @@
<TooltipProvider :delay-duration="150">
<router-view />
<Alert />
<DocsAssistant />
<PWA v-if="offerPwaInstall" />
</TooltipProvider>
</template>
@@ -12,15 +13,16 @@
<script setup>
import { watch, ref, onMounted, defineAsyncComponent } from 'vue';
import { useRoute } from 'vue-router';
import { TooltipProvider } from './components/ui/tooltip';
import Alert from './components/widgets/Toast.vue';
import { TooltipProvider } from '@/components/ui/tooltip';
import Alert from '@/components/widgets/Toast.vue';
import DocsAssistant from '@/components/widgets/DocsAssistant.vue';
import { shouldOfferPwaInstall } from '@/utils/pwa.js';
import { useTheme } from '@/composables/use-theme.js';
// PWA install prompt — async and eligibility-gated: ineligible visits (first
// visit, prompt cap reached, already installed) never load pwa-install or
// trigger its manifest fetch; eligible ones load it at the prompt's 30s mark.
const PWA = defineAsyncComponent(() => import('./components/widgets/PWA.vue'));
const PWA = defineAsyncComponent(() => import('@/components/widgets/PWA.vue'));
const offerPwaInstall = ref(false);
onMounted(() => {
if (shouldOfferPwaInstall()) {
+71 -1
View File
@@ -81,6 +81,39 @@
<!-- Right: Action area (ml-auto push to the right) -->
<div class="ml-auto flex items-center gap-2">
<!-- Docs search: submitting opens the GitBook Assistant embed with the
query as the visitor's first message (see use-docs-assistant.js).
The placeholder rotates through preset questions so visitors see
this is an "ask anything" box, not plain search. -->
<form v-if="showDocsSearch && !isMobile" class="relative" @submit.prevent="submitDocsSearch">
<MessageCircleQuestionMark
class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground z-10" />
<Input v-model="docsQuery" type="text" :aria-label="t('nav.SearchDocs')"
class="h-8 w-44 focus:w-64 transition-[width] duration-300 pl-8 text-sm" autocomplete="off"
autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore data-lpignore="true"
@keydown.enter.prevent="submitDocsSearch" />
<!-- Rotating placeholder rendered as an overlay (native placeholders
can't animate). Hidden as soon as the visitor types. -->
<div v-if="!docsQuery"
class="pointer-events-none absolute inset-y-0 left-8 right-2 flex items-center overflow-hidden">
<Transition enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-3" enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-300 ease-in absolute"
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 -translate-y-3">
<span :key="docsPlaceholder" class="block w-full truncate text-sm text-muted-foreground">
{{ docsPlaceholder }}
</span>
</Transition>
</div>
</form>
<!-- Mobile: same entry as a single icon — opens the assistant without a query. -->
<JnTooltip v-else-if="showDocsSearch" :text="t('nav.SearchDocs')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" :aria-label="t('nav.SearchDocs')"
@click="openDocsAssistant">
<MessageCircleQuestionMark />
</Button>
</JnTooltip>
<!-- Preferences -->
<JnTooltip :text="t('shortcutKeys.Preferences')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences"
@@ -263,8 +296,10 @@ import {
} from '@/components/ui/dropdown-menu';
import {
Award, ChevronDown, HeartHandshake,
LogOut, Menu, Cog,
LogOut, Menu, Cog, MessageCircleQuestionMark,
} from '@lucide/vue';
import { Input } from '@/components/ui/input';
import { useDocsAssistant, isDocsConfigured } from '@/composables/use-docs-assistant';
import { Icon } from '@iconify/vue';
import brandIcon from './svgicons/Brand.vue';
import { SECTION_IDS } from '@/data/sections';
@@ -298,6 +333,41 @@ const advancedTools = computed(() =>
// Mobile: Advanced Tools sub-list expanded by default for discoverability.
const mobileToolsOpen = ref(true);
// Docs search → GitBook Assistant embed (lazy-loaded on first submit).
// Needs a docs site to answer from: configured at build time, and only on the
// canonical deployment — same gate as the original-site-only tools above.
const showDocsSearch = computed(() => isDocsConfigured && configs.value.originalSite);
const { askDocs, docsQuestions } = useDocsAssistant();
const docsQuery = ref('');
const submitDocsSearch = () => {
if (!docsQuery.value.trim()) return;
trackEvent('Nav', 'DocsSearch', 'submit');
askDocs(docsQuery.value);
docsQuery.value = '';
};
const openDocsAssistant = () => {
trackEvent('Nav', 'DocsSearch', 'open');
askDocs('');
};
// Rotating placeholder: cycle through the preset questions so the box reads
// as "ask anything". Rotation is cosmetic — pausing it while the visitor is
// typing avoids distraction; locale switches re-resolve via docsQuestions().
const docsPlaceholderIndex = ref(0);
const docsPlaceholder = computed(() => {
const questions = docsQuestions();
return questions[docsPlaceholderIndex.value % questions.length] || t('nav.SearchDocs');
});
let docsPlaceholderTimer = null;
onMounted(() => {
docsPlaceholderTimer = setInterval(() => {
if (!docsQuery.value) docsPlaceholderIndex.value += 1;
}, 4000);
});
onBeforeUnmount(() => {
if (docsPlaceholderTimer) clearInterval(docsPlaceholderTimer);
});
// GitHub star count for the repo badge. Fetched from our own edge-cached
// endpoint; stays null (badge hides the count) if the request fails.
const githubStars = ref(null);
@@ -0,0 +1,80 @@
<template>
<!-- Touch-only chrome for the GitBook Assistant panel (see
composables/use-docs-assistant.js). The panel opens and closes through
the embed's own launcher; what it lacks on phones is a way to dismiss
by tapping outside, and a scroll lock — without one, swiping a chat too
short to scroll drags the page underneath instead. Desktop keeps page
scrolling, as with our other panels. Placement of the launcher and the
panel lives in style.css next to the embed overrides. -->
<Teleport to="body">
<Transition enter-active-class="transition-opacity duration-200" enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-200" leave-to-class="opacity-0">
<div v-if="isOpen && isMobile" class="fixed inset-0 z-1090 bg-black/40" aria-hidden="true" @click="closeDocs">
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, watch, onBeforeUnmount } from 'vue';
import { useMainStore } from '@/store';
import { useDocsAssistant } from '@/composables/use-docs-assistant';
const PANEL_SELECTOR = '#gitbook-widget-window';
const SYNC_INTERVAL = 300;
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
const { isOpen, isLoaded, closeDocs, setOpen } = useDocsAssistant();
// The embed fires no open/close callbacks and its launcher toggles the panel
// on its own, so read the panel element instead — both directions. Polling
// starts only once the embed has been loaded (first docs search of the
// session) and stops on unmount.
let syncTimer = null;
const panelIsVisible = () => {
const panel = document.querySelector(PANEL_SELECTOR);
if (!panel) return false;
const rect = panel.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && getComputedStyle(panel).visibility !== 'hidden';
};
const startSync = () => {
if (syncTimer) return;
syncTimer = setInterval(() => setOpen(panelIsVisible()), SYNC_INTERVAL);
};
const stopSync = () => {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
}
};
// Mobile-only body scroll lock.
const lockScroll = (locked) => {
document.body.style.overflow = locked ? 'hidden' : '';
document.body.style.overscrollBehavior = locked ? 'none' : '';
};
watch(isLoaded, (loaded) => {
if (loaded) startSync();
}, { immediate: true });
watch([isOpen, isMobile], ([open, mobile]) => {
lockScroll(open && mobile);
}, { immediate: true });
const onKeydown = (event) => {
if (event.key === 'Escape' && isOpen.value) closeDocs();
};
window.addEventListener('keydown', onKeydown);
onBeforeUnmount(() => {
lockScroll(false);
stopSync();
window.removeEventListener('keydown', onKeydown);
});
</script>
+117
View File
@@ -0,0 +1,117 @@
// Lazy loader + controller for the GitBook Docs Assistant embed.
//
// Gated twice: the docs site origin comes from `VITE_DOCS_URL` (build-time —
// empty means the whole feature is absent), and the nav entry points only
// render on the canonical deployment (`configs.originalSite`), since the
// assistant answers from IPCheck.ing's own docs site.
//
// Nothing loads until the visitor actually asks for docs: the embed script
// (served by the docs site) is injected on demand, then the assistant panel
// opens — with the visitor's query posted as their first message when one was
// typed. From then on the embed's own floating launcher (moved to the
// bottom-left in style.css, clear of our FAB stack) toggles the panel.
//
// `isOpen` is observed, not owned: the launcher can open and close the panel
// without telling us, so DocsAssistant.vue watches the panel element and
// reports back through `setOpen`. It drives the touch-only chrome there — a
// tap-outside backdrop and a body scroll lock.
//
// The welcome screen's greeting, suggestions and the sidebar action are
// localized, and re-sent on every open so a language switch takes effect. The
// embed has no language option of its own, so its remaining chrome — input
// placeholder, buttons — stays English; answers follow the language of the
// question. Theming follows the page's CSS `color-scheme` (style.css).
//
// If the script cannot load (blocked / offline), we fall back to opening the
// docs site in a new tab so the action never dead-ends.
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
export const DOCS_URL = (import.meta.env?.VITE_DOCS_URL || '').replace(/\/+$/, '');
export const isDocsConfigured = !!DOCS_URL;
const EMBED_SRC = `${DOCS_URL}/~gitbook/embed/script.js`;
const ASSISTANT_NAME = 'IPChecking Copilot'; // embed caps this at 32 chars
let loadPromise = null;
// Shared across every caller so the nav trigger and the chrome component
// observe the same panel.
const isOpen = ref(false);
const isLoaded = ref(false);
const loadEmbedScript = () => {
if (loadPromise) return loadPromise;
loadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = EMBED_SRC;
script.async = true;
script.onload = () => {
isLoaded.value = true;
resolve();
};
script.onerror = () => {
loadPromise = null; // allow a retry on the next attempt
script.remove();
reject(new Error('GitBook embed script failed to load'));
};
document.head.appendChild(script);
});
return loadPromise;
};
export function useDocsAssistant() {
const { t, tm, rt } = useI18n();
const isOpening = ref(false);
// Localized suggestion questions (shared with the nav placeholder rotation).
const docsQuestions = () => tm('nav.DocsQuestions').map((message) => rt(message));
const openDocsSite = () => window.open(DOCS_URL, '_blank', 'noopener');
// Reported by DocsAssistant.vue from the live panel — the embed fires no
// open/close callbacks, and its launcher can toggle the panel on its own.
const setOpen = (value) => {
isOpen.value = value;
};
const closeDocs = () => {
isOpen.value = false;
if (typeof window.GitBook === 'function') window.GitBook('close');
};
const askDocs = async (query) => {
if (!isDocsConfigured) return;
const question = (query || '').trim();
if (isOpening.value) return;
isOpening.value = true;
try {
await loadEmbedScript();
window.GitBook('configure', {
assistantName: ASSISTANT_NAME,
tabs: ['assistant'], // chat only — no in-panel docs browser or search
trademark: false,
greeting: {
title: t('nav.DocsGreetingTitle'),
subtitle: t('nav.DocsGreetingSubtitle'),
},
suggestions: docsQuestions(),
actions: [
{ icon: 'book', label: t('nav.OpenDocs'), onClick: openDocsSite },
],
});
window.GitBook('open');
if (question) {
window.GitBook('navigateToAssistant');
window.GitBook('postUserMessage', question);
}
} catch (error) {
console.warn('Docs assistant unavailable, opening docs site instead:', error);
openDocsSite();
} finally {
isOpening.value = false;
}
};
return { askDocs, closeDocs, setOpen, isOpen, isLoaded, isOpening, docsQuestions };
}
+13 -1
View File
@@ -148,6 +148,16 @@
"MTRTest": "MTR Test",
"RuleTest": "Rule Test",
"AdvancedTools": "Advanced Tools",
"SearchDocs": "Search docs…",
"OpenDocs": "Open docs",
"DocsGreetingTitle": "How can I help?",
"DocsGreetingSubtitle": "Ask me anything about MyIP — the tools, your results, or self-hosting.",
"DocsQuestions": [
"Is my VPN leaking my real IP?",
"Why is my IP location wrong?",
"How do I read MTR results?",
"How do I deploy my own MyIP?"
],
"preferences": {
"title": "Preferences",
"preferenceTips": "These settings are saved in your browser for easy reuse. Some options require refreshing the page to take effect.",
@@ -975,7 +985,9 @@
"linkedIn": "LinkedIn",
"twitter": "Twitter",
"Sponsor": "Sponsor Us",
"Privacy": "Privacy"
"Privacy": "Privacy",
"HelpCenter": "Help Center",
"Close": "Close"
},
"acknowledgement": {
"Title": "Acknowledgement",
+13 -1
View File
@@ -148,6 +148,16 @@
"MTRTest": "Test MTR",
"RuleTest": "Test de règles",
"AdvancedTools": "Outils avancés",
"SearchDocs": "Rechercher dans la doc…",
"OpenDocs": "Ouvrir la doc",
"DocsGreetingTitle": "Comment puis-je vous aider ?",
"DocsGreetingSubtitle": "Posez vos questions sur MyIP : les outils, vos résultats ou l'auto-hébergement.",
"DocsQuestions": [
"Mon VPN fuit-il ma vraie IP ?",
"Pourquoi ma localisation IP est-elle fausse ?",
"Comment lire les résultats MTR ?",
"Comment déployer mon propre MyIP ?"
],
"preferences": {
"title": "Préférences",
"preferenceTips": "Ces paramètres sont enregistrés dans votre navigateur pour une utilisation ultérieure. Certains paramètres nécessitent un rafraîchissement de la page pour prendre effet.",
@@ -976,7 +986,9 @@
"linkedIn": "LinkedIn",
"twitter": "Twitter",
"Sponsor": "Nous soutenir",
"Privacy": "Confidentialité"
"Privacy": "Confidentialité",
"HelpCenter": "Centre d'aide",
"Close": "Fermer"
},
"acknowledgement": {
"Title": "Remerciements",
+13 -1
View File
@@ -148,6 +148,16 @@
"MTRTest": "Тест MTR",
"RuleTest": "Проверка правил",
"AdvancedTools": "Расширенные инструменты",
"SearchDocs": "Поиск по документации…",
"OpenDocs": "Открыть документацию",
"DocsGreetingTitle": "Чем могу помочь?",
"DocsGreetingSubtitle": "Спросите о MyIP: инструменты, ваши результаты или самостоятельный хостинг.",
"DocsQuestions": [
"Мой VPN раскрывает мой настоящий IP?",
"Почему геолокация моего IP неверна?",
"Как читать результаты MTR?",
"Как развернуть свой MyIP?"
],
"preferences": {
"title": "Настройки",
"preferenceTips": "Эти настройки сохраняются в браузере для повторного использования. Чтобы некоторые параметры вступили в силу, нужно обновить страницу.",
@@ -976,7 +986,9 @@
"linkedIn": "LinkedIn",
"twitter": "Twitter",
"Sponsor": "Поддержать",
"Privacy": "Приватность"
"Privacy": "Приватность",
"HelpCenter": "Справочный центр",
"Close": "Закрыть"
},
"acknowledgement": {
"Title": "Благодарности",
+13 -1
View File
@@ -148,6 +148,16 @@
"MTRTest": "MTR 测试",
"RuleTest": "分流测试",
"AdvancedTools": "高级工具",
"SearchDocs": "搜索文档…",
"OpenDocs": "打开文档",
"DocsGreetingTitle": "有什么可以帮你?",
"DocsGreetingSubtitle": "关于 MyIP 的工具、测试结果或自部署,都可以问我。",
"DocsQuestions": [
"我的 VPN 泄漏真实 IP 了吗?",
"为什么我的 IP 归属地不对?",
"MTR 结果怎么看?",
"怎么部署一个自己的 MyIP?"
],
"preferences": {
"title": "偏好设置",
"preferenceTips": "这些设置会保存在浏览器,方便下次使用。部分选项需要刷新页面方可生效。",
@@ -976,7 +986,9 @@
"linkedIn": "LinkedIn",
"twitter": "Twitter",
"Sponsor": "赞助我们",
"Privacy": "隐私政策"
"Privacy": "隐私政策",
"HelpCenter": "帮助中心",
"Close": "关闭"
},
"acknowledgement": {
"Title": "致谢",
+30 -1
View File
@@ -38,6 +38,7 @@
Each semantic color has a foreground pair, which automatically corrects the text color when used in bg-* ≈ amber-500 (foreground is dark because amber is light; sky / green / red are dark, so foreground is white). The light/dark mode is defined independently by :root / .dark, and tokens do not need to write dark: variants.
*/
:root {
color-scheme: light;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -75,6 +76,7 @@
}
.dark {
color-scheme: dark;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
@@ -248,4 +250,31 @@ footer {
.svgMap-tooltip {
z-index: 10001 !important;
}
}
/* GitBook Docs Assistant embed (loaded on demand by use-docs-assistant.js).
The embed anchors its launcher and panel bottom-right, where our FAB stack
lives — move both to the bottom-LEFT, with the launcher on the FAB dock's
baseline (bottom-9 = 2.25rem). The panel must clear the launcher by the
same amount the embed leaves by default, so it stacks on top of it rather
than overlapping: launcher height (2.5rem) + the embed's own gap (0.75rem).
The launcher doubles as the panel's toggle, which is how it gets closed. */
#gitbook-widget-button,
#gitbook-widget-window {
--jn-gb-anchor: 2.25rem;
right: auto !important;
left: 1rem !important;
}
#gitbook-widget-button {
bottom: var(--jn-gb-anchor) !important;
}
#gitbook-widget-window {
bottom: calc(var(--jn-gb-anchor) + 3.25rem) !important;
/* Raising the panel above the launcher eats into its headroom, so cap the
height to what is left — otherwise a tall panel runs off the top of the
screen on phones. dvh tracks mobile browser chrome as it collapses; the
vh line is the fallback for browsers without it. */
max-height: calc(100vh - var(--jn-gb-anchor) - 3.25rem - env(safe-area-inset-top) - 1rem) !important;
max-height: calc(100dvh - var(--jn-gb-anchor) - 3.25rem - env(safe-area-inset-top) - 1rem) !important;
}