jekyll-theme-zer0 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +4 -4
- data/_data/features.yml +15 -0
- data/_data/theme-manifest.yml +428 -0
- data/_includes/components/ai-chat.html +579 -0
- data/_includes/core/footer.html +3 -1
- data/_layouts/root.html +3 -0
- data/_plugins/obsidian_links.rb +18 -5
- data/_sass/tokens/_layers.scss +1 -0
- data/scripts/bin/audit-consumer +336 -0
- data/scripts/bin/manifest +183 -0
- data/scripts/bin/release +15 -1
- data/scripts/bin/sync-plugins +356 -0
- data/scripts/bin/validate +1 -1
- data/scripts/lib/audit.sh +284 -0
- data/scripts/lib/frontmatter.sh +4 -2
- data/scripts/test/lib/test_locale_independence.sh +3 -0
- metadata +7 -1
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
===================================================================
|
|
3
|
+
AI CHAT - Frontend AI Chatbot with Page Context
|
|
4
|
+
===================================================================
|
|
5
|
+
|
|
6
|
+
File: ai-chat.html
|
|
7
|
+
Path: _includes/components/ai-chat.html
|
|
8
|
+
Purpose: Provides an AI-powered chat widget that includes the
|
|
9
|
+
current page context in conversations. Uses the OpenAI
|
|
10
|
+
Chat Completions API with an API key injected at build
|
|
11
|
+
time from GitHub repository secrets.
|
|
12
|
+
|
|
13
|
+
Template Logic:
|
|
14
|
+
- Renders a floating chat button and expandable chat panel
|
|
15
|
+
- Collects current page metadata (title, description, content,
|
|
16
|
+
URL, categories, tags) as system context for the AI
|
|
17
|
+
- Sends messages to the configured AI provider endpoint
|
|
18
|
+
- Rendering is gated by ai_chat.enabled and either:
|
|
19
|
+
- Proxy mode: auth_mode: "proxy" with proxy_ready: true (recommended), or
|
|
20
|
+
- Direct mode: a non-empty ai_chat.api_key for client-side API access
|
|
21
|
+
|
|
22
|
+
Dependencies:
|
|
23
|
+
- Bootstrap 5 for UI components and styling
|
|
24
|
+
- Bootstrap Icons for chat button icon
|
|
25
|
+
- site.ai_chat configuration from _config.yml
|
|
26
|
+
|
|
27
|
+
Security:
|
|
28
|
+
- API key is injected at build time, not stored in source
|
|
29
|
+
- IMPORTANT: Client-side API keys are visible in page source.
|
|
30
|
+
For production, route requests through a server-side proxy
|
|
31
|
+
by changing the 'endpoint' config to your proxy URL.
|
|
32
|
+
|
|
33
|
+
Accessibility:
|
|
34
|
+
- ARIA labels for screen readers
|
|
35
|
+
- Keyboard navigation support (Escape to close)
|
|
36
|
+
- Focus management when opening/closing the chat panel
|
|
37
|
+
===================================================================
|
|
38
|
+
-->
|
|
39
|
+
|
|
40
|
+
{% assign ai_auth_mode = site.ai_chat.auth_mode | default: 'proxy' %}
|
|
41
|
+
{% comment %}
|
|
42
|
+
Boolean expressions are NOT evaluated inside assign tags (Liquid stores a
|
|
43
|
+
truthy string instead) — compute the render flag with if-tags, which do
|
|
44
|
+
support and/comparisons. Render only when chat is enabled AND a usable
|
|
45
|
+
auth path exists: proxy mode with a deployed proxy, or direct mode with a
|
|
46
|
+
non-empty api_key.
|
|
47
|
+
{% endcomment %}
|
|
48
|
+
{% assign ai_render = false %}
|
|
49
|
+
{% if site.ai_chat.enabled %}
|
|
50
|
+
{% if ai_auth_mode == 'proxy' and site.ai_chat.proxy_ready %}
|
|
51
|
+
{% assign ai_render = true %}
|
|
52
|
+
{% elsif ai_auth_mode == 'direct' and site.ai_chat.api_key and site.ai_chat.api_key != '' %}
|
|
53
|
+
{% assign ai_render = true %}
|
|
54
|
+
{% endif %}
|
|
55
|
+
{% endif %}
|
|
56
|
+
{% if ai_render %}
|
|
57
|
+
|
|
58
|
+
<!-- AI Chat Toggle Button -->
|
|
59
|
+
<button id="aiChatToggle"
|
|
60
|
+
class="ai-chat-toggle btn btn-primary rounded-circle shadow-lg"
|
|
61
|
+
type="button"
|
|
62
|
+
aria-label="Open AI chat assistant"
|
|
63
|
+
aria-expanded="false"
|
|
64
|
+
aria-controls="aiChatPanel">
|
|
65
|
+
<i class="{{ site.ai_chat.icon | default: 'bi-robot' }} ai-chat-icon-open" aria-hidden="true"></i>
|
|
66
|
+
<i class="bi-x-lg ai-chat-icon-close d-none" aria-hidden="true"></i>
|
|
67
|
+
</button>
|
|
68
|
+
|
|
69
|
+
<!-- AI Chat Panel -->
|
|
70
|
+
<div id="aiChatPanel"
|
|
71
|
+
class="ai-chat-panel shadow-lg rounded-3"
|
|
72
|
+
role="dialog"
|
|
73
|
+
aria-labelledby="aiChatTitle"
|
|
74
|
+
aria-hidden="true">
|
|
75
|
+
|
|
76
|
+
<!-- Chat Header -->
|
|
77
|
+
<div class="ai-chat-header d-flex align-items-center justify-content-between p-3">
|
|
78
|
+
<div class="d-flex align-items-center gap-2">
|
|
79
|
+
<i class="{{ site.ai_chat.icon | default: 'bi-robot' }} fs-5" aria-hidden="true"></i>
|
|
80
|
+
<h6 id="aiChatTitle" class="mb-0 fw-semibold">Zer0 Assistant</h6>
|
|
81
|
+
</div>
|
|
82
|
+
<button type="button"
|
|
83
|
+
class="btn btn-sm btn-outline-light border-0"
|
|
84
|
+
id="aiChatClose"
|
|
85
|
+
aria-label="Close chat">
|
|
86
|
+
<i class="bi-x-lg" aria-hidden="true"></i>
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- Chat Messages -->
|
|
91
|
+
<div id="aiChatMessages" class="ai-chat-messages p-3" role="log" aria-live="polite">
|
|
92
|
+
<!-- Welcome message inserted by JS -->
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<!-- Chat Input -->
|
|
96
|
+
<div class="ai-chat-input p-3">
|
|
97
|
+
<form id="aiChatForm" class="d-flex gap-2" autocomplete="off">
|
|
98
|
+
<label for="aiChatInput" class="visually-hidden">Chat message</label>
|
|
99
|
+
<input type="text"
|
|
100
|
+
id="aiChatInput"
|
|
101
|
+
class="form-control form-control-sm"
|
|
102
|
+
placeholder="{{ site.ai_chat.placeholder | default: 'Ask about this page...' }}"
|
|
103
|
+
maxlength="500"
|
|
104
|
+
required>
|
|
105
|
+
<button type="submit"
|
|
106
|
+
class="btn btn-primary btn-sm"
|
|
107
|
+
id="aiChatSend"
|
|
108
|
+
aria-label="Send message">
|
|
109
|
+
<i class="bi-send" aria-hidden="true"></i>
|
|
110
|
+
</button>
|
|
111
|
+
</form>
|
|
112
|
+
<p class="ai-chat-disclaimer text-muted mt-1 mb-0">
|
|
113
|
+
<small>AI responses may be inaccurate. Verify important information.</small>
|
|
114
|
+
</p>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<!-- Page Context Data (generated by Jekyll at build time) -->
|
|
119
|
+
<script id="aiChatPageContext" type="application/json">
|
|
120
|
+
{
|
|
121
|
+
"page_title": {{ page.title | default: site.title | jsonify }},
|
|
122
|
+
"page_description": {{ page.description | default: page.excerpt | strip_html | default: "" | jsonify }},
|
|
123
|
+
"page_url": {{ page.url | absolute_url | jsonify }},
|
|
124
|
+
"page_categories": {{ page.categories | default: "" | jsonify }},
|
|
125
|
+
"page_tags": {{ page.tags | default: "" | jsonify }},
|
|
126
|
+
"page_layout": {{ page.layout | default: "" | jsonify }},
|
|
127
|
+
"page_date": {{ page.date | date: "%Y-%m-%d" | default: "" | jsonify }},
|
|
128
|
+
"site_title": {{ site.title | jsonify }},
|
|
129
|
+
"site_description": {{ site.description | strip_html | strip | truncate: 200 | jsonify }}
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
{% comment %}
|
|
134
|
+
Page content is captured and truncated to stay within context limits.
|
|
135
|
+
HTML is stripped to send clean text to the AI.
|
|
136
|
+
{% endcomment %}
|
|
137
|
+
{% capture page_text %}{{ content | strip_html | strip_newlines | normalize_whitespace }}{% endcapture %}
|
|
138
|
+
{% assign context_length = site.ai_chat.context_max_length | default: 2000 %}
|
|
139
|
+
<script id="aiChatPageContent" type="text/plain">{{ page_text | truncate: context_length | replace: '</script>', '<\/script>' }}</script>
|
|
140
|
+
|
|
141
|
+
<script>
|
|
142
|
+
// AI Chat Widget
|
|
143
|
+
(function() {
|
|
144
|
+
'use strict';
|
|
145
|
+
|
|
146
|
+
// Configuration from Jekyll
|
|
147
|
+
const CONFIG = {
|
|
148
|
+
endpoint: {{ site.ai_chat.endpoint | default: "/api/chat" | jsonify }},
|
|
149
|
+
authMode: {{ site.ai_chat.auth_mode | default: "proxy" | jsonify }},
|
|
150
|
+
apiKey: {{ site.ai_chat.api_key | jsonify }},
|
|
151
|
+
model: {{ site.ai_chat.model | default: "gpt-4o-mini" | jsonify }},
|
|
152
|
+
maxTokens: {{ site.ai_chat.max_tokens | default: 1024 }},
|
|
153
|
+
temperature: {{ site.ai_chat.temperature | default: 0.7 }},
|
|
154
|
+
strictContext: {{ site.ai_chat.strict_context | default: true | jsonify }},
|
|
155
|
+
outOfScopeMessage: {{ site.ai_chat.out_of_scope_message | default: "I can only answer from the content on this page." | jsonify }},
|
|
156
|
+
systemPrompt: {{ site.ai_chat.system_prompt | default: "You are a helpful assistant." | jsonify }},
|
|
157
|
+
welcomeMessage: {{ site.ai_chat.welcome_message | default: "Hi! Ask me anything about this page." | jsonify }}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// State
|
|
161
|
+
let isOpen = false;
|
|
162
|
+
let isLoading = false;
|
|
163
|
+
let lastSendTime = 0;
|
|
164
|
+
const SEND_COOLDOWN_MS = 1000; // Minimum time between sends
|
|
165
|
+
const MAX_MESSAGES = 40; // Maximum number of messages to retain in history
|
|
166
|
+
const _messages = [];
|
|
167
|
+
const messages = new Proxy(_messages, {
|
|
168
|
+
set(target, prop, value) {
|
|
169
|
+
const result = Reflect.set(target, prop, value);
|
|
170
|
+
|
|
171
|
+
// When the array grows (numeric index or length change), enforce the cap
|
|
172
|
+
if (prop === 'length' || !Number.isNaN(Number(prop))) {
|
|
173
|
+
const excess = target.length - MAX_MESSAGES;
|
|
174
|
+
if (excess > 0) {
|
|
175
|
+
// Remove oldest messages, keep only the most recent MAX_MESSAGES
|
|
176
|
+
target.splice(0, excess);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
// DOM Elements
|
|
184
|
+
const toggle = document.getElementById('aiChatToggle');
|
|
185
|
+
const panel = document.getElementById('aiChatPanel');
|
|
186
|
+
const messagesContainer = document.getElementById('aiChatMessages');
|
|
187
|
+
const form = document.getElementById('aiChatForm');
|
|
188
|
+
const input = document.getElementById('aiChatInput');
|
|
189
|
+
const closeBtn = document.getElementById('aiChatClose');
|
|
190
|
+
const iconOpen = toggle.querySelector('.ai-chat-icon-open');
|
|
191
|
+
const iconClose = toggle.querySelector('.ai-chat-icon-close');
|
|
192
|
+
|
|
193
|
+
// Build page context for system message
|
|
194
|
+
function getPageContext() {
|
|
195
|
+
let context = '';
|
|
196
|
+
try {
|
|
197
|
+
const meta = JSON.parse(document.getElementById('aiChatPageContext').textContent);
|
|
198
|
+
const pageContent = document.getElementById('aiChatPageContent').textContent.trim();
|
|
199
|
+
|
|
200
|
+
context = 'Current page context:\n';
|
|
201
|
+
if (meta.page_title) context += '- Title: ' + meta.page_title + '\n';
|
|
202
|
+
if (meta.page_description) context += '- Description: ' + meta.page_description + '\n';
|
|
203
|
+
if (meta.page_url) context += '- URL: ' + meta.page_url + '\n';
|
|
204
|
+
if (meta.page_categories && meta.page_categories.length) {
|
|
205
|
+
context += '- Categories: ' + (Array.isArray(meta.page_categories) ? meta.page_categories.join(', ') : meta.page_categories) + '\n';
|
|
206
|
+
}
|
|
207
|
+
if (meta.page_tags && meta.page_tags.length) {
|
|
208
|
+
context += '- Tags: ' + (Array.isArray(meta.page_tags) ? meta.page_tags.join(', ') : meta.page_tags) + '\n';
|
|
209
|
+
}
|
|
210
|
+
if (meta.page_date) context += '- Date: ' + meta.page_date + '\n';
|
|
211
|
+
if (meta.site_title) context += '- Site: ' + meta.site_title + '\n';
|
|
212
|
+
if (pageContent) context += '\nPage content:\n' + pageContent + '\n';
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.warn('AI Chat: Could not parse page context', e);
|
|
215
|
+
}
|
|
216
|
+
return context;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Build system message with page context
|
|
220
|
+
function getSystemMessage() {
|
|
221
|
+
const pageContext = getPageContext();
|
|
222
|
+
let systemContent = CONFIG.systemPrompt;
|
|
223
|
+
if (CONFIG.strictContext) {
|
|
224
|
+
systemContent += '\n\nGrounding rules:\n'
|
|
225
|
+
+ '- Answer ONLY using the provided page context.\n'
|
|
226
|
+
+ '- If the answer is not in the page context, reply exactly with: "' + CONFIG.outOfScopeMessage + '"\n'
|
|
227
|
+
+ '- Do not invent facts, links, or features.\n';
|
|
228
|
+
}
|
|
229
|
+
if (pageContext) {
|
|
230
|
+
systemContent += '\n\n' + pageContext;
|
|
231
|
+
}
|
|
232
|
+
return { role: 'system', content: systemContent };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function escapeHtml(text) {
|
|
236
|
+
return String(text)
|
|
237
|
+
.replace(/&/g, '&')
|
|
238
|
+
.replace(/</g, '<')
|
|
239
|
+
.replace(/>/g, '>')
|
|
240
|
+
.replace(/\"/g, '"')
|
|
241
|
+
.replace(/'/g, ''');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderAssistantMarkdown(raw) {
|
|
245
|
+
// Escape first, then allow a small markdown subset safely.
|
|
246
|
+
let safe = escapeHtml(raw || '');
|
|
247
|
+
|
|
248
|
+
safe = safe
|
|
249
|
+
.replace(/^###\s+(.+)$/gm, '<strong>$1</strong>')
|
|
250
|
+
.replace(/^##\s+(.+)$/gm, '<strong>$1</strong>')
|
|
251
|
+
.replace(/^#\s+(.+)$/gm, '<strong>$1</strong>')
|
|
252
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
253
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
254
|
+
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
|
255
|
+
.replace(/^\s*[-*]\s+(.+)$/gm, '• $1')
|
|
256
|
+
.replace(/\n/g, '<br>');
|
|
257
|
+
|
|
258
|
+
return safe;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Create a message element
|
|
262
|
+
function createMessageEl(role, content) {
|
|
263
|
+
const wrapper = document.createElement('div');
|
|
264
|
+
wrapper.className = 'ai-chat-message ai-chat-message--' + role + ' mb-2';
|
|
265
|
+
|
|
266
|
+
const bubble = document.createElement('div');
|
|
267
|
+
bubble.className = 'ai-chat-bubble p-2 rounded-3 small';
|
|
268
|
+
if (role === 'assistant') {
|
|
269
|
+
bubble.innerHTML = renderAssistantMarkdown(content);
|
|
270
|
+
} else {
|
|
271
|
+
bubble.textContent = content;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
wrapper.appendChild(bubble);
|
|
275
|
+
return wrapper;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Create a loading indicator
|
|
279
|
+
function createLoadingEl() {
|
|
280
|
+
const wrapper = document.createElement('div');
|
|
281
|
+
wrapper.className = 'ai-chat-message ai-chat-message--assistant mb-2';
|
|
282
|
+
wrapper.id = 'aiChatLoading';
|
|
283
|
+
|
|
284
|
+
const bubble = document.createElement('div');
|
|
285
|
+
bubble.className = 'ai-chat-bubble p-2 rounded-3 small';
|
|
286
|
+
bubble.innerHTML = '<span class="ai-chat-typing"><span>.</span><span>.</span><span>.</span></span>';
|
|
287
|
+
|
|
288
|
+
wrapper.appendChild(bubble);
|
|
289
|
+
return wrapper;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Append message to chat
|
|
293
|
+
function appendMessage(role, content) {
|
|
294
|
+
const el = createMessageEl(role, content);
|
|
295
|
+
messagesContainer.appendChild(el);
|
|
296
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
297
|
+
return el;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Remove loading indicator
|
|
301
|
+
function removeLoading() {
|
|
302
|
+
const loading = document.getElementById('aiChatLoading');
|
|
303
|
+
if (loading) loading.remove();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Send message to AI
|
|
307
|
+
async function sendMessage(userMessage) {
|
|
308
|
+
if (isLoading || !userMessage.trim()) return;
|
|
309
|
+
|
|
310
|
+
// Enforce cooldown between sends
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
if (now - lastSendTime < SEND_COOLDOWN_MS) return;
|
|
313
|
+
lastSendTime = now;
|
|
314
|
+
|
|
315
|
+
// Add user message to UI and history
|
|
316
|
+
appendMessage('user', userMessage);
|
|
317
|
+
messages.push({ role: 'user', content: userMessage });
|
|
318
|
+
|
|
319
|
+
// Show loading
|
|
320
|
+
isLoading = true;
|
|
321
|
+
input.disabled = true;
|
|
322
|
+
messagesContainer.appendChild(createLoadingEl());
|
|
323
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const response = await fetch(CONFIG.endpoint, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
headers: (function() {
|
|
329
|
+
const requestHeaders = {
|
|
330
|
+
'Content-Type': 'application/json'
|
|
331
|
+
};
|
|
332
|
+
if (CONFIG.authMode === 'direct' && CONFIG.apiKey) {
|
|
333
|
+
requestHeaders.Authorization = 'Bearer ' + CONFIG.apiKey;
|
|
334
|
+
}
|
|
335
|
+
return requestHeaders;
|
|
336
|
+
})(),
|
|
337
|
+
body: JSON.stringify({
|
|
338
|
+
model: CONFIG.model,
|
|
339
|
+
messages: [getSystemMessage(), ...messages],
|
|
340
|
+
max_tokens: CONFIG.maxTokens,
|
|
341
|
+
temperature: CONFIG.temperature
|
|
342
|
+
})
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
removeLoading();
|
|
346
|
+
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
const errorData = await response.json().catch(function() { return {}; });
|
|
349
|
+
var statusMessages = {
|
|
350
|
+
401: 'Authentication failed. The API key may be invalid or missing.',
|
|
351
|
+
429: 'Rate limit exceeded. Please wait a moment and try again.',
|
|
352
|
+
500: 'The AI service is temporarily unavailable. Please try again later.',
|
|
353
|
+
503: 'The AI service is temporarily unavailable. Please try again later.'
|
|
354
|
+
};
|
|
355
|
+
throw new Error(errorData.error?.message || statusMessages[response.status] || 'API request failed (' + response.status + ')');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const data = await response.json();
|
|
359
|
+
let assistantMessage = data.choices?.[0]?.message?.content || 'Sorry, I could not generate a response.';
|
|
360
|
+
assistantMessage = String(assistantMessage).trim() || CONFIG.outOfScopeMessage;
|
|
361
|
+
|
|
362
|
+
// Add assistant message to UI and history
|
|
363
|
+
appendMessage('assistant', assistantMessage);
|
|
364
|
+
messages.push({ role: 'assistant', content: assistantMessage });
|
|
365
|
+
|
|
366
|
+
} catch (error) {
|
|
367
|
+
removeLoading();
|
|
368
|
+
appendMessage('assistant', 'Sorry, something went wrong: ' + error.message);
|
|
369
|
+
console.error('AI Chat error:', error);
|
|
370
|
+
} finally {
|
|
371
|
+
isLoading = false;
|
|
372
|
+
input.disabled = false;
|
|
373
|
+
input.focus();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Toggle chat panel
|
|
378
|
+
function toggleChat() {
|
|
379
|
+
isOpen = !isOpen;
|
|
380
|
+
panel.classList.toggle('ai-chat-panel--open', isOpen);
|
|
381
|
+
panel.setAttribute('aria-hidden', !isOpen);
|
|
382
|
+
toggle.setAttribute('aria-expanded', isOpen);
|
|
383
|
+
iconOpen.classList.toggle('d-none', isOpen);
|
|
384
|
+
iconClose.classList.toggle('d-none', !isOpen);
|
|
385
|
+
|
|
386
|
+
if (isOpen) {
|
|
387
|
+
// Focus input when opening (short delay for CSS transition to start)
|
|
388
|
+
setTimeout(function() { input.focus(); }, 50);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Initialize
|
|
393
|
+
function init() {
|
|
394
|
+
// Show welcome message
|
|
395
|
+
appendMessage('assistant', CONFIG.welcomeMessage);
|
|
396
|
+
|
|
397
|
+
// Event: toggle button
|
|
398
|
+
toggle.addEventListener('click', toggleChat);
|
|
399
|
+
|
|
400
|
+
// Event: close button
|
|
401
|
+
closeBtn.addEventListener('click', toggleChat);
|
|
402
|
+
|
|
403
|
+
// Event: form submit
|
|
404
|
+
form.addEventListener('submit', function(e) {
|
|
405
|
+
e.preventDefault();
|
|
406
|
+
const msg = input.value.trim();
|
|
407
|
+
if (msg) {
|
|
408
|
+
input.value = '';
|
|
409
|
+
sendMessage(msg);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Event: Escape key closes chat
|
|
414
|
+
document.addEventListener('keydown', function(e) {
|
|
415
|
+
if (e.key === 'Escape' && isOpen) {
|
|
416
|
+
toggleChat();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Event: click outside closes chat
|
|
421
|
+
document.addEventListener('click', function(e) {
|
|
422
|
+
if (isOpen && !panel.contains(e.target) && !toggle.contains(e.target)) {
|
|
423
|
+
toggleChat();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Start when DOM is ready
|
|
429
|
+
if (document.readyState === 'loading') {
|
|
430
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
431
|
+
} else {
|
|
432
|
+
init();
|
|
433
|
+
}
|
|
434
|
+
})();
|
|
435
|
+
</script>
|
|
436
|
+
|
|
437
|
+
<style>
|
|
438
|
+
/* AI Chat Toggle Button */
|
|
439
|
+
/* FAB stack slot above back-to-top, below the TOC FAB — driven by the
|
|
440
|
+
design tokens (_sass/tokens/_spacing.scss, _layers.scss) so all
|
|
441
|
+
floating buttons share one offset/stacking system. */
|
|
442
|
+
.ai-chat-toggle {
|
|
443
|
+
position: fixed;
|
|
444
|
+
bottom: calc(var(--zer0-space-fab-offset, 1rem) + var(--zer0-space-fab-size, 3.5rem) + var(--zer0-space-fab-gap, 0.75rem));
|
|
445
|
+
right: var(--zer0-space-fab-offset, 1rem);
|
|
446
|
+
z-index: var(--zer0-layer-fab-chat, 1052);
|
|
447
|
+
width: 3rem;
|
|
448
|
+
height: 3rem;
|
|
449
|
+
display: flex;
|
|
450
|
+
align-items: center;
|
|
451
|
+
justify-content: center;
|
|
452
|
+
font-size: 1.25rem;
|
|
453
|
+
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.ai-chat-toggle:hover {
|
|
457
|
+
transform: scale(1.1);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/* AI Chat Panel */
|
|
461
|
+
.ai-chat-panel {
|
|
462
|
+
position: fixed;
|
|
463
|
+
bottom: calc(var(--zer0-space-fab-offset, 1rem) + 2 * (var(--zer0-space-fab-size, 3.5rem) + var(--zer0-space-fab-gap, 0.75rem)));
|
|
464
|
+
right: var(--zer0-space-fab-offset, 1rem);
|
|
465
|
+
z-index: var(--zer0-layer-fab-chat, 1052);
|
|
466
|
+
width: 360px;
|
|
467
|
+
max-height: 500px;
|
|
468
|
+
display: flex;
|
|
469
|
+
flex-direction: column;
|
|
470
|
+
background: var(--bs-body-bg, #212529);
|
|
471
|
+
border: 1px solid var(--bs-border-color, #495057);
|
|
472
|
+
opacity: 0;
|
|
473
|
+
visibility: hidden;
|
|
474
|
+
transform: translateY(1rem) scale(0.95);
|
|
475
|
+
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.ai-chat-panel--open {
|
|
479
|
+
opacity: 1;
|
|
480
|
+
visibility: visible;
|
|
481
|
+
transform: translateY(0) scale(1);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* Chat Header */
|
|
485
|
+
.ai-chat-header {
|
|
486
|
+
background: var(--bs-primary, #0d6efd);
|
|
487
|
+
color: #fff;
|
|
488
|
+
border-radius: 0.375rem 0.375rem 0 0;
|
|
489
|
+
flex-shrink: 0;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/* Chat Messages */
|
|
493
|
+
.ai-chat-messages {
|
|
494
|
+
flex: 1 1 auto;
|
|
495
|
+
overflow-y: auto;
|
|
496
|
+
min-height: 200px;
|
|
497
|
+
max-height: 320px;
|
|
498
|
+
scroll-behavior: smooth;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* Message Bubbles */
|
|
502
|
+
.ai-chat-message--user {
|
|
503
|
+
display: flex;
|
|
504
|
+
justify-content: flex-end;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.ai-chat-message--user .ai-chat-bubble {
|
|
508
|
+
background: var(--bs-primary, #0d6efd);
|
|
509
|
+
color: #fff;
|
|
510
|
+
max-width: 85%;
|
|
511
|
+
word-wrap: break-word;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.ai-chat-message--assistant .ai-chat-bubble {
|
|
515
|
+
background: var(--bs-tertiary-bg, #2b3035);
|
|
516
|
+
color: var(--bs-body-color, #dee2e6);
|
|
517
|
+
max-width: 85%;
|
|
518
|
+
word-wrap: break-word;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* Chat Input */
|
|
522
|
+
.ai-chat-input {
|
|
523
|
+
border-top: 1px solid var(--bs-border-color, #495057);
|
|
524
|
+
flex-shrink: 0;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.ai-chat-disclaimer {
|
|
528
|
+
font-size: 0.7rem;
|
|
529
|
+
line-height: 1.2;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/* Typing Animation */
|
|
533
|
+
.ai-chat-typing span {
|
|
534
|
+
animation: aiChatTyping 1.4s infinite;
|
|
535
|
+
display: inline-block;
|
|
536
|
+
font-weight: bold;
|
|
537
|
+
font-size: 1.2em;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.ai-chat-typing span:nth-child(2) {
|
|
541
|
+
animation-delay: 0.2s;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.ai-chat-typing span:nth-child(3) {
|
|
545
|
+
animation-delay: 0.4s;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
@keyframes aiChatTyping {
|
|
549
|
+
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
|
|
550
|
+
30% { opacity: 1; transform: translateY(-4px); }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/* Mobile Responsive */
|
|
554
|
+
@media (max-width: 576px) {
|
|
555
|
+
.ai-chat-panel {
|
|
556
|
+
width: calc(100vw - 2rem);
|
|
557
|
+
right: 1rem;
|
|
558
|
+
bottom: 8rem;
|
|
559
|
+
max-height: 60vh;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.ai-chat-toggle {
|
|
563
|
+
bottom: 4.5rem;
|
|
564
|
+
right: 1rem;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* Respect reduced motion */
|
|
569
|
+
@media (prefers-reduced-motion: reduce) {
|
|
570
|
+
.ai-chat-panel,
|
|
571
|
+
.ai-chat-toggle,
|
|
572
|
+
.ai-chat-typing span {
|
|
573
|
+
transition: none !important;
|
|
574
|
+
animation: none !important;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
</style>
|
|
578
|
+
|
|
579
|
+
{% endif %}
|
data/_includes/core/footer.html
CHANGED
|
@@ -260,10 +260,12 @@
|
|
|
260
260
|
{%- endunless -%}
|
|
261
261
|
|
|
262
262
|
<!-- Back to Top Button (controlled by assets/js/back-to-top.js) -->
|
|
263
|
+
<!-- Positioning/stacking handled by _sass/components/_back-to-top.scss
|
|
264
|
+
via the FAB tokens (bottom-up: back-to-top → chat toggle → TOC FAB) -->
|
|
263
265
|
<a
|
|
264
266
|
id="backToTopBtn"
|
|
265
267
|
href="#"
|
|
266
|
-
class="btn btn-primary
|
|
268
|
+
class="btn btn-primary"
|
|
267
269
|
aria-label="Back to top"
|
|
268
270
|
title="Back to top"
|
|
269
271
|
role="button"
|
data/_layouts/root.html
CHANGED
|
@@ -96,6 +96,9 @@
|
|
|
96
96
|
<!-- Privacy-compliant cookie consent banner -->
|
|
97
97
|
{%- include components/cookie-consent.html -%}
|
|
98
98
|
|
|
99
|
+
<!-- AI Chat Assistant -->
|
|
100
|
+
{%- include components/ai-chat.html -%}
|
|
101
|
+
|
|
99
102
|
<!-- JavaScript dependencies and custom scripts -->
|
|
100
103
|
<div>
|
|
101
104
|
<!-- Search functionality -->
|
data/_plugins/obsidian_links.rb
CHANGED
|
@@ -60,7 +60,11 @@ module Jekyll
|
|
|
60
60
|
'tag_base_url' => '/tags/',
|
|
61
61
|
'callout_class_prefix' => 'obsidian-callout',
|
|
62
62
|
'wiki_link_class' => 'wiki-link',
|
|
63
|
-
'broken_link_class' => 'wiki-link wiki-link-broken'
|
|
63
|
+
'broken_link_class' => 'wiki-link wiki-link-broken',
|
|
64
|
+
# Slugs (normalised: lowercase, stripped) that are too generic to be
|
|
65
|
+
# useful as wiki-link targets and whose collisions should be silenced.
|
|
66
|
+
# Add entries in _config.yml under `obsidian.skip_slugs`.
|
|
67
|
+
'skip_slugs' => []
|
|
64
68
|
}.freeze
|
|
65
69
|
|
|
66
70
|
# Bootstrap alert mapping per Obsidian callout type.
|
|
@@ -104,15 +108,23 @@ module Jekyll
|
|
|
104
108
|
class Index
|
|
105
109
|
attr_reader :entries
|
|
106
110
|
|
|
107
|
-
def initialize(site)
|
|
111
|
+
def initialize(site, config = {})
|
|
108
112
|
@entries = {}
|
|
113
|
+
config ||= {}
|
|
114
|
+
@skip_slugs = Array(config['skip_slugs'])
|
|
115
|
+
.map { |s| s.to_s.downcase.strip.gsub(/\s+/, ' ') }
|
|
116
|
+
.to_set
|
|
109
117
|
build(site)
|
|
110
118
|
end
|
|
111
119
|
|
|
112
120
|
def build(site)
|
|
113
|
-
# All renderable docs across collections + standalone pages.
|
|
121
|
+
# All renderable docs across collections + standalone HTML pages.
|
|
122
|
+
# Filtering to output_ext == '.html' ensures raw binary docs (e.g.
|
|
123
|
+
# .ipynb notebook files) are not indexed as wiki-link targets.
|
|
114
124
|
items = []
|
|
115
|
-
|
|
125
|
+
# Docs that don't expose output_ext (test doubles, exotic generators)
|
|
126
|
+
# stay indexable; real binary outputs are excluded.
|
|
127
|
+
items.concat(site.documents.reject { |d| d.respond_to?(:output_ext) && d.output_ext != '.html' }) if site.respond_to?(:documents)
|
|
116
128
|
items.concat(site.pages.select { |p| p.output_ext == '.html' })
|
|
117
129
|
|
|
118
130
|
items.each do |doc|
|
|
@@ -133,6 +145,7 @@ module Jekyll
|
|
|
133
145
|
def register(key, url, doc)
|
|
134
146
|
slug = normalize(key)
|
|
135
147
|
return if slug.empty?
|
|
148
|
+
return if @skip_slugs.include?(slug)
|
|
136
149
|
|
|
137
150
|
# First registration wins; subsequent collisions produce a deterministic
|
|
138
151
|
# warning so authors can disambiguate (e.g. via `aliases:`).
|
|
@@ -449,7 +462,7 @@ module Jekyll
|
|
|
449
462
|
return @cached_index
|
|
450
463
|
end
|
|
451
464
|
|
|
452
|
-
new_index = Index.new(site)
|
|
465
|
+
new_index = Index.new(site, self.config)
|
|
453
466
|
@cached_fingerprint = current_fingerprint
|
|
454
467
|
@cached_site_id = site.object_id
|
|
455
468
|
@cached_index = new_index
|
data/_sass/tokens/_layers.scss
CHANGED