@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.
Files changed (109) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +37 -37
  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 +122 -122
  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/Chatbot.jsx +885 -885
  16. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  17. package/runtime/velu-ui/components/Columns.jsx +56 -56
  18. package/runtime/velu-ui/components/ContextMenu.jsx +298 -273
  19. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  20. package/runtime/velu-ui/components/Field.jsx +81 -81
  21. package/runtime/velu-ui/components/Image.jsx +163 -163
  22. package/runtime/velu-ui/components/Logo.jsx +31 -31
  23. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  24. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  25. package/runtime/velu-ui/components/NotFound.jsx +63 -63
  26. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  27. package/runtime/velu-ui/components/PageFooter.jsx +145 -145
  28. package/runtime/velu-ui/components/PageHeader.jsx +422 -422
  29. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  30. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  31. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  32. package/runtime/velu-ui/components/Search.jsx +460 -460
  33. package/runtime/velu-ui/components/Sidebar.jsx +254 -254
  34. package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
  35. package/runtime/velu-ui/components/Steps.jsx +65 -65
  36. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  37. package/runtime/velu-ui/components/Toc.jsx +537 -537
  38. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  39. package/runtime/velu-ui/components/Tree.jsx +87 -87
  40. package/runtime/velu-ui/components/TryItBar.jsx +102 -102
  41. package/runtime/velu-ui/components/accordion.css +92 -92
  42. package/runtime/velu-ui/components/api-page.css +208 -208
  43. package/runtime/velu-ui/components/api.css +635 -635
  44. package/runtime/velu-ui/components/ask-bar.css +94 -94
  45. package/runtime/velu-ui/components/card.css +105 -105
  46. package/runtime/velu-ui/components/chatbot.css +622 -622
  47. package/runtime/velu-ui/components/code-block.css +263 -263
  48. package/runtime/velu-ui/components/context-menu.css +173 -173
  49. package/runtime/velu-ui/components/docs-layout.css +822 -822
  50. package/runtime/velu-ui/components/field.css +82 -82
  51. package/runtime/velu-ui/components/image.css +237 -237
  52. package/runtime/velu-ui/components/nav-select.css +157 -157
  53. package/runtime/velu-ui/components/not-found.css +94 -94
  54. package/runtime/velu-ui/components/page-feedback.css +241 -241
  55. package/runtime/velu-ui/components/page-footer.css +130 -130
  56. package/runtime/velu-ui/components/page-header.css +558 -558
  57. package/runtime/velu-ui/components/page-nav.css +50 -50
  58. package/runtime/velu-ui/components/powered-by.css +92 -92
  59. package/runtime/velu-ui/components/prompt.css +99 -99
  60. package/runtime/velu-ui/components/search.css +307 -307
  61. package/runtime/velu-ui/components/sidebar.css +205 -205
  62. package/runtime/velu-ui/components/steps.css +77 -77
  63. package/runtime/velu-ui/components/theme-toggle.css +102 -102
  64. package/runtime/velu-ui/components/toc-bar.css +234 -234
  65. package/runtime/velu-ui/components/tree.css +49 -49
  66. package/runtime/velu-ui/index.js +54 -54
  67. package/runtime/velu-ui/lib/api-send.js +92 -92
  68. package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
  69. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  70. package/runtime/velu-ui/lib/copyText.js +64 -64
  71. package/runtime/velu-ui/lib/docs-assistant.js +250 -250
  72. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  73. package/runtime/velu-ui/lib/pagefind.js +113 -113
  74. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  75. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  76. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  77. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  78. package/runtime/velu-ui/mdx-components.jsx +105 -105
  79. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  80. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  81. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  82. package/runtime/velu-ui/primitives/stack.css +3 -3
  83. package/runtime/velu-ui/primitives/switcher.css +25 -25
  84. package/runtime/velu-ui/styles.css +46 -46
  85. package/runtime/velu-ui/tokens.css +4 -4
  86. package/schema/velu.schema.json +423 -423
  87. package/src/lib/extract-mdx-error.js +170 -170
  88. package/src/lib/issues.js +159 -159
  89. package/src/lib/known-components.js +34 -34
  90. package/src/navigation.js +443 -443
  91. package/src/runtime/App.jsx +1669 -1668
  92. package/src/runtime/ErrorBoundary.jsx +54 -54
  93. package/src/runtime/client-entry.jsx +22 -22
  94. package/src/runtime/server-entry.jsx +16 -16
  95. package/src/template.html +48 -48
  96. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  97. package/templates/starter/ai-tools/cursor.mdx +17 -17
  98. package/templates/starter/api-reference/introduction.mdx +43 -43
  99. package/templates/starter/development.mdx +19 -19
  100. package/templates/starter/essentials/code.mdx +29 -29
  101. package/templates/starter/essentials/images.mdx +29 -29
  102. package/templates/starter/essentials/markdown.mdx +25 -25
  103. package/templates/starter/essentials/navigation.mdx +39 -39
  104. package/templates/starter/essentials/settings.mdx +30 -30
  105. package/templates/starter/favicon.svg +6 -6
  106. package/templates/starter/index.mdx +31 -31
  107. package/templates/starter/openapi.json +160 -160
  108. package/templates/starter/quickstart.mdx +31 -31
  109. 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;