@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
|
@@ -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
|
|
188
|
-
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
|
|
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 ("
|
|
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('
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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 {
|
package/src/runtime/App.jsx
CHANGED
|
@@ -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
|
);
|