@aravindc26/velu 0.8.0 → 0.9.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.
- package/README.md +7 -0
- package/package.json +19 -7
- package/src/build.ts +121 -1503
- package/src/cli.ts +24 -25
- package/src/engine/_server.mjs +271 -0
- package/src/engine/app/(docs)/[[...slug]]/page.tsx +66 -0
- package/src/engine/app/(docs)/layout.tsx +16 -0
- package/src/engine/app/assistant.css +326 -0
- package/src/engine/app/copy-page.css +132 -0
- package/src/engine/app/global.css +21 -0
- package/src/engine/app/layout.tsx +34 -0
- package/src/engine/app/search.css +118 -0
- package/src/engine/components/assistant.tsx +350 -0
- package/src/engine/components/copy-page.tsx +96 -0
- package/src/engine/components/search.tsx +164 -0
- package/src/engine/lib/layout.shared.ts +18 -0
- package/src/engine/lib/source.ts +9 -0
- package/src/engine/lib/velu.ts +42 -0
- package/src/engine/mdx-components.tsx +9 -0
- package/src/engine/next-env.d.ts +4 -0
- package/src/engine/next.config.mjs +18 -0
- package/src/engine/postcss.config.mjs +7 -0
- package/src/engine/source.config.ts +17 -0
- package/src/engine/src/components/Footer.astro +44 -0
- package/src/engine/src/components/PageTitle.astro +451 -0
- package/src/engine/src/components/Sidebar.astro +60 -0
- package/src/engine/src/content.config.ts +6 -0
- package/src/engine/src/lib/velu.ts +153 -0
- package/src/engine/src/styles/assistant.css +351 -0
- package/src/engine/src/styles/tabs.css +183 -0
- package/src/engine/tsconfig.json +32 -0
- package/src/themes.ts +34 -29
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export function VeluAssistant() {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
// Guard against double-init
|
|
8
|
+
if (document.getElementById('veluAskBar')) return;
|
|
9
|
+
|
|
10
|
+
// ── Inject HTML ──
|
|
11
|
+
const askBar = document.createElement('div');
|
|
12
|
+
askBar.className = 'velu-ask-bar';
|
|
13
|
+
askBar.id = 'veluAskBar';
|
|
14
|
+
askBar.innerHTML = `
|
|
15
|
+
<div class="velu-ask-bar-inner">
|
|
16
|
+
<input type="text" class="velu-ask-input" id="veluAskInput" placeholder="Ask a question..." autocomplete="off" />
|
|
17
|
+
<button class="velu-ask-submit" id="veluAskSubmit" aria-label="Send">
|
|
18
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
19
|
+
</button>
|
|
20
|
+
</div>`;
|
|
21
|
+
document.body.appendChild(askBar);
|
|
22
|
+
|
|
23
|
+
const panel = document.createElement('div');
|
|
24
|
+
panel.className = 'velu-assistant-panel velu-panel-closed';
|
|
25
|
+
panel.id = 'veluAssistantPanel';
|
|
26
|
+
panel.innerHTML = `
|
|
27
|
+
<div class="velu-assistant-header">
|
|
28
|
+
<span class="velu-assistant-title">Assistant</span>
|
|
29
|
+
<div class="velu-assistant-actions">
|
|
30
|
+
<button class="velu-assistant-action" data-velu-action="expand" title="Expand" aria-label="Expand assistant" type="button">
|
|
31
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
32
|
+
</button>
|
|
33
|
+
<button class="velu-assistant-action" data-velu-action="reset" title="New chat" aria-label="New chat" type="button">
|
|
34
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
|
35
|
+
</button>
|
|
36
|
+
<button class="velu-assistant-action" data-velu-action="close" title="Close" aria-label="Close assistant" type="button">
|
|
37
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="velu-assistant-messages" id="veluAssistantMessages"></div>
|
|
42
|
+
<div class="velu-assistant-input-area">
|
|
43
|
+
<input type="text" class="velu-assistant-chat-input" id="veluAssistantChatInput" placeholder="Ask a question..." autocomplete="off" />
|
|
44
|
+
<button class="velu-assistant-send" id="veluAssistantSend" aria-label="Send">
|
|
45
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94l18-8.5a.75.75 0 000-1.38l-18-8.5z"/></svg>
|
|
46
|
+
</button>
|
|
47
|
+
</div>`;
|
|
48
|
+
document.body.appendChild(panel);
|
|
49
|
+
|
|
50
|
+
// ── Logic ──
|
|
51
|
+
initAssistant();
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
askBar.remove();
|
|
55
|
+
panel.remove();
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function initAssistant() {
|
|
63
|
+
const API_BASE = 'https://api.getvelu.com/api/v1/public/ai-assistant';
|
|
64
|
+
const state: {
|
|
65
|
+
conversationId: string | null;
|
|
66
|
+
conversationToken: string | null;
|
|
67
|
+
lastSeq: number;
|
|
68
|
+
eventSource: EventSource | null;
|
|
69
|
+
expanded: boolean;
|
|
70
|
+
bootstrapped: boolean;
|
|
71
|
+
} = {
|
|
72
|
+
conversationId: null,
|
|
73
|
+
conversationToken: null,
|
|
74
|
+
lastSeq: 0,
|
|
75
|
+
eventSource: null,
|
|
76
|
+
expanded: false,
|
|
77
|
+
bootstrapped: false,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const askBar = document.getElementById('veluAskBar')!;
|
|
81
|
+
const askInput = document.getElementById('veluAskInput') as HTMLInputElement;
|
|
82
|
+
const askSubmit = document.getElementById('veluAskSubmit')!;
|
|
83
|
+
const panel = document.getElementById('veluAssistantPanel')!;
|
|
84
|
+
const messagesEl = document.getElementById('veluAssistantMessages')!;
|
|
85
|
+
const chatInput = document.getElementById('veluAssistantChatInput') as HTMLInputElement;
|
|
86
|
+
const sendBtn = document.getElementById('veluAssistantSend')!;
|
|
87
|
+
|
|
88
|
+
function saveState() {
|
|
89
|
+
try {
|
|
90
|
+
sessionStorage.setItem('velu-panel-open', isPanelOpen() ? '1' : '');
|
|
91
|
+
sessionStorage.setItem('velu-panel-expanded', state.expanded ? '1' : '');
|
|
92
|
+
sessionStorage.setItem('velu-panel-messages', messagesEl.innerHTML);
|
|
93
|
+
sessionStorage.setItem('velu-conv-id', state.conversationId || '');
|
|
94
|
+
sessionStorage.setItem('velu-conv-token', state.conversationToken || '');
|
|
95
|
+
sessionStorage.setItem('velu-last-seq', String(state.lastSeq));
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function openPanel() {
|
|
100
|
+
panel.classList.remove('velu-panel-closed');
|
|
101
|
+
askBar.classList.add('velu-ask-bar-hidden');
|
|
102
|
+
document.documentElement.classList.add('velu-assistant-open');
|
|
103
|
+
chatInput.focus();
|
|
104
|
+
saveState();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function closePanel() {
|
|
108
|
+
panel.classList.add('velu-panel-closed');
|
|
109
|
+
askBar.classList.remove('velu-ask-bar-hidden');
|
|
110
|
+
document.documentElement.classList.remove('velu-assistant-open');
|
|
111
|
+
document.documentElement.classList.remove('velu-assistant-wide');
|
|
112
|
+
if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
|
|
113
|
+
saveState();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resetChat() {
|
|
117
|
+
state.conversationId = null;
|
|
118
|
+
state.conversationToken = null;
|
|
119
|
+
state.lastSeq = 0;
|
|
120
|
+
if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
|
|
121
|
+
messagesEl.innerHTML = '';
|
|
122
|
+
chatInput.value = '';
|
|
123
|
+
chatInput.focus();
|
|
124
|
+
saveState();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toggleExpand() {
|
|
128
|
+
state.expanded = !state.expanded;
|
|
129
|
+
panel.classList.toggle('velu-assistant-expanded', state.expanded);
|
|
130
|
+
document.documentElement.classList.toggle('velu-assistant-wide', state.expanded);
|
|
131
|
+
saveState();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isPanelOpen() {
|
|
135
|
+
return !panel.classList.contains('velu-panel-closed');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function bootstrap() {
|
|
139
|
+
if (state.bootstrapped) return Promise.resolve();
|
|
140
|
+
return fetch(API_BASE + '/bootstrap', { credentials: 'include' })
|
|
141
|
+
.then((r) => r.json())
|
|
142
|
+
.then(() => { state.bootstrapped = true; })
|
|
143
|
+
.catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatContent(text: string, citations: any[]) {
|
|
147
|
+
let html = text
|
|
148
|
+
.replace(/&/g, '&')
|
|
149
|
+
.replace(/</g, '<')
|
|
150
|
+
.replace(/>/g, '>')
|
|
151
|
+
.replace(/\n/g, '<br>')
|
|
152
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
153
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
154
|
+
html = html.replace(/\[(\d+)\]/g, (m, n) => {
|
|
155
|
+
const idx = parseInt(n) - 1;
|
|
156
|
+
const c = citations[idx];
|
|
157
|
+
if (c) {
|
|
158
|
+
return '<a href="' + (c.url || c.route_path || '#') + '" class="velu-citation-ref" target="_blank">[' + n + ']</a>';
|
|
159
|
+
}
|
|
160
|
+
return m;
|
|
161
|
+
});
|
|
162
|
+
return html;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function addMessage(role: string, content: string, citations: any[] = []) {
|
|
166
|
+
const msgDiv = document.createElement('div');
|
|
167
|
+
msgDiv.className = 'velu-msg velu-msg-' + role;
|
|
168
|
+
const bubble = document.createElement('div');
|
|
169
|
+
bubble.className = 'velu-msg-bubble velu-msg-bubble-' + role;
|
|
170
|
+
bubble.innerHTML = formatContent(content, citations);
|
|
171
|
+
msgDiv.appendChild(bubble);
|
|
172
|
+
|
|
173
|
+
if (role === 'assistant' && citations.length > 0) {
|
|
174
|
+
const citDiv = document.createElement('div');
|
|
175
|
+
citDiv.className = 'velu-msg-citations';
|
|
176
|
+
citations.forEach((c, i) => {
|
|
177
|
+
const a = document.createElement('a');
|
|
178
|
+
a.href = c.url || c.route_path || '#';
|
|
179
|
+
a.className = 'velu-citation-link';
|
|
180
|
+
a.textContent = '[' + (i + 1) + '] ' + (c.title || c.route_path || 'Source');
|
|
181
|
+
a.target = '_blank';
|
|
182
|
+
citDiv.appendChild(a);
|
|
183
|
+
});
|
|
184
|
+
msgDiv.appendChild(citDiv);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (role === 'assistant') {
|
|
188
|
+
const actions = document.createElement('div');
|
|
189
|
+
actions.className = 'velu-msg-actions';
|
|
190
|
+
actions.innerHTML =
|
|
191
|
+
'<button class="velu-msg-action" title="Like"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg></button>' +
|
|
192
|
+
'<button class="velu-msg-action" title="Dislike"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg></button>' +
|
|
193
|
+
'<button class="velu-msg-action velu-msg-copy" title="Copy"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>';
|
|
194
|
+
msgDiv.appendChild(actions);
|
|
195
|
+
|
|
196
|
+
const copyBtn = actions.querySelector('.velu-msg-copy');
|
|
197
|
+
if (copyBtn) {
|
|
198
|
+
(copyBtn as HTMLElement).onclick = () => {
|
|
199
|
+
navigator.clipboard.writeText(content);
|
|
200
|
+
(copyBtn as HTMLElement).title = 'Copied!';
|
|
201
|
+
setTimeout(() => { (copyBtn as HTMLElement).title = 'Copy'; }, 1500);
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
messagesEl.appendChild(msgDiv);
|
|
207
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
208
|
+
saveState();
|
|
209
|
+
return bubble;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function addThinking() {
|
|
213
|
+
const div = document.createElement('div');
|
|
214
|
+
div.className = 'velu-msg velu-msg-assistant';
|
|
215
|
+
div.id = 'veluThinking';
|
|
216
|
+
div.innerHTML = '<div class="velu-msg-bubble velu-msg-bubble-assistant"><span class="velu-thinking-dots"><span></span><span></span><span></span></span></div>';
|
|
217
|
+
messagesEl.appendChild(div);
|
|
218
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function removeThinking() {
|
|
222
|
+
document.getElementById('veluThinking')?.remove();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function connectSSE() {
|
|
226
|
+
if (state.eventSource) state.eventSource.close();
|
|
227
|
+
const url = API_BASE + '/conversations/' + state.conversationId + '/events?after_seq=' + state.lastSeq + '&token=' + encodeURIComponent(state.conversationToken || '');
|
|
228
|
+
state.eventSource = new EventSource(url);
|
|
229
|
+
|
|
230
|
+
state.eventSource.addEventListener('assistant.completed', (e: MessageEvent) => {
|
|
231
|
+
removeThinking();
|
|
232
|
+
try {
|
|
233
|
+
const data = JSON.parse(e.data);
|
|
234
|
+
const msg = data.message || data;
|
|
235
|
+
if (msg.seq) state.lastSeq = msg.seq;
|
|
236
|
+
addMessage('assistant', msg.content || '', msg.citations || []);
|
|
237
|
+
} catch {}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
state.eventSource.addEventListener('assistant.error', (e: MessageEvent) => {
|
|
241
|
+
removeThinking();
|
|
242
|
+
try {
|
|
243
|
+
const data = JSON.parse(e.data);
|
|
244
|
+
addMessage('assistant', data.error || 'Something went wrong. Please try again.');
|
|
245
|
+
} catch {
|
|
246
|
+
addMessage('assistant', 'Something went wrong. Please try again.');
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
state.eventSource.onerror = () => {};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function sendMessage(text: string) {
|
|
254
|
+
if (!text.trim()) return;
|
|
255
|
+
addMessage('user', text);
|
|
256
|
+
addThinking();
|
|
257
|
+
|
|
258
|
+
bootstrap()
|
|
259
|
+
.then(() =>
|
|
260
|
+
fetch(API_BASE + '/messages', {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'Content-Type': 'application/json' },
|
|
263
|
+
credentials: 'include',
|
|
264
|
+
body: JSON.stringify({
|
|
265
|
+
message: text,
|
|
266
|
+
conversation_id: state.conversationId,
|
|
267
|
+
}),
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
.then((r) => {
|
|
271
|
+
if (r.status === 429) {
|
|
272
|
+
removeThinking();
|
|
273
|
+
addMessage('assistant', 'Rate limited. Please wait a moment and try again.');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
return r.json();
|
|
277
|
+
})
|
|
278
|
+
.then((data: any) => {
|
|
279
|
+
if (!data) return;
|
|
280
|
+
if (data.conversation_id) state.conversationId = data.conversation_id;
|
|
281
|
+
if (data.conversation_token) state.conversationToken = data.conversation_token;
|
|
282
|
+
saveState();
|
|
283
|
+
if (!state.eventSource || state.eventSource.readyState === 2) {
|
|
284
|
+
connectSSE();
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
.catch(() => {
|
|
288
|
+
removeThinking();
|
|
289
|
+
addMessage('assistant', 'Failed to connect. Please try again.');
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Event handlers
|
|
294
|
+
askInput.onkeydown = (e) => { if (e.key === 'Enter') { const t = askInput.value.trim(); if (!t) return; askInput.value = ''; openPanel(); sendMessage(t); } };
|
|
295
|
+
askSubmit.onclick = () => { const t = askInput.value.trim(); if (!t) return; askInput.value = ''; openPanel(); sendMessage(t); };
|
|
296
|
+
chatInput.onkeydown = (e) => { if (e.key === 'Enter') { const t = chatInput.value.trim(); if (!t) return; chatInput.value = ''; sendMessage(t); } };
|
|
297
|
+
sendBtn.onclick = () => { const t = chatInput.value.trim(); if (!t) return; chatInput.value = ''; sendMessage(t); };
|
|
298
|
+
|
|
299
|
+
panel.addEventListener('click', (e) => {
|
|
300
|
+
const actionBtn = (e.target as HTMLElement).closest('[data-velu-action]');
|
|
301
|
+
if (!actionBtn) return;
|
|
302
|
+
const action = actionBtn.getAttribute('data-velu-action');
|
|
303
|
+
if (action === 'close') closePanel();
|
|
304
|
+
else if (action === 'expand') toggleExpand();
|
|
305
|
+
else if (action === 'reset') resetChat();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Scroll: hide ask bar at bottom of page
|
|
309
|
+
window.addEventListener('scroll', () => {
|
|
310
|
+
if (isPanelOpen()) return;
|
|
311
|
+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
312
|
+
const docHeight = document.documentElement.scrollHeight;
|
|
313
|
+
const winHeight = window.innerHeight;
|
|
314
|
+
if (docHeight <= winHeight + 10) return;
|
|
315
|
+
if (docHeight - scrollTop - winHeight < 60) {
|
|
316
|
+
askBar.classList.add('velu-ask-bar-hidden');
|
|
317
|
+
} else {
|
|
318
|
+
askBar.classList.remove('velu-ask-bar-hidden');
|
|
319
|
+
}
|
|
320
|
+
}, { passive: true });
|
|
321
|
+
|
|
322
|
+
document.addEventListener('keydown', (e) => {
|
|
323
|
+
if (e.key === 'Escape' && isPanelOpen()) closePanel();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Restore from session
|
|
327
|
+
try {
|
|
328
|
+
const savedOpen = sessionStorage.getItem('velu-panel-open');
|
|
329
|
+
const savedExpanded = sessionStorage.getItem('velu-panel-expanded');
|
|
330
|
+
const savedMessages = sessionStorage.getItem('velu-panel-messages');
|
|
331
|
+
const savedConvId = sessionStorage.getItem('velu-conv-id');
|
|
332
|
+
const savedConvToken = sessionStorage.getItem('velu-conv-token');
|
|
333
|
+
const savedSeq = sessionStorage.getItem('velu-last-seq');
|
|
334
|
+
if (savedConvId) state.conversationId = savedConvId;
|
|
335
|
+
if (savedConvToken) state.conversationToken = savedConvToken;
|
|
336
|
+
if (savedSeq) state.lastSeq = parseInt(savedSeq, 10) || 0;
|
|
337
|
+
if (savedMessages) messagesEl.innerHTML = savedMessages;
|
|
338
|
+
if (savedExpanded === '1') {
|
|
339
|
+
state.expanded = true;
|
|
340
|
+
panel.classList.add('velu-assistant-expanded');
|
|
341
|
+
document.documentElement.classList.add('velu-assistant-wide');
|
|
342
|
+
}
|
|
343
|
+
if (savedOpen === '1') {
|
|
344
|
+
openPanel();
|
|
345
|
+
if (state.conversationId) connectSSE();
|
|
346
|
+
}
|
|
347
|
+
} catch {}
|
|
348
|
+
|
|
349
|
+
bootstrap();
|
|
350
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export function CopyPageButton() {
|
|
6
|
+
const [label, setLabel] = useState('Copy page');
|
|
7
|
+
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
8
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
function handleClick(e: MouseEvent) {
|
|
12
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
13
|
+
setDropdownOpen(false);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
document.addEventListener('click', handleClick);
|
|
17
|
+
return () => document.removeEventListener('click', handleClick);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
function doCopy() {
|
|
21
|
+
setLabel('Copying...');
|
|
22
|
+
const titleEl = document.querySelector('h1');
|
|
23
|
+
const article = document.querySelector('[data-pagefind-body]') || document.querySelector('main');
|
|
24
|
+
let text = '';
|
|
25
|
+
if (titleEl) text = '# ' + titleEl.textContent + '\n\n';
|
|
26
|
+
if (article) text += (article as HTMLElement).innerText;
|
|
27
|
+
if (text) {
|
|
28
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
29
|
+
setLabel('Copied!');
|
|
30
|
+
setTimeout(() => setLabel('Copy page'), 1500);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
setDropdownOpen(false);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="velu-copy-page-container" ref={containerRef}>
|
|
40
|
+
<div className="velu-copy-split-btn">
|
|
41
|
+
<button
|
|
42
|
+
className="velu-copy-main-btn"
|
|
43
|
+
onClick={(e) => { e.stopPropagation(); doCopy(); }}
|
|
44
|
+
>
|
|
45
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
46
|
+
<span className="velu-copy-label">{label}</span>
|
|
47
|
+
</button>
|
|
48
|
+
<span className="velu-copy-sep" />
|
|
49
|
+
<button
|
|
50
|
+
className="velu-copy-caret-btn"
|
|
51
|
+
aria-expanded={dropdownOpen}
|
|
52
|
+
aria-haspopup="true"
|
|
53
|
+
onClick={(e) => { e.stopPropagation(); setDropdownOpen(!dropdownOpen); }}
|
|
54
|
+
>
|
|
55
|
+
<svg className="velu-copy-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{dropdownOpen && (
|
|
60
|
+
<div className="velu-copy-dropdown" onClick={(e) => e.stopPropagation()}>
|
|
61
|
+
<button className="velu-copy-option" onClick={doCopy}>
|
|
62
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
63
|
+
<div>
|
|
64
|
+
<div className="velu-copy-option-title">Copy page</div>
|
|
65
|
+
<div className="velu-copy-option-desc">Copy page as Markdown for LLMs</div>
|
|
66
|
+
</div>
|
|
67
|
+
</button>
|
|
68
|
+
<a
|
|
69
|
+
className="velu-copy-option"
|
|
70
|
+
href={`https://chatgpt.com/?prompt=Read+from+${encodeURIComponent(currentUrl)}+so+I+can+ask+questions+about+it.`}
|
|
71
|
+
target="_blank"
|
|
72
|
+
rel="noopener noreferrer"
|
|
73
|
+
>
|
|
74
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>
|
|
75
|
+
<div>
|
|
76
|
+
<div className="velu-copy-option-title">Open in ChatGPT <span className="velu-external-arrow">↗</span></div>
|
|
77
|
+
<div className="velu-copy-option-desc">Ask questions about this page</div>
|
|
78
|
+
</div>
|
|
79
|
+
</a>
|
|
80
|
+
<a
|
|
81
|
+
className="velu-copy-option"
|
|
82
|
+
href={`https://claude.ai/new?q=Read+from+${encodeURIComponent(currentUrl)}+so+I+can+ask+questions+about+it.`}
|
|
83
|
+
target="_blank"
|
|
84
|
+
rel="noopener noreferrer"
|
|
85
|
+
>
|
|
86
|
+
<svg width="18" height="18" viewBox="0 0 200 200" style={{ overflow: 'visible' }} fill="currentColor"><path d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
|
|
87
|
+
<div>
|
|
88
|
+
<div className="velu-copy-option-title">Open in Claude <span className="velu-external-arrow">↗</span></div>
|
|
89
|
+
<div className="velu-copy-option-desc">Ask questions about this page</div>
|
|
90
|
+
</div>
|
|
91
|
+
</a>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useCallback, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
interface PagefindResult {
|
|
6
|
+
url: string;
|
|
7
|
+
excerpt: string;
|
|
8
|
+
meta: { title?: string };
|
|
9
|
+
sub_results?: Array<{
|
|
10
|
+
url: string;
|
|
11
|
+
title: string;
|
|
12
|
+
excerpt: string;
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PagefindResponse {
|
|
17
|
+
results: Array<{ data: () => Promise<PagefindResult> }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PagefindInstance {
|
|
21
|
+
init: () => Promise<void>;
|
|
22
|
+
search: (query: string) => Promise<PagefindResponse>;
|
|
23
|
+
destroy: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function PagefindSearch({
|
|
27
|
+
open,
|
|
28
|
+
onOpenChange,
|
|
29
|
+
}: {
|
|
30
|
+
open: boolean;
|
|
31
|
+
onOpenChange: (open: boolean) => void;
|
|
32
|
+
}) {
|
|
33
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
34
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
35
|
+
const pagefindRef = useRef<PagefindInstance | null>(null);
|
|
36
|
+
const [query, setQuery] = useState('');
|
|
37
|
+
const [results, setResults] = useState<PagefindResult[]>([]);
|
|
38
|
+
const [loading, setLoading] = useState(false);
|
|
39
|
+
const [available, setAvailable] = useState(true);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
async function loadPagefind() {
|
|
43
|
+
try {
|
|
44
|
+
// Bypass bundler resolution — pagefind.js only exists in the static output
|
|
45
|
+
const pf = await new Function('return import("/pagefind/pagefind.js")')();
|
|
46
|
+
await pf.init();
|
|
47
|
+
pagefindRef.current = pf as unknown as PagefindInstance;
|
|
48
|
+
} catch {
|
|
49
|
+
setAvailable(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
loadPagefind();
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (open) {
|
|
57
|
+
dialogRef.current?.showModal();
|
|
58
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
59
|
+
} else {
|
|
60
|
+
dialogRef.current?.close();
|
|
61
|
+
setQuery('');
|
|
62
|
+
setResults([]);
|
|
63
|
+
}
|
|
64
|
+
}, [open]);
|
|
65
|
+
|
|
66
|
+
const search = useCallback(
|
|
67
|
+
async (q: string) => {
|
|
68
|
+
setQuery(q);
|
|
69
|
+
if (!q.trim() || !pagefindRef.current) {
|
|
70
|
+
setResults([]);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setLoading(true);
|
|
74
|
+
try {
|
|
75
|
+
const response = await pagefindRef.current.search(q);
|
|
76
|
+
const items = await Promise.all(
|
|
77
|
+
response.results.slice(0, 8).map((r) => r.data())
|
|
78
|
+
);
|
|
79
|
+
setResults(items);
|
|
80
|
+
} catch {
|
|
81
|
+
setResults([]);
|
|
82
|
+
}
|
|
83
|
+
setLoading(false);
|
|
84
|
+
},
|
|
85
|
+
[]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<dialog
|
|
90
|
+
ref={dialogRef}
|
|
91
|
+
className="fd-search-dialog"
|
|
92
|
+
onClick={(e) => {
|
|
93
|
+
if (e.target === dialogRef.current) onOpenChange(false);
|
|
94
|
+
}}
|
|
95
|
+
onClose={() => onOpenChange(false)}
|
|
96
|
+
>
|
|
97
|
+
<div className="fd-search-content">
|
|
98
|
+
<div className="fd-search-input-wrap">
|
|
99
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
100
|
+
<input
|
|
101
|
+
ref={inputRef}
|
|
102
|
+
type="text"
|
|
103
|
+
placeholder="Search documentation..."
|
|
104
|
+
value={query}
|
|
105
|
+
onChange={(e) => search(e.target.value)}
|
|
106
|
+
className="fd-search-input"
|
|
107
|
+
/>
|
|
108
|
+
<kbd className="fd-search-kbd" onClick={() => onOpenChange(false)}>
|
|
109
|
+
Esc
|
|
110
|
+
</kbd>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div className="fd-search-results">
|
|
114
|
+
{!available && (
|
|
115
|
+
<p className="fd-search-empty">
|
|
116
|
+
Search is available after building the site.
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
{available && loading && (
|
|
120
|
+
<p className="fd-search-empty">Searching…</p>
|
|
121
|
+
)}
|
|
122
|
+
{available && !loading && query && results.length === 0 && (
|
|
123
|
+
<p className="fd-search-empty">No results found.</p>
|
|
124
|
+
)}
|
|
125
|
+
{results.map((r, i) => (
|
|
126
|
+
<a
|
|
127
|
+
key={i}
|
|
128
|
+
href={r.url}
|
|
129
|
+
className="fd-search-result"
|
|
130
|
+
onClick={() => onOpenChange(false)}
|
|
131
|
+
>
|
|
132
|
+
<span className="fd-search-result-title">
|
|
133
|
+
{r.meta?.title || r.url}
|
|
134
|
+
</span>
|
|
135
|
+
<span
|
|
136
|
+
className="fd-search-result-excerpt"
|
|
137
|
+
dangerouslySetInnerHTML={{ __html: r.excerpt }}
|
|
138
|
+
/>
|
|
139
|
+
</a>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</dialog>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function SearchToggle({
|
|
148
|
+
onClick,
|
|
149
|
+
}: {
|
|
150
|
+
onClick: () => void;
|
|
151
|
+
}) {
|
|
152
|
+
return (
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className="fd-search-trigger"
|
|
156
|
+
onClick={onClick}
|
|
157
|
+
aria-label="Search"
|
|
158
|
+
>
|
|
159
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
160
|
+
<span>Search…</span>
|
|
161
|
+
<kbd>⌘K</kbd>
|
|
162
|
+
</button>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
|
2
|
+
import { getExternalTabs } from '@/lib/velu';
|
|
3
|
+
|
|
4
|
+
export function baseOptions(): BaseLayoutProps {
|
|
5
|
+
const externalTabs = getExternalTabs();
|
|
6
|
+
const links = externalTabs.map((tab: { label: string; href: string }) => ({
|
|
7
|
+
text: tab.label,
|
|
8
|
+
url: tab.href,
|
|
9
|
+
secondary: false,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
nav: {
|
|
14
|
+
title: 'Velu Docs',
|
|
15
|
+
},
|
|
16
|
+
links,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { loader } from 'fumadocs-core/source';
|
|
2
|
+
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
|
3
|
+
import { docs } from 'fumadocs-mdx:collections/server';
|
|
4
|
+
|
|
5
|
+
export const source = loader({
|
|
6
|
+
baseUrl: '/',
|
|
7
|
+
source: docs.toFumadocsSource(),
|
|
8
|
+
plugins: [lucideIconsPlugin()],
|
|
9
|
+
});
|