@veluai/velu 0.2.4 → 0.2.6

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.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "bin": "./dist/cli.js",
6
6
  "publishConfig": {
@@ -160,8 +160,42 @@ function tokenizeAnswer(answer) {
160
160
  return tokens;
161
161
  }
162
162
 
163
+ /* A citation's in-site target. Real backend sources carry a `url` (and `path`
164
+ is a route like "/essentials/code"); the demo's `path` is a fake file path,
165
+ so those stay inert. */
166
+ function sourceHref(s) {
167
+ if (s?.url) return s.url;
168
+ if (s?.path && s.path.startsWith('/')) return s.path;
169
+ return null;
170
+ }
171
+
172
+ /* A readable label for a source: the section heading if present, else a title
173
+ derived from the route (e.g. "/essentials/settings" → "Settings"). */
174
+ function citeTitle(s) {
175
+ if (s?.title) return s.title;
176
+ const path = (s?.path || '').replace(/[#?].*$/, '');
177
+ const seg = path.split('/').filter(Boolean).pop();
178
+ if (!seg) return 'Documentation';
179
+ return seg.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
180
+ }
181
+
182
+ /* Source click: SPA-navigate same-origin targets via onNavigate (keeps the chat
183
+ panel open + scrolls to the heading); otherwise let the <a href> navigate. */
184
+ function onSourceClick(e, s, onNavigate) {
185
+ const href = sourceHref(s);
186
+ if (!href) { e.preventDefault(); return; }
187
+ if (!onNavigate) return; // no SPA hook → let the browser follow the href
188
+ try {
189
+ const u = new URL(href, window.location.origin);
190
+ if (u.origin === window.location.origin) {
191
+ e.preventDefault();
192
+ onNavigate(u.pathname + u.search + u.hash);
193
+ }
194
+ } catch { /* malformed — fall back to href */ }
195
+ }
196
+
163
197
  /* Render partial tokens as paragraphs (split on \n\n, single \n → <br>). */
164
- function renderStream(tokens, sources) {
198
+ function renderStream(tokens, sources, onNavigate) {
165
199
  const paragraphs = [[]];
166
200
  for (const tk of tokens) {
167
201
  if (tk.kind === 't' && tk.v.includes('\n\n')) {
@@ -179,13 +213,14 @@ function renderStream(tokens, sources) {
179
213
  <p key={pi}>
180
214
  {para.map((tk, i) => {
181
215
  if (tk.kind === 'cite') {
216
+ const src = sources?.find((s) => s.num === tk.n);
182
217
  return (
183
218
  <a
184
219
  key={i}
185
220
  className="velu-chatbot__cite"
186
- href="#"
187
- onClick={(e) => e.preventDefault()}
188
- title={sources?.find((s) => s.num === tk.n)?.title}
221
+ href={sourceHref(src) || '#'}
222
+ onClick={(e) => onSourceClick(e, src, onNavigate)}
223
+ title={citeTitle(src)}
189
224
  >
190
225
  {tk.n}
191
226
  </a>
@@ -331,6 +366,7 @@ function AiMsg({
331
366
  onFollowup,
332
367
  messageId,
333
368
  onFeedback,
369
+ onNavigate,
334
370
  }) {
335
371
  const thinking = tokens.length === 0;
336
372
  const [vote, setVote] = useState(null); // 'up' | 'down' | null
@@ -367,7 +403,7 @@ function AiMsg({
367
403
 
368
404
  {!thinking && (
369
405
  <div className="velu-chatbot__answer">
370
- {renderStream(tokens, sources)}
406
+ {renderStream(tokens, sources, onNavigate)}
371
407
  {streaming && <span className="velu-chatbot__caret" />}
372
408
 
373
409
  {!streaming && sources?.length > 0 && (
@@ -377,12 +413,12 @@ function AiMsg({
377
413
  <a
378
414
  key={s.num}
379
415
  className="velu-chatbot__source"
380
- href="#"
381
- onClick={(e) => e.preventDefault()}
416
+ href={sourceHref(s) || '#'}
417
+ onClick={(e) => onSourceClick(e, s, onNavigate)}
382
418
  >
383
419
  <span className="velu-chatbot__source-num">{s.num}</span>
384
420
  <span className="velu-chatbot__source-meta">
385
- <span className="velu-chatbot__source-title">{s.title}</span>
421
+ <span className="velu-chatbot__source-title">{citeTitle(s)}</span>
386
422
  <span className="velu-chatbot__source-path">{s.path}</span>
387
423
  </span>
388
424
  </a>
@@ -489,6 +525,7 @@ export default function Chatbot({
489
525
  onClose,
490
526
  ask,
491
527
  onFeedback,
528
+ onNavigate,
492
529
  className = '',
493
530
  ...rest
494
531
  }) {
@@ -741,6 +778,7 @@ export default function Chatbot({
741
778
  streaming={m.streaming}
742
779
  messageId={m.messageId}
743
780
  onFeedback={onFeedback}
781
+ onNavigate={onNavigate}
744
782
  onFollowup={(f) => send(f)}
745
783
  />
746
784
  ),
@@ -61,6 +61,31 @@ function mapCitations(cits) {
61
61
  }));
62
62
  }
63
63
 
64
+ // Renumber cited sources 1,2,3… in order of first appearance and rewrite the
65
+ // [n] markers in the text to match. The backend keeps the raw RAG ranks (the
66
+ // LLM may cite [1] and [5]); this makes them sequential and drops uncited ones.
67
+ function renumberCitations(content, citations) {
68
+ const text = content || '';
69
+ const order = [];
70
+ const re = /\[(\d+)\]/g;
71
+ let m;
72
+ while ((m = re.exec(text)) !== null) {
73
+ const n = Number(m[1]);
74
+ if (!order.includes(n)) order.push(n);
75
+ }
76
+ if (!order.length) return { content: text, citations: [] };
77
+ const remap = new Map(order.map((old, i) => [old, i + 1]));
78
+ const newContent = text.replace(/\[(\d+)\]/g, (full, d) =>
79
+ remap.has(Number(d)) ? `[${remap.get(Number(d))}]` : full,
80
+ );
81
+ const byNum = new Map((citations || []).map((c) => [c.num, c]));
82
+ const newCitations = order
83
+ .map((old) => byNum.get(old))
84
+ .filter(Boolean)
85
+ .map((c, i) => ({ ...c, num: i + 1 }));
86
+ return { content: newContent, citations: newCitations };
87
+ }
88
+
64
89
  export function createDocsAssistant({ apiBase, host } = {}) {
65
90
  const base = trimSlash(apiBase);
66
91
  if (!base) return null;
@@ -130,9 +155,10 @@ export function createDocsAssistant({ apiBase, host } = {}) {
130
155
  }
131
156
  if (event === 'assistant.completed') {
132
157
  const m = data?.message || {};
158
+ const renum = renumberCitations(m.content || '', mapCitations(m.citations));
133
159
  yield {
134
- delta: m.content || '',
135
- citations: mapCitations(m.citations),
160
+ delta: renum.content,
161
+ citations: renum.citations,
136
162
  messageId: m.id,
137
163
  conversationId: convId,
138
164
  };
@@ -1558,7 +1558,7 @@ function DocsPage() {
1558
1558
  approaches (see IntersectionObserver above). */}
1559
1559
  {/* Ask-a-question bar — opens the AI chatbot, which runs on the
1560
1560
  deployed site. Hidden in the local dev preview. */}
1561
- {!IS_DEV_PREVIEW && (
1561
+ {!IS_DEV_PREVIEW && !chatOpen && (
1562
1562
  <AskBar
1563
1563
  onSubmit={askAI}
1564
1564
  style={{
@@ -1623,6 +1623,21 @@ function DocsPage() {
1623
1623
  onClose={() => setChatOpen(false)}
1624
1624
  ask={assistant?.ask}
1625
1625
  onFeedback={assistant?.sendFeedback}
1626
+ onNavigate={(to) => {
1627
+ navigate(to);
1628
+ // After the destination page mounts, scroll to the cited section
1629
+ // (its heading id) — or the top of the page when there's no anchor.
1630
+ const hash = to.includes('#') ? to.slice(to.indexOf('#') + 1) : '';
1631
+ let tries = 0;
1632
+ const go = () => {
1633
+ if (hash) {
1634
+ if (document.getElementById(hash)) { scrollTo(hash); return; }
1635
+ if (tries++ < 10) { setTimeout(go, 60); return; }
1636
+ }
1637
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1638
+ };
1639
+ setTimeout(go, 50);
1640
+ }}
1626
1641
  />
1627
1642
  </div>
1628
1643
  );