@veluai/velu 0.2.13 → 0.2.15
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 +80 -80
- package/dist/cli.js +37 -37
- package/package.json +64 -64
- package/runtime/velu-ui/base.css +320 -320
- package/runtime/velu-ui/components/Accordion.jsx +64 -64
- package/runtime/velu-ui/components/ApiClient.jsx +207 -207
- package/runtime/velu-ui/components/ApiField.jsx +87 -87
- package/runtime/velu-ui/components/ApiPath.jsx +63 -63
- package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
- package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
- package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
- package/runtime/velu-ui/components/AskBar.jsx +71 -71
- package/runtime/velu-ui/components/Callout.jsx +114 -114
- package/runtime/velu-ui/components/Card.jsx +131 -131
- package/runtime/velu-ui/components/Chatbot.jsx +885 -885
- package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
- package/runtime/velu-ui/components/Columns.jsx +56 -56
- package/runtime/velu-ui/components/ContextMenu.jsx +298 -273
- package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
- package/runtime/velu-ui/components/Field.jsx +81 -81
- package/runtime/velu-ui/components/Image.jsx +163 -163
- package/runtime/velu-ui/components/Logo.jsx +31 -31
- package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
- package/runtime/velu-ui/components/NavSelect.jsx +108 -108
- package/runtime/velu-ui/components/NotFound.jsx +63 -63
- package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
- package/runtime/velu-ui/components/PageFooter.jsx +145 -145
- package/runtime/velu-ui/components/PageHeader.jsx +422 -422
- package/runtime/velu-ui/components/PageNav.jsx +77 -77
- package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
- package/runtime/velu-ui/components/Prompt.jsx +115 -115
- package/runtime/velu-ui/components/Search.jsx +460 -460
- package/runtime/velu-ui/components/Sidebar.jsx +254 -254
- package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
- package/runtime/velu-ui/components/Steps.jsx +65 -65
- package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
- package/runtime/velu-ui/components/Toc.jsx +537 -537
- package/runtime/velu-ui/components/TocBar.jsx +195 -195
- package/runtime/velu-ui/components/Tree.jsx +87 -87
- package/runtime/velu-ui/components/TryItBar.jsx +102 -102
- package/runtime/velu-ui/components/accordion.css +92 -92
- package/runtime/velu-ui/components/api-page.css +208 -208
- package/runtime/velu-ui/components/api.css +635 -635
- package/runtime/velu-ui/components/ask-bar.css +94 -94
- package/runtime/velu-ui/components/card.css +105 -105
- package/runtime/velu-ui/components/chatbot.css +622 -622
- package/runtime/velu-ui/components/code-block.css +263 -263
- package/runtime/velu-ui/components/context-menu.css +173 -173
- package/runtime/velu-ui/components/docs-layout.css +822 -822
- package/runtime/velu-ui/components/field.css +82 -82
- package/runtime/velu-ui/components/image.css +237 -237
- package/runtime/velu-ui/components/nav-select.css +157 -157
- package/runtime/velu-ui/components/not-found.css +94 -94
- package/runtime/velu-ui/components/page-feedback.css +241 -241
- package/runtime/velu-ui/components/page-footer.css +130 -130
- package/runtime/velu-ui/components/page-header.css +558 -558
- package/runtime/velu-ui/components/page-nav.css +50 -50
- package/runtime/velu-ui/components/powered-by.css +92 -92
- package/runtime/velu-ui/components/prompt.css +99 -99
- package/runtime/velu-ui/components/search.css +307 -307
- package/runtime/velu-ui/components/sidebar.css +205 -205
- package/runtime/velu-ui/components/steps.css +77 -77
- package/runtime/velu-ui/components/theme-toggle.css +102 -102
- package/runtime/velu-ui/components/toc-bar.css +234 -234
- package/runtime/velu-ui/components/tree.css +49 -49
- package/runtime/velu-ui/index.js +54 -54
- package/runtime/velu-ui/lib/api-send.js +92 -92
- package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
- package/runtime/velu-ui/lib/component-schemas.js +100 -100
- package/runtime/velu-ui/lib/copyText.js +64 -64
- package/runtime/velu-ui/lib/docs-assistant.js +250 -250
- package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
- package/runtime/velu-ui/lib/pagefind.js +113 -113
- package/runtime/velu-ui/lib/prism-langs.js +957 -957
- package/runtime/velu-ui/lib/prism-loader.js +74 -74
- package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
- package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
- package/runtime/velu-ui/mdx-components.jsx +105 -105
- package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
- package/runtime/velu-ui/primitives/Stack.jsx +63 -63
- package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
- package/runtime/velu-ui/primitives/stack.css +3 -3
- package/runtime/velu-ui/primitives/switcher.css +25 -25
- package/runtime/velu-ui/styles.css +46 -46
- package/runtime/velu-ui/tokens.css +4 -4
- package/schema/velu.schema.json +423 -423
- package/src/lib/extract-mdx-error.js +170 -170
- package/src/lib/issues.js +159 -159
- package/src/lib/known-components.js +34 -34
- package/src/navigation.js +443 -443
- package/src/runtime/App.jsx +1669 -1668
- package/src/runtime/ErrorBoundary.jsx +54 -54
- package/src/runtime/client-entry.jsx +22 -22
- package/src/runtime/server-entry.jsx +16 -16
- package/src/template.html +48 -48
- package/templates/starter/ai-tools/claude-code.mdx +26 -26
- package/templates/starter/ai-tools/cursor.mdx +17 -17
- package/templates/starter/api-reference/introduction.mdx +43 -43
- package/templates/starter/development.mdx +19 -19
- package/templates/starter/essentials/code.mdx +29 -29
- package/templates/starter/essentials/images.mdx +29 -29
- package/templates/starter/essentials/markdown.mdx +25 -25
- package/templates/starter/essentials/navigation.mdx +39 -39
- package/templates/starter/essentials/settings.mdx +30 -30
- package/templates/starter/favicon.svg +6 -6
- package/templates/starter/index.mdx +31 -31
- package/templates/starter/openapi.json +160 -160
- package/templates/starter/quickstart.mdx +31 -31
- package/templates/starter/velu.json +41 -41
|
@@ -1,250 +1,250 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* docs-assistant — runtime client for the published docs site's "Ask AI",
|
|
3
|
-
* talking to the backend's PUBLIC docs-AI-assistant API:
|
|
4
|
-
*
|
|
5
|
-
* GET /api/v1/public/ai-assistant/bootstrap (visitor cookie + tenant)
|
|
6
|
-
* POST /api/v1/public/ai-assistant/messages ({ message, conversation_id })
|
|
7
|
-
* GET /api/v1/public/ai-assistant/conversations/{id}/events (SSE; ?token=…)
|
|
8
|
-
* POST|DELETE /api/v1/public/ai-assistant/messages/{id}/feedback ({ rating })
|
|
9
|
-
*
|
|
10
|
-
* `createDocsAssistant({ apiBase, host })` returns `{ ask, sendFeedback }`,
|
|
11
|
-
* shaped to drop straight into <Chatbot ask onFeedback>. `ask` is an async
|
|
12
|
-
* generator yielding the AskEvent shape Chatbot expects.
|
|
13
|
-
*
|
|
14
|
-
* Contract notes (from the backend):
|
|
15
|
-
* - bootstrap sets an **httponly visitor cookie**, so every call uses
|
|
16
|
-
* `credentials: 'include'`. Cross-origin therefore needs CORS w/ credentials
|
|
17
|
-
* AND a cross-site-capable cookie (SameSite=None) — today the cookie is
|
|
18
|
-
* SameSite=Lax, so the API must be same-site with the docs (proxied under the
|
|
19
|
-
* docs domain) until the backend relaxes it. See the plan's open questions.
|
|
20
|
-
* - tenant is resolved from the `X-Velu-Site-Host` header (falls back to Origin).
|
|
21
|
-
* - POST /messages returns immediately; the answer is generated in the
|
|
22
|
-
* background and arrives as a SINGLE `assistant.completed` SSE event with the
|
|
23
|
-
* full content + citations (NOT token-by-token). So `ask` yields one content
|
|
24
|
-
* event when the answer is ready (the panel shows its "thinking" state until).
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
const trimSlash = (s) => String(s || '').replace(/\/+$/, '');
|
|
28
|
-
|
|
29
|
-
function siteHost(explicit) {
|
|
30
|
-
if (explicit) return explicit;
|
|
31
|
-
return typeof window !== 'undefined' ? window.location.host : '';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Parse one raw SSE record ("id: N\nevent: x\ndata: {…}") into { event, data, seq }.
|
|
35
|
-
function parseSseRecord(raw) {
|
|
36
|
-
let event = 'message';
|
|
37
|
-
let seq = null;
|
|
38
|
-
const dataLines = [];
|
|
39
|
-
for (const line of raw.split('\n')) {
|
|
40
|
-
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
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(/^ /, ''));
|
|
45
|
-
}
|
|
46
|
-
let data = null;
|
|
47
|
-
if (dataLines.length) {
|
|
48
|
-
try { data = JSON.parse(dataLines.join('\n')); } catch { data = null; }
|
|
49
|
-
}
|
|
50
|
-
return { event, data, seq };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Backend citation → the Chatbot's Source shape.
|
|
54
|
-
function mapCitations(cits) {
|
|
55
|
-
if (!Array.isArray(cits)) return undefined;
|
|
56
|
-
return cits.map((c, i) => ({
|
|
57
|
-
num: c.num ?? c.index ?? i + 1,
|
|
58
|
-
title: c.title,
|
|
59
|
-
path: c.route_path ?? c.path,
|
|
60
|
-
url: c.url,
|
|
61
|
-
}));
|
|
62
|
-
}
|
|
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
|
-
|
|
89
|
-
export function createDocsAssistant({ apiBase, host } = {}) {
|
|
90
|
-
const base = trimSlash(apiBase);
|
|
91
|
-
if (!base) return null;
|
|
92
|
-
const root = `${base}/api/v1/public/ai-assistant`;
|
|
93
|
-
let bootstrapped = false;
|
|
94
|
-
// Highest event seq seen per conversation. The events endpoint replays history
|
|
95
|
-
// after `after_seq`; without this a reused (multi-turn) conversation replays
|
|
96
|
-
// from 0 and the first (old) assistant.completed is returned for every
|
|
97
|
-
// follow-up turn — i.e. the answer never updates after the first message.
|
|
98
|
-
const lastSeqByConv = new Map();
|
|
99
|
-
|
|
100
|
-
const headers = (extra) => ({
|
|
101
|
-
'X-Velu-Site-Host': siteHost(host),
|
|
102
|
-
...extra,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
async function bootstrap() {
|
|
106
|
-
if (bootstrapped) return;
|
|
107
|
-
// Sets the httponly visitor cookie used by every subsequent call.
|
|
108
|
-
await fetch(`${root}/bootstrap`, {
|
|
109
|
-
method: 'GET',
|
|
110
|
-
credentials: 'include',
|
|
111
|
-
headers: headers(),
|
|
112
|
-
});
|
|
113
|
-
bootstrapped = true;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Async generator: yields { conversationId } early, then one
|
|
117
|
-
// { delta, citations, messageId, conversationId } when the answer is ready.
|
|
118
|
-
async function* ask(prompt, { conversationId, signal } = {}) {
|
|
119
|
-
await bootstrap();
|
|
120
|
-
|
|
121
|
-
const res = await fetch(`${root}/messages`, {
|
|
122
|
-
method: 'POST',
|
|
123
|
-
credentials: 'include',
|
|
124
|
-
headers: headers({ 'Content-Type': 'application/json' }),
|
|
125
|
-
body: JSON.stringify({ message: prompt, conversation_id: conversationId ?? null }),
|
|
126
|
-
signal,
|
|
127
|
-
});
|
|
128
|
-
if (!res.ok) throw new Error(`ask-ai: messages ${res.status}`);
|
|
129
|
-
const { conversation_id: convId, conversation_token: token } = await res.json();
|
|
130
|
-
yield { conversationId: convId };
|
|
131
|
-
|
|
132
|
-
// Manual fetch-stream SSE parse (not EventSource): supports credentials,
|
|
133
|
-
// the X-Velu-Site-Host header, and AbortController cancellation.
|
|
134
|
-
const afterSeq = lastSeqByConv.get(convId) ?? 0;
|
|
135
|
-
const evRes = await fetch(
|
|
136
|
-
`${root}/conversations/${convId}/events?after_seq=${afterSeq}&token=${encodeURIComponent(token)}`,
|
|
137
|
-
{ credentials: 'include', headers: headers({ Accept: 'text/event-stream' }), signal },
|
|
138
|
-
);
|
|
139
|
-
if (!evRes.ok || !evRes.body) throw new Error(`ask-ai: stream ${evRes.status}`);
|
|
140
|
-
|
|
141
|
-
const reader = evRes.body.getReader();
|
|
142
|
-
const decoder = new TextDecoder();
|
|
143
|
-
let buf = '';
|
|
144
|
-
try {
|
|
145
|
-
while (true) {
|
|
146
|
-
const { value, done } = await reader.read();
|
|
147
|
-
if (done) break;
|
|
148
|
-
buf += decoder.decode(value, { stream: true });
|
|
149
|
-
let sep;
|
|
150
|
-
while ((sep = buf.indexOf('\n\n')) !== -1) {
|
|
151
|
-
const { event, data, seq } = parseSseRecord(buf.slice(0, sep));
|
|
152
|
-
buf = buf.slice(sep + 2);
|
|
153
|
-
if (seq != null) {
|
|
154
|
-
lastSeqByConv.set(convId, Math.max(lastSeqByConv.get(convId) ?? 0, seq));
|
|
155
|
-
}
|
|
156
|
-
if (event === 'assistant.completed') {
|
|
157
|
-
const m = data?.message || {};
|
|
158
|
-
const renum = renumberCitations(m.content || '', mapCitations(m.citations));
|
|
159
|
-
yield {
|
|
160
|
-
delta: renum.content,
|
|
161
|
-
citations: renum.citations,
|
|
162
|
-
messageId: m.id,
|
|
163
|
-
conversationId: convId,
|
|
164
|
-
};
|
|
165
|
-
return; // answer delivered — done
|
|
166
|
-
}
|
|
167
|
-
if (event === 'assistant.error') {
|
|
168
|
-
throw new Error(data?.error || 'ask-ai: generation failed');
|
|
169
|
-
}
|
|
170
|
-
// message.created (echoed user turn) and ping are ignored.
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
} finally {
|
|
174
|
-
try {
|
|
175
|
-
await reader.cancel();
|
|
176
|
-
} catch {
|
|
177
|
-
/* already closed */
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// rating: 'up' | 'down' to set, null to retract.
|
|
183
|
-
async function sendFeedback(messageId, rating) {
|
|
184
|
-
if (messageId == null) return;
|
|
185
|
-
const url = `${root}/messages/${messageId}/feedback`;
|
|
186
|
-
if (rating == null) {
|
|
187
|
-
await fetch(url, { method: 'DELETE', credentials: 'include', headers: headers() });
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
await fetch(url, {
|
|
191
|
-
method: 'POST',
|
|
192
|
-
credentials: 'include',
|
|
193
|
-
headers: headers({ 'Content-Type': 'application/json' }),
|
|
194
|
-
body: JSON.stringify({ rating }),
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
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 };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export default createDocsAssistant;
|
|
1
|
+
/**
|
|
2
|
+
* docs-assistant — runtime client for the published docs site's "Ask AI",
|
|
3
|
+
* talking to the backend's PUBLIC docs-AI-assistant API:
|
|
4
|
+
*
|
|
5
|
+
* GET /api/v1/public/ai-assistant/bootstrap (visitor cookie + tenant)
|
|
6
|
+
* POST /api/v1/public/ai-assistant/messages ({ message, conversation_id })
|
|
7
|
+
* GET /api/v1/public/ai-assistant/conversations/{id}/events (SSE; ?token=…)
|
|
8
|
+
* POST|DELETE /api/v1/public/ai-assistant/messages/{id}/feedback ({ rating })
|
|
9
|
+
*
|
|
10
|
+
* `createDocsAssistant({ apiBase, host })` returns `{ ask, sendFeedback }`,
|
|
11
|
+
* shaped to drop straight into <Chatbot ask onFeedback>. `ask` is an async
|
|
12
|
+
* generator yielding the AskEvent shape Chatbot expects.
|
|
13
|
+
*
|
|
14
|
+
* Contract notes (from the backend):
|
|
15
|
+
* - bootstrap sets an **httponly visitor cookie**, so every call uses
|
|
16
|
+
* `credentials: 'include'`. Cross-origin therefore needs CORS w/ credentials
|
|
17
|
+
* AND a cross-site-capable cookie (SameSite=None) — today the cookie is
|
|
18
|
+
* SameSite=Lax, so the API must be same-site with the docs (proxied under the
|
|
19
|
+
* docs domain) until the backend relaxes it. See the plan's open questions.
|
|
20
|
+
* - tenant is resolved from the `X-Velu-Site-Host` header (falls back to Origin).
|
|
21
|
+
* - POST /messages returns immediately; the answer is generated in the
|
|
22
|
+
* background and arrives as a SINGLE `assistant.completed` SSE event with the
|
|
23
|
+
* full content + citations (NOT token-by-token). So `ask` yields one content
|
|
24
|
+
* event when the answer is ready (the panel shows its "thinking" state until).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const trimSlash = (s) => String(s || '').replace(/\/+$/, '');
|
|
28
|
+
|
|
29
|
+
function siteHost(explicit) {
|
|
30
|
+
if (explicit) return explicit;
|
|
31
|
+
return typeof window !== 'undefined' ? window.location.host : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parse one raw SSE record ("id: N\nevent: x\ndata: {…}") into { event, data, seq }.
|
|
35
|
+
function parseSseRecord(raw) {
|
|
36
|
+
let event = 'message';
|
|
37
|
+
let seq = null;
|
|
38
|
+
const dataLines = [];
|
|
39
|
+
for (const line of raw.split('\n')) {
|
|
40
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
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(/^ /, ''));
|
|
45
|
+
}
|
|
46
|
+
let data = null;
|
|
47
|
+
if (dataLines.length) {
|
|
48
|
+
try { data = JSON.parse(dataLines.join('\n')); } catch { data = null; }
|
|
49
|
+
}
|
|
50
|
+
return { event, data, seq };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Backend citation → the Chatbot's Source shape.
|
|
54
|
+
function mapCitations(cits) {
|
|
55
|
+
if (!Array.isArray(cits)) return undefined;
|
|
56
|
+
return cits.map((c, i) => ({
|
|
57
|
+
num: c.num ?? c.index ?? i + 1,
|
|
58
|
+
title: c.title,
|
|
59
|
+
path: c.route_path ?? c.path,
|
|
60
|
+
url: c.url,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
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
|
+
|
|
89
|
+
export function createDocsAssistant({ apiBase, host } = {}) {
|
|
90
|
+
const base = trimSlash(apiBase);
|
|
91
|
+
if (!base) return null;
|
|
92
|
+
const root = `${base}/api/v1/public/ai-assistant`;
|
|
93
|
+
let bootstrapped = false;
|
|
94
|
+
// Highest event seq seen per conversation. The events endpoint replays history
|
|
95
|
+
// after `after_seq`; without this a reused (multi-turn) conversation replays
|
|
96
|
+
// from 0 and the first (old) assistant.completed is returned for every
|
|
97
|
+
// follow-up turn — i.e. the answer never updates after the first message.
|
|
98
|
+
const lastSeqByConv = new Map();
|
|
99
|
+
|
|
100
|
+
const headers = (extra) => ({
|
|
101
|
+
'X-Velu-Site-Host': siteHost(host),
|
|
102
|
+
...extra,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
async function bootstrap() {
|
|
106
|
+
if (bootstrapped) return;
|
|
107
|
+
// Sets the httponly visitor cookie used by every subsequent call.
|
|
108
|
+
await fetch(`${root}/bootstrap`, {
|
|
109
|
+
method: 'GET',
|
|
110
|
+
credentials: 'include',
|
|
111
|
+
headers: headers(),
|
|
112
|
+
});
|
|
113
|
+
bootstrapped = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Async generator: yields { conversationId } early, then one
|
|
117
|
+
// { delta, citations, messageId, conversationId } when the answer is ready.
|
|
118
|
+
async function* ask(prompt, { conversationId, signal } = {}) {
|
|
119
|
+
await bootstrap();
|
|
120
|
+
|
|
121
|
+
const res = await fetch(`${root}/messages`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
credentials: 'include',
|
|
124
|
+
headers: headers({ 'Content-Type': 'application/json' }),
|
|
125
|
+
body: JSON.stringify({ message: prompt, conversation_id: conversationId ?? null }),
|
|
126
|
+
signal,
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok) throw new Error(`ask-ai: messages ${res.status}`);
|
|
129
|
+
const { conversation_id: convId, conversation_token: token } = await res.json();
|
|
130
|
+
yield { conversationId: convId };
|
|
131
|
+
|
|
132
|
+
// Manual fetch-stream SSE parse (not EventSource): supports credentials,
|
|
133
|
+
// the X-Velu-Site-Host header, and AbortController cancellation.
|
|
134
|
+
const afterSeq = lastSeqByConv.get(convId) ?? 0;
|
|
135
|
+
const evRes = await fetch(
|
|
136
|
+
`${root}/conversations/${convId}/events?after_seq=${afterSeq}&token=${encodeURIComponent(token)}`,
|
|
137
|
+
{ credentials: 'include', headers: headers({ Accept: 'text/event-stream' }), signal },
|
|
138
|
+
);
|
|
139
|
+
if (!evRes.ok || !evRes.body) throw new Error(`ask-ai: stream ${evRes.status}`);
|
|
140
|
+
|
|
141
|
+
const reader = evRes.body.getReader();
|
|
142
|
+
const decoder = new TextDecoder();
|
|
143
|
+
let buf = '';
|
|
144
|
+
try {
|
|
145
|
+
while (true) {
|
|
146
|
+
const { value, done } = await reader.read();
|
|
147
|
+
if (done) break;
|
|
148
|
+
buf += decoder.decode(value, { stream: true });
|
|
149
|
+
let sep;
|
|
150
|
+
while ((sep = buf.indexOf('\n\n')) !== -1) {
|
|
151
|
+
const { event, data, seq } = parseSseRecord(buf.slice(0, sep));
|
|
152
|
+
buf = buf.slice(sep + 2);
|
|
153
|
+
if (seq != null) {
|
|
154
|
+
lastSeqByConv.set(convId, Math.max(lastSeqByConv.get(convId) ?? 0, seq));
|
|
155
|
+
}
|
|
156
|
+
if (event === 'assistant.completed') {
|
|
157
|
+
const m = data?.message || {};
|
|
158
|
+
const renum = renumberCitations(m.content || '', mapCitations(m.citations));
|
|
159
|
+
yield {
|
|
160
|
+
delta: renum.content,
|
|
161
|
+
citations: renum.citations,
|
|
162
|
+
messageId: m.id,
|
|
163
|
+
conversationId: convId,
|
|
164
|
+
};
|
|
165
|
+
return; // answer delivered — done
|
|
166
|
+
}
|
|
167
|
+
if (event === 'assistant.error') {
|
|
168
|
+
throw new Error(data?.error || 'ask-ai: generation failed');
|
|
169
|
+
}
|
|
170
|
+
// message.created (echoed user turn) and ping are ignored.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
try {
|
|
175
|
+
await reader.cancel();
|
|
176
|
+
} catch {
|
|
177
|
+
/* already closed */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// rating: 'up' | 'down' to set, null to retract.
|
|
183
|
+
async function sendFeedback(messageId, rating) {
|
|
184
|
+
if (messageId == null) return;
|
|
185
|
+
const url = `${root}/messages/${messageId}/feedback`;
|
|
186
|
+
if (rating == null) {
|
|
187
|
+
await fetch(url, { method: 'DELETE', credentials: 'include', headers: headers() });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await fetch(url, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
credentials: 'include',
|
|
193
|
+
headers: headers({ 'Content-Type': 'application/json' }),
|
|
194
|
+
body: JSON.stringify({ rating }),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
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 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export default createDocsAssistant;
|