@veluai/velu 0.2.6 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veluai/velu",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "bin": "./dist/cli.js",
6
6
  "publishConfig": {
@@ -243,16 +243,20 @@
243
243
  margin-block-start: var(--s5);
244
244
  }
245
245
 
246
- /* Sidebars / TOC scroll container: discreet thin scrollbar with a
247
- tokenised colour in every state.
246
+ /* Discreet thin scrollbar with a tokenised colour in every state — applied
247
+ GLOBALLY (the `*` selector) so every scrollable element inherits it by
248
+ default: the chatbot, dialogs, and any future component, with no
249
+ per-component opt-in. Component-specific scrollbar rules win via
250
+ specificity (e.g. the nav/TOC reveal-on-hover overrides below).
251
+ `.velu-hide-scrollbar` stays as an explicit alias for existing markup.
248
252
 
249
- No transitions: Firefox doesn't honour CSS transitions on
250
- `scrollbar-color` consistently, and the in-between interpolation +
251
- Firefox's auto-derived hover/active colours can flash a yellow/amber
252
- accent (it picks an OS / system accent when interpolating). Hard
253
- switches avoid that. Same reason every WebKit thumb pseudo-state is
254
- set explicitly otherwise Chromium can fall back to the Windows 11
255
- system accent on hover. */
253
+ No transitions: Firefox doesn't honour CSS transitions on `scrollbar-color`
254
+ consistently, and the in-between interpolation + its auto-derived
255
+ hover/active colours can flash a yellow/amber accent (an OS / system accent
256
+ when interpolating). Hard switches avoid that. Same reason every WebKit
257
+ thumb pseudo-state is set explicitly otherwise Chromium can fall back to
258
+ the Windows 11 system accent on hover. */
259
+ *,
256
260
  .velu-hide-scrollbar {
257
261
  scrollbar-width: thin;
258
262
  /* Single colour both at rest and on hover. Firefox auto-darkens on
@@ -260,17 +264,22 @@
260
264
  derived shade in the same family (no yellow flash). */
261
265
  scrollbar-color: var(--border-color) transparent;
262
266
  }
267
+ *::-webkit-scrollbar,
263
268
  .velu-hide-scrollbar::-webkit-scrollbar {
264
269
  inline-size: 6px;
265
270
  block-size: 6px;
266
271
  }
272
+ *::-webkit-scrollbar-track,
267
273
  .velu-hide-scrollbar::-webkit-scrollbar-track {
268
274
  background: transparent;
269
275
  }
276
+ *::-webkit-scrollbar-thumb,
270
277
  .velu-hide-scrollbar::-webkit-scrollbar-thumb {
271
278
  background: var(--border-color);
272
279
  border-radius: 999px;
273
280
  }
281
+ *::-webkit-scrollbar-thumb:hover,
282
+ *::-webkit-scrollbar-thumb:active,
274
283
  .velu-hide-scrollbar::-webkit-scrollbar-thumb:hover,
275
284
  .velu-hide-scrollbar::-webkit-scrollbar-thumb:active {
276
285
  background: var(--muted-color);
@@ -287,7 +287,22 @@ function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
287
287
  }
288
288
 
289
289
  /* ── History overlay ────────────────────────────────────────────────── */
290
- function HistoryPanel({ onPick, onClose }) {
290
+
291
+ /* "Today, 10:24" / "Yesterday" / "Apr 28" for a conversation timestamp. */
292
+ function whenLabel(iso) {
293
+ if (!iso) return '';
294
+ const d = new Date(iso);
295
+ if (Number.isNaN(d.getTime())) return '';
296
+ const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
297
+ const days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
298
+ if (days <= 0) {
299
+ return `Today, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
300
+ }
301
+ if (days === 1) return 'Yesterday';
302
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
303
+ }
304
+
305
+ function HistoryPanel({ items, loading, onPick, onClose }) {
291
306
  return (
292
307
  <div className="velu-chatbot__history">
293
308
  <Cluster
@@ -307,17 +322,23 @@ function HistoryPanel({ onPick, onClose }) {
307
322
  </button>
308
323
  </Cluster>
309
324
  <Stack as="div" space="var(--s-5)" className="velu-chatbot__history-list">
310
- {HISTORY.map((h) => (
311
- <button
312
- key={h.id}
313
- type="button"
314
- className="velu-chatbot__history-item"
315
- onClick={() => onPick(h)}
316
- >
317
- <span className="velu-chatbot__history-item-title">{h.title}</span>
318
- <span className="velu-chatbot__history-item-when">{h.when}</span>
319
- </button>
320
- ))}
325
+ {loading ? (
326
+ <span className="velu-chatbot__history-empty">Loading…</span>
327
+ ) : items.length === 0 ? (
328
+ <span className="velu-chatbot__history-empty">No conversations yet.</span>
329
+ ) : (
330
+ items.map((h) => (
331
+ <button
332
+ key={h.id}
333
+ type="button"
334
+ className="velu-chatbot__history-item"
335
+ onClick={() => onPick(h)}
336
+ >
337
+ <span className="velu-chatbot__history-item-title">{h.title}</span>
338
+ <span className="velu-chatbot__history-item-when">{h.when}</span>
339
+ </button>
340
+ ))
341
+ )}
321
342
  </Stack>
322
343
  </div>
323
344
  );
@@ -526,12 +547,16 @@ export default function Chatbot({
526
547
  ask,
527
548
  onFeedback,
528
549
  onNavigate,
550
+ listHistory,
551
+ loadConversation,
529
552
  className = '',
530
553
  ...rest
531
554
  }) {
532
555
  const [messages, setMessages] = useState([]);
533
556
  const [input, setInput] = useState('');
534
557
  const [historyOpen, setHistoryOpen] = useState(false);
558
+ const [history, setHistory] = useState([]);
559
+ const [historyLoading, setHistoryLoading] = useState(false);
535
560
  const bodyRef = useRef(null);
536
561
  const streamingRef = useRef(false);
537
562
  const lastSeedRef = useRef(undefined);
@@ -567,6 +592,60 @@ export default function Chatbot({
567
592
  streamingRef.current = false;
568
593
  }, []);
569
594
 
595
+ // Real history: fetch the visitor's conversations when the panel opens.
596
+ // Without a backend (dev preview) the canned demo entries show instead.
597
+ useEffect(() => {
598
+ if (!historyOpen) return;
599
+ if (!listHistory) {
600
+ setHistory(HISTORY.map((h) => ({ ...h, demo: true })));
601
+ return;
602
+ }
603
+ let stale = false;
604
+ setHistoryLoading(true);
605
+ listHistory()
606
+ .then((items) => {
607
+ if (stale) return;
608
+ setHistory(items.map((c) => ({
609
+ id: c.conversationId,
610
+ title: c.title,
611
+ when: whenLabel(c.updatedAt),
612
+ conversation: c,
613
+ })));
614
+ })
615
+ .catch(() => { if (!stale) setHistory([]); })
616
+ .finally(() => { if (!stale) setHistoryLoading(false); });
617
+ return () => { stale = true; };
618
+ }, [historyOpen, listHistory]);
619
+
620
+ // Resume a past conversation: load its turns into the panel and thread the
621
+ // conversation id so follow-up asks continue it.
622
+ const openConversation = useCallback(
623
+ async (item) => {
624
+ setHistoryOpen(false);
625
+ if (!item.conversation || !loadConversation) return; // demo entry
626
+ cancelInFlight();
627
+ try {
628
+ const { conversationId, messages: msgs } = await loadConversation(item.conversation);
629
+ convoRef.current = conversationId;
630
+ setMessages(msgs.map((m) =>
631
+ m.role === 'user'
632
+ ? { role: 'user', text: m.content }
633
+ : {
634
+ role: 'ai',
635
+ tokens: textToTokens(m.content),
636
+ sources: m.citations || [],
637
+ followups: [],
638
+ streaming: false,
639
+ messageId: m.messageId,
640
+ },
641
+ ));
642
+ } catch {
643
+ /* keep the current chat if the load fails */
644
+ }
645
+ },
646
+ [loadConversation, cancelInFlight],
647
+ );
648
+
570
649
  const send = useCallback(
571
650
  async (text) => {
572
651
  const prompt = String(text ?? '').trim();
@@ -756,8 +835,10 @@ export default function Chatbot({
756
835
 
757
836
  {historyOpen && (
758
837
  <HistoryPanel
838
+ items={history}
839
+ loading={historyLoading}
759
840
  onClose={() => setHistoryOpen(false)}
760
- onPick={() => setHistoryOpen(false)}
841
+ onPick={openConversation}
761
842
  />
762
843
  )}
763
844
 
@@ -237,6 +237,11 @@
237
237
  font-weight: var(--weight-light);
238
238
  color: var(--muted-color);
239
239
  }
240
+ .velu-chatbot__history-empty {
241
+ font-size: var(--f-h6);
242
+ color: var(--muted-color);
243
+ padding: var(--s-3);
244
+ }
240
245
 
241
246
  /* ── Body ───────────────────────────────────────────────────────────── */
242
247
  .velu-chatbot__body {
@@ -195,7 +195,56 @@ export function createDocsAssistant({ apiBase, host } = {}) {
195
195
  });
196
196
  }
197
197
 
198
- return { ask, sendFeedback };
198
+ // The visitor's recent conversations on this site (the history panel).
199
+ // Returns [{ conversationId, token, title, updatedAt }], newest first.
200
+ async function listConversations() {
201
+ await bootstrap();
202
+ const res = await fetch(`${root}/conversations`, {
203
+ credentials: 'include',
204
+ headers: headers(),
205
+ });
206
+ if (!res.ok) throw new Error(`ask-ai: conversations ${res.status}`);
207
+ const { conversations } = await res.json();
208
+ return (conversations || []).map((c) => ({
209
+ conversationId: c.conversation_id,
210
+ token: c.conversation_token,
211
+ title: c.title,
212
+ lastSeq: c.last_seq,
213
+ updatedAt: c.updated_at,
214
+ }));
215
+ }
216
+
217
+ // Load a past conversation's messages (to resume it in the panel).
218
+ // Returns { conversationId, messages: [{ role, content, citations,
219
+ // messageId }] } with citations already renumbered to the Chatbot's Source
220
+ // shape. Also records the conversation's last seq so a follow-up `ask` on
221
+ // it streams from the right offset instead of replaying history.
222
+ async function loadConversation({ conversationId, token, lastSeq }) {
223
+ const res = await fetch(`${root}/conversations/${conversationId}`, {
224
+ credentials: 'include',
225
+ headers: headers({ Authorization: `Bearer ${token}` }),
226
+ });
227
+ if (!res.ok) throw new Error(`ask-ai: conversation ${res.status}`);
228
+ const data = await res.json();
229
+ let maxSeq = lastSeq ?? 0;
230
+ const messages = (data.messages || []).map((m) => {
231
+ if (m.seq != null) maxSeq = Math.max(maxSeq, m.seq);
232
+ if (m.role !== 'assistant') {
233
+ return { role: m.role, content: m.content, messageId: m.id };
234
+ }
235
+ const renum = renumberCitations(m.content || '', mapCitations(m.citations));
236
+ return {
237
+ role: 'assistant',
238
+ content: renum.content,
239
+ citations: renum.citations,
240
+ messageId: m.id,
241
+ };
242
+ });
243
+ lastSeqByConv.set(conversationId, Math.max(lastSeqByConv.get(conversationId) ?? 0, maxSeq));
244
+ return { conversationId, messages };
245
+ }
246
+
247
+ return { ask, sendFeedback, listConversations, loadConversation };
199
248
  }
200
249
 
201
250
  export default createDocsAssistant;
@@ -1623,6 +1623,8 @@ function DocsPage() {
1623
1623
  onClose={() => setChatOpen(false)}
1624
1624
  ask={assistant?.ask}
1625
1625
  onFeedback={assistant?.sendFeedback}
1626
+ listHistory={assistant?.listConversations}
1627
+ loadConversation={assistant?.loadConversation}
1626
1628
  onNavigate={(to) => {
1627
1629
  navigate(to);
1628
1630
  // After the destination page mounts, scroll to the cited section