@veluai/velu 0.2.3 → 0.2.5

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.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "bin": "./dist/cli.js",
6
6
  "publishConfig": {
@@ -160,8 +160,32 @@ 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
+ /* Source click: SPA-navigate same-origin targets via onNavigate (keeps the chat
173
+ panel open + scrolls to the heading); otherwise let the <a href> navigate. */
174
+ function onSourceClick(e, s, onNavigate) {
175
+ const href = sourceHref(s);
176
+ if (!href) { e.preventDefault(); return; }
177
+ if (!onNavigate) return; // no SPA hook → let the browser follow the href
178
+ try {
179
+ const u = new URL(href, window.location.origin);
180
+ if (u.origin === window.location.origin) {
181
+ e.preventDefault();
182
+ onNavigate(u.pathname + u.search + u.hash);
183
+ }
184
+ } catch { /* malformed — fall back to href */ }
185
+ }
186
+
163
187
  /* Render partial tokens as paragraphs (split on \n\n, single \n → <br>). */
164
- function renderStream(tokens, sources) {
188
+ function renderStream(tokens, sources, onNavigate) {
165
189
  const paragraphs = [[]];
166
190
  for (const tk of tokens) {
167
191
  if (tk.kind === 't' && tk.v.includes('\n\n')) {
@@ -179,13 +203,14 @@ function renderStream(tokens, sources) {
179
203
  <p key={pi}>
180
204
  {para.map((tk, i) => {
181
205
  if (tk.kind === 'cite') {
206
+ const src = sources?.find((s) => s.num === tk.n);
182
207
  return (
183
208
  <a
184
209
  key={i}
185
210
  className="velu-chatbot__cite"
186
- href="#"
187
- onClick={(e) => e.preventDefault()}
188
- title={sources?.find((s) => s.num === tk.n)?.title}
211
+ href={sourceHref(src) || '#'}
212
+ onClick={(e) => onSourceClick(e, src, onNavigate)}
213
+ title={src?.title}
189
214
  >
190
215
  {tk.n}
191
216
  </a>
@@ -331,6 +356,7 @@ function AiMsg({
331
356
  onFollowup,
332
357
  messageId,
333
358
  onFeedback,
359
+ onNavigate,
334
360
  }) {
335
361
  const thinking = tokens.length === 0;
336
362
  const [vote, setVote] = useState(null); // 'up' | 'down' | null
@@ -367,7 +393,7 @@ function AiMsg({
367
393
 
368
394
  {!thinking && (
369
395
  <div className="velu-chatbot__answer">
370
- {renderStream(tokens, sources)}
396
+ {renderStream(tokens, sources, onNavigate)}
371
397
  {streaming && <span className="velu-chatbot__caret" />}
372
398
 
373
399
  {!streaming && sources?.length > 0 && (
@@ -377,8 +403,8 @@ function AiMsg({
377
403
  <a
378
404
  key={s.num}
379
405
  className="velu-chatbot__source"
380
- href="#"
381
- onClick={(e) => e.preventDefault()}
406
+ href={sourceHref(s) || '#'}
407
+ onClick={(e) => onSourceClick(e, s, onNavigate)}
382
408
  >
383
409
  <span className="velu-chatbot__source-num">{s.num}</span>
384
410
  <span className="velu-chatbot__source-meta">
@@ -489,6 +515,7 @@ export default function Chatbot({
489
515
  onClose,
490
516
  ask,
491
517
  onFeedback,
518
+ onNavigate,
492
519
  className = '',
493
520
  ...rest
494
521
  }) {
@@ -741,6 +768,7 @@ export default function Chatbot({
741
768
  streaming={m.streaming}
742
769
  messageId={m.messageId}
743
770
  onFeedback={onFeedback}
771
+ onNavigate={onNavigate}
744
772
  onFollowup={(f) => send(f)}
745
773
  />
746
774
  ),
@@ -31,20 +31,23 @@ function siteHost(explicit) {
31
31
  return typeof window !== 'undefined' ? window.location.host : '';
32
32
  }
33
33
 
34
- // Parse one raw SSE record ("event: x\ndata: {…}") into { event, data }.
34
+ // Parse one raw SSE record ("id: N\nevent: x\ndata: {…}") into { event, data, seq }.
35
35
  function parseSseRecord(raw) {
36
36
  let event = 'message';
37
+ let seq = null;
37
38
  const dataLines = [];
38
39
  for (const line of raw.split('\n')) {
39
40
  if (line.startsWith('event:')) event = line.slice(6).trim();
40
- else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
41
+ else if (line.startsWith('id:')) {
42
+ const n = Number(line.slice(3).trim());
43
+ if (Number.isFinite(n)) seq = n;
44
+ } else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
41
45
  }
42
- if (!dataLines.length) return { event, data: null };
43
- try {
44
- return { event, data: JSON.parse(dataLines.join('\n')) };
45
- } catch {
46
- return { event, data: null };
46
+ let data = null;
47
+ if (dataLines.length) {
48
+ try { data = JSON.parse(dataLines.join('\n')); } catch { data = null; }
47
49
  }
50
+ return { event, data, seq };
48
51
  }
49
52
 
50
53
  // Backend citation → the Chatbot's Source shape.
@@ -63,6 +66,11 @@ export function createDocsAssistant({ apiBase, host } = {}) {
63
66
  if (!base) return null;
64
67
  const root = `${base}/api/v1/public/ai-assistant`;
65
68
  let bootstrapped = false;
69
+ // Highest event seq seen per conversation. The events endpoint replays history
70
+ // after `after_seq`; without this a reused (multi-turn) conversation replays
71
+ // from 0 and the first (old) assistant.completed is returned for every
72
+ // follow-up turn — i.e. the answer never updates after the first message.
73
+ const lastSeqByConv = new Map();
66
74
 
67
75
  const headers = (extra) => ({
68
76
  'X-Velu-Site-Host': siteHost(host),
@@ -98,8 +106,9 @@ export function createDocsAssistant({ apiBase, host } = {}) {
98
106
 
99
107
  // Manual fetch-stream SSE parse (not EventSource): supports credentials,
100
108
  // the X-Velu-Site-Host header, and AbortController cancellation.
109
+ const afterSeq = lastSeqByConv.get(convId) ?? 0;
101
110
  const evRes = await fetch(
102
- `${root}/conversations/${convId}/events?after_seq=0&token=${encodeURIComponent(token)}`,
111
+ `${root}/conversations/${convId}/events?after_seq=${afterSeq}&token=${encodeURIComponent(token)}`,
103
112
  { credentials: 'include', headers: headers({ Accept: 'text/event-stream' }), signal },
104
113
  );
105
114
  if (!evRes.ok || !evRes.body) throw new Error(`ask-ai: stream ${evRes.status}`);
@@ -114,8 +123,11 @@ export function createDocsAssistant({ apiBase, host } = {}) {
114
123
  buf += decoder.decode(value, { stream: true });
115
124
  let sep;
116
125
  while ((sep = buf.indexOf('\n\n')) !== -1) {
117
- const { event, data } = parseSseRecord(buf.slice(0, sep));
126
+ const { event, data, seq } = parseSseRecord(buf.slice(0, sep));
118
127
  buf = buf.slice(sep + 2);
128
+ if (seq != null) {
129
+ lastSeqByConv.set(convId, Math.max(lastSeqByConv.get(convId) ?? 0, seq));
130
+ }
119
131
  if (event === 'assistant.completed') {
120
132
  const m = data?.message || {};
121
133
  yield {
@@ -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,7 @@ function DocsPage() {
1623
1623
  onClose={() => setChatOpen(false)}
1624
1624
  ask={assistant?.ask}
1625
1625
  onFeedback={assistant?.sendFeedback}
1626
+ onNavigate={navigate}
1626
1627
  />
1627
1628
  </div>
1628
1629
  );