@veluai/velu 0.2.25 → 0.2.27

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.
Files changed (114) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +44 -44
  3. package/package.json +64 -64
  4. package/runtime/velu-ui/base.css +320 -320
  5. package/runtime/velu-ui/components/Accordion.jsx +64 -64
  6. package/runtime/velu-ui/components/ApiClient.jsx +207 -207
  7. package/runtime/velu-ui/components/ApiField.jsx +87 -87
  8. package/runtime/velu-ui/components/ApiPath.jsx +63 -63
  9. package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
  10. package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
  11. package/runtime/velu-ui/components/ApiSidebar.jsx +125 -125
  12. package/runtime/velu-ui/components/AskBar.jsx +71 -71
  13. package/runtime/velu-ui/components/Callout.jsx +114 -114
  14. package/runtime/velu-ui/components/Card.jsx +131 -131
  15. package/runtime/velu-ui/components/ChangelogFilters.jsx +75 -75
  16. package/runtime/velu-ui/components/Chatbot.jsx +972 -972
  17. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  18. package/runtime/velu-ui/components/Columns.jsx +56 -56
  19. package/runtime/velu-ui/components/ContextMenu.jsx +327 -327
  20. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  21. package/runtime/velu-ui/components/Field.jsx +81 -81
  22. package/runtime/velu-ui/components/Image.jsx +163 -163
  23. package/runtime/velu-ui/components/Logo.jsx +31 -31
  24. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  25. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  26. package/runtime/velu-ui/components/NotFound.jsx +63 -63
  27. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  28. package/runtime/velu-ui/components/PageFooter.jsx +145 -145
  29. package/runtime/velu-ui/components/PageHeader.jsx +422 -422
  30. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  31. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  32. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  33. package/runtime/velu-ui/components/Search.jsx +460 -460
  34. package/runtime/velu-ui/components/Sidebar.jsx +246 -246
  35. package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
  36. package/runtime/velu-ui/components/Steps.jsx +65 -65
  37. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  38. package/runtime/velu-ui/components/Toc.jsx +537 -537
  39. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  40. package/runtime/velu-ui/components/Tree.jsx +87 -87
  41. package/runtime/velu-ui/components/TryItBar.jsx +102 -102
  42. package/runtime/velu-ui/components/Update.jsx +92 -92
  43. package/runtime/velu-ui/components/accordion.css +92 -92
  44. package/runtime/velu-ui/components/api-page.css +208 -208
  45. package/runtime/velu-ui/components/api.css +635 -635
  46. package/runtime/velu-ui/components/ask-bar.css +103 -103
  47. package/runtime/velu-ui/components/card.css +105 -105
  48. package/runtime/velu-ui/components/changelog-filters.css +43 -43
  49. package/runtime/velu-ui/components/chatbot.css +687 -687
  50. package/runtime/velu-ui/components/code-block.css +263 -263
  51. package/runtime/velu-ui/components/context-menu.css +199 -199
  52. package/runtime/velu-ui/components/docs-layout.css +822 -822
  53. package/runtime/velu-ui/components/field.css +82 -82
  54. package/runtime/velu-ui/components/image.css +237 -237
  55. package/runtime/velu-ui/components/nav-select.css +157 -157
  56. package/runtime/velu-ui/components/not-found.css +94 -94
  57. package/runtime/velu-ui/components/page-feedback.css +261 -261
  58. package/runtime/velu-ui/components/page-footer.css +130 -130
  59. package/runtime/velu-ui/components/page-header.css +558 -558
  60. package/runtime/velu-ui/components/page-nav.css +50 -50
  61. package/runtime/velu-ui/components/powered-by.css +92 -92
  62. package/runtime/velu-ui/components/prompt.css +99 -99
  63. package/runtime/velu-ui/components/search.css +307 -307
  64. package/runtime/velu-ui/components/sidebar.css +205 -205
  65. package/runtime/velu-ui/components/steps.css +77 -77
  66. package/runtime/velu-ui/components/theme-toggle.css +102 -102
  67. package/runtime/velu-ui/components/toc-bar.css +234 -234
  68. package/runtime/velu-ui/components/tree.css +49 -49
  69. package/runtime/velu-ui/components/update.css +111 -111
  70. package/runtime/velu-ui/index.js +58 -58
  71. package/runtime/velu-ui/lib/api-send.js +118 -118
  72. package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
  73. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  74. package/runtime/velu-ui/lib/copyText.js +64 -64
  75. package/runtime/velu-ui/lib/docs-assistant.js +273 -273
  76. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  77. package/runtime/velu-ui/lib/page-feedback.js +72 -72
  78. package/runtime/velu-ui/lib/pagefind.js +116 -116
  79. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  80. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  81. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  82. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  83. package/runtime/velu-ui/mdx-components.jsx +107 -107
  84. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  85. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  86. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  87. package/runtime/velu-ui/primitives/stack.css +3 -3
  88. package/runtime/velu-ui/primitives/switcher.css +25 -25
  89. package/runtime/velu-ui/styles.css +48 -48
  90. package/runtime/velu-ui/tokens.css +4 -4
  91. package/schema/velu.schema.json +419 -419
  92. package/src/lib/extract-mdx-error.js +170 -170
  93. package/src/lib/issues.js +159 -159
  94. package/src/lib/known-components.js +35 -35
  95. package/src/navigation.js +459 -459
  96. package/src/runtime/App.jsx +1754 -1754
  97. package/src/runtime/ErrorBoundary.jsx +54 -54
  98. package/src/runtime/client-entry.jsx +27 -27
  99. package/src/runtime/server-entry.jsx +24 -24
  100. package/src/template.html +48 -48
  101. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  102. package/templates/starter/ai-tools/cursor.mdx +17 -17
  103. package/templates/starter/api-reference/introduction.mdx +43 -43
  104. package/templates/starter/development.mdx +19 -19
  105. package/templates/starter/essentials/code.mdx +29 -29
  106. package/templates/starter/essentials/images.mdx +29 -29
  107. package/templates/starter/essentials/markdown.mdx +25 -25
  108. package/templates/starter/essentials/navigation.mdx +39 -39
  109. package/templates/starter/essentials/settings.mdx +30 -30
  110. package/templates/starter/favicon.svg +6 -6
  111. package/templates/starter/index.mdx +31 -31
  112. package/templates/starter/openapi.json +160 -160
  113. package/templates/starter/quickstart.mdx +31 -31
  114. package/templates/starter/velu.json +40 -40
@@ -1,273 +1,273 @@
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
- // Whether the docs owner's plan + credit balance allow Ask-AI right now.
95
- // Set from /bootstrap; the host UI hides the widget when false. Fail-open
96
- // (stays true) if bootstrap can't be reached so a transient error doesn't
97
- // wrongly hide a working assistant.
98
- let askEnabled = true;
99
- // Highest event seq seen per conversation. The events endpoint replays history
100
- // after `after_seq`; without this a reused (multi-turn) conversation replays
101
- // from 0 and the first (old) assistant.completed is returned for every
102
- // follow-up turn — i.e. the answer never updates after the first message.
103
- const lastSeqByConv = new Map();
104
-
105
- const headers = (extra) => ({
106
- 'X-Velu-Site-Host': siteHost(host),
107
- ...extra,
108
- });
109
-
110
- async function bootstrap() {
111
- if (bootstrapped) return;
112
- // Sets the httponly visitor cookie used by every subsequent call, and tells
113
- // us whether Ask-AI is currently enabled for this site (plan + credits).
114
- const res = await fetch(`${root}/bootstrap`, {
115
- method: 'GET',
116
- credentials: 'include',
117
- headers: headers(),
118
- });
119
- try {
120
- const data = await res.json();
121
- if (typeof data?.ask_ai_enabled === 'boolean') askEnabled = data.ask_ai_enabled;
122
- } catch {
123
- /* keep fail-open default */
124
- }
125
- bootstrapped = true;
126
- }
127
-
128
- // Resolve whether the widget should be shown (runs bootstrap once). Returns
129
- // true on any error so a transient failure never hides a working assistant.
130
- async function isEnabled() {
131
- try {
132
- await bootstrap();
133
- } catch {
134
- return true;
135
- }
136
- return askEnabled;
137
- }
138
-
139
- // Async generator: yields { conversationId } early, then one
140
- // { delta, citations, messageId, conversationId } when the answer is ready.
141
- async function* ask(prompt, { conversationId, signal } = {}) {
142
- await bootstrap();
143
-
144
- const res = await fetch(`${root}/messages`, {
145
- method: 'POST',
146
- credentials: 'include',
147
- headers: headers({ 'Content-Type': 'application/json' }),
148
- body: JSON.stringify({ message: prompt, conversation_id: conversationId ?? null }),
149
- signal,
150
- });
151
- if (!res.ok) throw new Error(`ask-ai: messages ${res.status}`);
152
- const { conversation_id: convId, conversation_token: token } = await res.json();
153
- yield { conversationId: convId };
154
-
155
- // Manual fetch-stream SSE parse (not EventSource): supports credentials,
156
- // the X-Velu-Site-Host header, and AbortController cancellation.
157
- const afterSeq = lastSeqByConv.get(convId) ?? 0;
158
- const evRes = await fetch(
159
- `${root}/conversations/${convId}/events?after_seq=${afterSeq}&token=${encodeURIComponent(token)}`,
160
- { credentials: 'include', headers: headers({ Accept: 'text/event-stream' }), signal },
161
- );
162
- if (!evRes.ok || !evRes.body) throw new Error(`ask-ai: stream ${evRes.status}`);
163
-
164
- const reader = evRes.body.getReader();
165
- const decoder = new TextDecoder();
166
- let buf = '';
167
- try {
168
- while (true) {
169
- const { value, done } = await reader.read();
170
- if (done) break;
171
- buf += decoder.decode(value, { stream: true });
172
- let sep;
173
- while ((sep = buf.indexOf('\n\n')) !== -1) {
174
- const { event, data, seq } = parseSseRecord(buf.slice(0, sep));
175
- buf = buf.slice(sep + 2);
176
- if (seq != null) {
177
- lastSeqByConv.set(convId, Math.max(lastSeqByConv.get(convId) ?? 0, seq));
178
- }
179
- if (event === 'assistant.completed') {
180
- const m = data?.message || {};
181
- const renum = renumberCitations(m.content || '', mapCitations(m.citations));
182
- yield {
183
- delta: renum.content,
184
- citations: renum.citations,
185
- messageId: m.id,
186
- conversationId: convId,
187
- };
188
- return; // answer delivered — done
189
- }
190
- if (event === 'assistant.error') {
191
- throw new Error(data?.error || 'ask-ai: generation failed');
192
- }
193
- // message.created (echoed user turn) and ping are ignored.
194
- }
195
- }
196
- } finally {
197
- try {
198
- await reader.cancel();
199
- } catch {
200
- /* already closed */
201
- }
202
- }
203
- }
204
-
205
- // rating: 'up' | 'down' to set, null to retract.
206
- async function sendFeedback(messageId, rating) {
207
- if (messageId == null) return;
208
- const url = `${root}/messages/${messageId}/feedback`;
209
- if (rating == null) {
210
- await fetch(url, { method: 'DELETE', credentials: 'include', headers: headers() });
211
- return;
212
- }
213
- await fetch(url, {
214
- method: 'POST',
215
- credentials: 'include',
216
- headers: headers({ 'Content-Type': 'application/json' }),
217
- body: JSON.stringify({ rating }),
218
- });
219
- }
220
-
221
- // The visitor's recent conversations on this site (the history panel).
222
- // Returns [{ conversationId, token, title, updatedAt }], newest first.
223
- async function listConversations() {
224
- await bootstrap();
225
- const res = await fetch(`${root}/conversations`, {
226
- credentials: 'include',
227
- headers: headers(),
228
- });
229
- if (!res.ok) throw new Error(`ask-ai: conversations ${res.status}`);
230
- const { conversations } = await res.json();
231
- return (conversations || []).map((c) => ({
232
- conversationId: c.conversation_id,
233
- token: c.conversation_token,
234
- title: c.title,
235
- lastSeq: c.last_seq,
236
- updatedAt: c.updated_at,
237
- }));
238
- }
239
-
240
- // Load a past conversation's messages (to resume it in the panel).
241
- // Returns { conversationId, messages: [{ role, content, citations,
242
- // messageId }] } with citations already renumbered to the Chatbot's Source
243
- // shape. Also records the conversation's last seq so a follow-up `ask` on
244
- // it streams from the right offset instead of replaying history.
245
- async function loadConversation({ conversationId, token, lastSeq }) {
246
- const res = await fetch(`${root}/conversations/${conversationId}`, {
247
- credentials: 'include',
248
- headers: headers({ Authorization: `Bearer ${token}` }),
249
- });
250
- if (!res.ok) throw new Error(`ask-ai: conversation ${res.status}`);
251
- const data = await res.json();
252
- let maxSeq = lastSeq ?? 0;
253
- const messages = (data.messages || []).map((m) => {
254
- if (m.seq != null) maxSeq = Math.max(maxSeq, m.seq);
255
- if (m.role !== 'assistant') {
256
- return { role: m.role, content: m.content, messageId: m.id };
257
- }
258
- const renum = renumberCitations(m.content || '', mapCitations(m.citations));
259
- return {
260
- role: 'assistant',
261
- content: renum.content,
262
- citations: renum.citations,
263
- messageId: m.id,
264
- };
265
- });
266
- lastSeqByConv.set(conversationId, Math.max(lastSeqByConv.get(conversationId) ?? 0, maxSeq));
267
- return { conversationId, messages };
268
- }
269
-
270
- return { ask, sendFeedback, listConversations, loadConversation, isEnabled };
271
- }
272
-
273
- 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
+ // Whether the docs owner's plan + credit balance allow Ask-AI right now.
95
+ // Set from /bootstrap; the host UI hides the widget when false. Fail-open
96
+ // (stays true) if bootstrap can't be reached so a transient error doesn't
97
+ // wrongly hide a working assistant.
98
+ let askEnabled = true;
99
+ // Highest event seq seen per conversation. The events endpoint replays history
100
+ // after `after_seq`; without this a reused (multi-turn) conversation replays
101
+ // from 0 and the first (old) assistant.completed is returned for every
102
+ // follow-up turn — i.e. the answer never updates after the first message.
103
+ const lastSeqByConv = new Map();
104
+
105
+ const headers = (extra) => ({
106
+ 'X-Velu-Site-Host': siteHost(host),
107
+ ...extra,
108
+ });
109
+
110
+ async function bootstrap() {
111
+ if (bootstrapped) return;
112
+ // Sets the httponly visitor cookie used by every subsequent call, and tells
113
+ // us whether Ask-AI is currently enabled for this site (plan + credits).
114
+ const res = await fetch(`${root}/bootstrap`, {
115
+ method: 'GET',
116
+ credentials: 'include',
117
+ headers: headers(),
118
+ });
119
+ try {
120
+ const data = await res.json();
121
+ if (typeof data?.ask_ai_enabled === 'boolean') askEnabled = data.ask_ai_enabled;
122
+ } catch {
123
+ /* keep fail-open default */
124
+ }
125
+ bootstrapped = true;
126
+ }
127
+
128
+ // Resolve whether the widget should be shown (runs bootstrap once). Returns
129
+ // true on any error so a transient failure never hides a working assistant.
130
+ async function isEnabled() {
131
+ try {
132
+ await bootstrap();
133
+ } catch {
134
+ return true;
135
+ }
136
+ return askEnabled;
137
+ }
138
+
139
+ // Async generator: yields { conversationId } early, then one
140
+ // { delta, citations, messageId, conversationId } when the answer is ready.
141
+ async function* ask(prompt, { conversationId, signal } = {}) {
142
+ await bootstrap();
143
+
144
+ const res = await fetch(`${root}/messages`, {
145
+ method: 'POST',
146
+ credentials: 'include',
147
+ headers: headers({ 'Content-Type': 'application/json' }),
148
+ body: JSON.stringify({ message: prompt, conversation_id: conversationId ?? null }),
149
+ signal,
150
+ });
151
+ if (!res.ok) throw new Error(`ask-ai: messages ${res.status}`);
152
+ const { conversation_id: convId, conversation_token: token } = await res.json();
153
+ yield { conversationId: convId };
154
+
155
+ // Manual fetch-stream SSE parse (not EventSource): supports credentials,
156
+ // the X-Velu-Site-Host header, and AbortController cancellation.
157
+ const afterSeq = lastSeqByConv.get(convId) ?? 0;
158
+ const evRes = await fetch(
159
+ `${root}/conversations/${convId}/events?after_seq=${afterSeq}&token=${encodeURIComponent(token)}`,
160
+ { credentials: 'include', headers: headers({ Accept: 'text/event-stream' }), signal },
161
+ );
162
+ if (!evRes.ok || !evRes.body) throw new Error(`ask-ai: stream ${evRes.status}`);
163
+
164
+ const reader = evRes.body.getReader();
165
+ const decoder = new TextDecoder();
166
+ let buf = '';
167
+ try {
168
+ while (true) {
169
+ const { value, done } = await reader.read();
170
+ if (done) break;
171
+ buf += decoder.decode(value, { stream: true });
172
+ let sep;
173
+ while ((sep = buf.indexOf('\n\n')) !== -1) {
174
+ const { event, data, seq } = parseSseRecord(buf.slice(0, sep));
175
+ buf = buf.slice(sep + 2);
176
+ if (seq != null) {
177
+ lastSeqByConv.set(convId, Math.max(lastSeqByConv.get(convId) ?? 0, seq));
178
+ }
179
+ if (event === 'assistant.completed') {
180
+ const m = data?.message || {};
181
+ const renum = renumberCitations(m.content || '', mapCitations(m.citations));
182
+ yield {
183
+ delta: renum.content,
184
+ citations: renum.citations,
185
+ messageId: m.id,
186
+ conversationId: convId,
187
+ };
188
+ return; // answer delivered — done
189
+ }
190
+ if (event === 'assistant.error') {
191
+ throw new Error(data?.error || 'ask-ai: generation failed');
192
+ }
193
+ // message.created (echoed user turn) and ping are ignored.
194
+ }
195
+ }
196
+ } finally {
197
+ try {
198
+ await reader.cancel();
199
+ } catch {
200
+ /* already closed */
201
+ }
202
+ }
203
+ }
204
+
205
+ // rating: 'up' | 'down' to set, null to retract.
206
+ async function sendFeedback(messageId, rating) {
207
+ if (messageId == null) return;
208
+ const url = `${root}/messages/${messageId}/feedback`;
209
+ if (rating == null) {
210
+ await fetch(url, { method: 'DELETE', credentials: 'include', headers: headers() });
211
+ return;
212
+ }
213
+ await fetch(url, {
214
+ method: 'POST',
215
+ credentials: 'include',
216
+ headers: headers({ 'Content-Type': 'application/json' }),
217
+ body: JSON.stringify({ rating }),
218
+ });
219
+ }
220
+
221
+ // The visitor's recent conversations on this site (the history panel).
222
+ // Returns [{ conversationId, token, title, updatedAt }], newest first.
223
+ async function listConversations() {
224
+ await bootstrap();
225
+ const res = await fetch(`${root}/conversations`, {
226
+ credentials: 'include',
227
+ headers: headers(),
228
+ });
229
+ if (!res.ok) throw new Error(`ask-ai: conversations ${res.status}`);
230
+ const { conversations } = await res.json();
231
+ return (conversations || []).map((c) => ({
232
+ conversationId: c.conversation_id,
233
+ token: c.conversation_token,
234
+ title: c.title,
235
+ lastSeq: c.last_seq,
236
+ updatedAt: c.updated_at,
237
+ }));
238
+ }
239
+
240
+ // Load a past conversation's messages (to resume it in the panel).
241
+ // Returns { conversationId, messages: [{ role, content, citations,
242
+ // messageId }] } with citations already renumbered to the Chatbot's Source
243
+ // shape. Also records the conversation's last seq so a follow-up `ask` on
244
+ // it streams from the right offset instead of replaying history.
245
+ async function loadConversation({ conversationId, token, lastSeq }) {
246
+ const res = await fetch(`${root}/conversations/${conversationId}`, {
247
+ credentials: 'include',
248
+ headers: headers({ Authorization: `Bearer ${token}` }),
249
+ });
250
+ if (!res.ok) throw new Error(`ask-ai: conversation ${res.status}`);
251
+ const data = await res.json();
252
+ let maxSeq = lastSeq ?? 0;
253
+ const messages = (data.messages || []).map((m) => {
254
+ if (m.seq != null) maxSeq = Math.max(maxSeq, m.seq);
255
+ if (m.role !== 'assistant') {
256
+ return { role: m.role, content: m.content, messageId: m.id };
257
+ }
258
+ const renum = renumberCitations(m.content || '', mapCitations(m.citations));
259
+ return {
260
+ role: 'assistant',
261
+ content: renum.content,
262
+ citations: renum.citations,
263
+ messageId: m.id,
264
+ };
265
+ });
266
+ lastSeqByConv.set(conversationId, Math.max(lastSeqByConv.get(conversationId) ?? 0, maxSeq));
267
+ return { conversationId, messages };
268
+ }
269
+
270
+ return { ask, sendFeedback, listConversations, loadConversation, isEnabled };
271
+ }
272
+
273
+ export default createDocsAssistant;