gaard-client 0.1.0__tar.gz → 0.2.0__tar.gz

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 (21) hide show
  1. {gaard_client-0.1.0 → gaard_client-0.2.0}/PKG-INFO +2 -1
  2. {gaard_client-0.1.0 → gaard_client-0.2.0}/pyproject.toml +2 -1
  3. gaard_client-0.2.0/src/gaard_client/client-web/assets/main.js +665 -0
  4. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client/client-web/assets/styles.css +136 -12
  5. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client/client-web/src/main.ts +271 -44
  6. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client/main.py +77 -1
  7. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client.egg-info/PKG-INFO +2 -1
  8. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client.egg-info/requires.txt +1 -0
  9. {gaard_client-0.1.0 → gaard_client-0.2.0}/tests/test_client_app.py +139 -43
  10. gaard_client-0.1.0/src/gaard_client/client-web/assets/main.js +0 -590
  11. {gaard_client-0.1.0 → gaard_client-0.2.0}/README.md +0 -0
  12. {gaard_client-0.1.0 → gaard_client-0.2.0}/setup.cfg +0 -0
  13. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client/__init__.py +0 -0
  14. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client/cli.py +0 -0
  15. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client/cli_commands.py +0 -0
  16. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client/client-web/index.html +0 -0
  17. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client.egg-info/SOURCES.txt +0 -0
  18. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client.egg-info/dependency_links.txt +0 -0
  19. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client.egg-info/entry_points.txt +0 -0
  20. {gaard_client-0.1.0 → gaard_client-0.2.0}/src/gaard_client.egg-info/top_level.txt +0 -0
  21. {gaard_client-0.1.0 → gaard_client-0.2.0}/tests/test_cli.py +0 -0
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-client
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: GAARD frontend web services providing client interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.0
7
8
  Requires-Dist: fastapi>=0.111.0
8
9
  Requires-Dist: uvicorn[standard]>=0.30.0
9
10
  Requires-Dist: pydantic>=2.7.0
@@ -4,12 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-client"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "GAARD frontend web services providing client interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
 
12
12
  dependencies = [
13
+ "gaard-plugin-api>=0.2.0,<0.3.0",
13
14
  "fastapi>=0.111.0",
14
15
  "uvicorn[standard]>=0.30.0",
15
16
  "pydantic>=2.7.0",
@@ -0,0 +1,665 @@
1
+ // src/main.ts
2
+ var app = document.querySelector("#app");
3
+ var params = new URLSearchParams(window.location.search);
4
+ var configuredBackendUrl = (params.get("backendUrl") || params.get("apiUrl") || window.GAARD_CLIENT_CONFIG?.backendUrl || "http://localhost:8000").replace(/\/+$/, "");
5
+ var state = {
6
+ backendUrl: configuredBackendUrl,
7
+ queryMode: normalizeQueryMode(params.get("mode")),
8
+ messages: [],
9
+ nextMessageId: 1,
10
+ pending: false,
11
+ error: ""
12
+ };
13
+ function escapeHtml(value) {
14
+ return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
15
+ }
16
+ function render(options = {}) {
17
+ if (!app) return;
18
+ app.innerHTML = `
19
+ <main class="shell">
20
+ <header class="header">
21
+ <h1>GAARD - Governed AI Access to Relational Data</h1>
22
+ </header>
23
+ <section class="history" aria-live="polite">
24
+ ${state.messages.length ? state.messages.map(renderMessage).join("") : `<div class="empty-state">Ask a governed data question.</div>`}
25
+ </section>
26
+ <form id="query-form" class="query-bar">
27
+ <fieldset class="mode-control" ${state.pending ? "disabled" : ""}>
28
+ <legend>Mode</legend>
29
+ <label class="${state.queryMode === "sql" ? "active" : ""}">
30
+ <input type="radio" name="mode" value="sql" ${state.queryMode === "sql" ? "checked" : ""}>
31
+ <span>SQL</span>
32
+ </label>
33
+ <label class="${state.queryMode === "analysis" ? "active" : ""}">
34
+ <input type="radio" name="mode" value="analysis" ${state.queryMode === "analysis" ? "checked" : ""}>
35
+ <span>Analysis</span>
36
+ </label>
37
+ </fieldset>
38
+ <textarea id="question-input" name="question" placeholder="Ask a question" rows="1" ${state.pending ? "disabled" : ""}></textarea>
39
+ <button class="send-button" type="submit" aria-label="Send question" title="Send" ${state.pending ? "disabled" : ""}>
40
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
41
+ <path d="M22 2 11 13" />
42
+ <path d="m22 2-7 20-4-9-9-4Z" />
43
+ </svg>
44
+ </button>
45
+ </form>
46
+ </main>`;
47
+ document.querySelector("#query-form")?.addEventListener("submit", submitQuestion);
48
+ document.querySelectorAll('input[name="mode"]').forEach((input2) => {
49
+ input2.addEventListener("change", handleModeChange);
50
+ });
51
+ document.querySelectorAll("[data-toggle-data]").forEach((button) => {
52
+ button.addEventListener("click", toggleDataTable);
53
+ });
54
+ document.querySelectorAll("[data-retry-question]").forEach((button) => {
55
+ button.addEventListener("click", retryQuestion);
56
+ });
57
+ document.querySelectorAll("[data-save-widget]").forEach((button) => {
58
+ button.addEventListener("click", saveWidgetFromMessage);
59
+ });
60
+ document.querySelectorAll("[data-analysis-reply-form]").forEach((form) => {
61
+ form.addEventListener("submit", submitAnalysisReply);
62
+ });
63
+ document.querySelectorAll("[data-analysis-progress]").forEach((details) => {
64
+ details.addEventListener("toggle", toggleAnalysisProgress);
65
+ });
66
+ const input = document.querySelector("#question-input");
67
+ input?.addEventListener("keydown", handleQuestionKeydown);
68
+ input?.focus();
69
+ if (options.scrollToLatest) {
70
+ scrollToLatest();
71
+ }
72
+ }
73
+ function renderMessage(message) {
74
+ const rows = getRows(message.response);
75
+ const meta = message.status === "ok" ? renderMeta(message, rows) : "";
76
+ const answer = message.status === "pending" ? "Processing..." : message.status === "waiting" ? "Waiting for your answer." : message.status === "error" ? message.error : message.response?.answer || "";
77
+ const dataTable = message.status === "ok" && message.dataOpen ? renderDataTable(rows) : "";
78
+ const mockWarning = message.status === "ok" ? renderMockWarning(message.response?.metadata) : "";
79
+ const saveNotice = renderSaveNotice(message);
80
+ const progress = message.mode === "analysis" ? renderAnalysisProgress(message) : "";
81
+ const analysisReply = message.status === "waiting" ? renderAnalysisReply(message) : "";
82
+ return `
83
+ <article class="exchange ${message.status}">
84
+ <div class="exchange-top">
85
+ <div class="question">
86
+ <span>Question \xB7 ${escapeHtml(formatMode(message.mode))}</span>
87
+ <p>${escapeHtml(message.question)}</p>
88
+ </div>
89
+ ${renderMessageActions(message)}
90
+ </div>
91
+ <div class="answer">
92
+ <span>Answer</span>
93
+ <p>${escapeHtml(answer)}</p>
94
+ </div>
95
+ ${progress}
96
+ ${analysisReply}
97
+ ${mockWarning}
98
+ ${saveNotice}
99
+ ${meta}
100
+ ${dataTable}
101
+ </article>`;
102
+ }
103
+ function renderMessageActions(message) {
104
+ const saveDisabled = state.pending || message.saveStatus === "saving" || message.saveStatus === "saved";
105
+ const saveTitle = message.saveStatus === "saved" ? "Saved as widget" : message.saveStatus === "saving" ? "Saving widget" : "Save as widget";
106
+ return `
107
+ <div class="message-actions">
108
+ <button class="retry-button" type="button" data-retry-question="${message.id}" aria-label="Copy question to input" title="Retry question" ${state.pending ? "disabled" : ""}>
109
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
110
+ <path d="M3 12a9 9 0 1 0 2.64-6.36L3 8" />
111
+ <path d="M3 3v5h5" />
112
+ </svg>
113
+ </button>
114
+ ${canSaveWidget(message) ? `
115
+ <button class="save-widget-button" type="button" data-save-widget="${message.id}" aria-label="Save question as widget" title="${escapeHtml(saveTitle)}" ${saveDisabled ? "disabled" : ""}>
116
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
117
+ <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
118
+ <path d="M17 21v-8H7v8" />
119
+ <path d="M7 3v5h8" />
120
+ </svg>
121
+ </button>` : ""}
122
+ </div>`;
123
+ }
124
+ function canSaveWidget(message) {
125
+ return message.status === "ok" && Boolean(message.response?.sql?.trim());
126
+ }
127
+ function renderSaveNotice(message) {
128
+ if (message.saveStatus === "saved") {
129
+ return `<div class="save-notice success" role="status">Saved as inactive widget.</div>`;
130
+ }
131
+ if (message.saveStatus === "error") {
132
+ return `<div class="save-notice error" role="alert">${escapeHtml(message.saveError || "Widget could not be saved.")}</div>`;
133
+ }
134
+ return "";
135
+ }
136
+ function renderAnalysisProgress(message) {
137
+ if (!message.progress.length) {
138
+ return "";
139
+ }
140
+ const latest = message.progress[message.progress.length - 1];
141
+ return `
142
+ <details class="analysis-log" data-analysis-progress="${message.id}" ${message.progressOpen ? "open" : ""}>
143
+ <summary>
144
+ <span>Analysis</span>
145
+ <strong>${escapeHtml(latest.title)}</strong>
146
+ ${latest.detail ? `<small>${escapeHtml(latest.detail)}</small>` : ""}
147
+ </summary>
148
+ <ol class="analysis-progress" aria-label="Analysis progress">
149
+ ${message.progress.map((update, index) => `
150
+ <li class="${index === message.progress.length - 1 ? "active" : "done"}">
151
+ <div>
152
+ <p>${escapeHtml(update.title)}</p>
153
+ ${update.detail ? `<p class="progress-detail">${escapeHtml(update.detail)}</p>` : ""}
154
+ ${renderProgressDecisions(update.items)}
155
+ </div>
156
+ </li>`).join("")}
157
+ </ol>
158
+ </details>`;
159
+ }
160
+ function renderAnalysisReply(message) {
161
+ return `
162
+ <form class="analysis-reply" data-analysis-reply-form="${message.id}">
163
+ <div class="analysis-reply-question">${escapeHtml(message.userQuestion || "GAARD needs a clarification.")}</div>
164
+ <label>
165
+ <span>Your answer</span>
166
+ <textarea name="reply" rows="2" placeholder="Answer GAARD" ${state.pending ? "disabled" : ""}></textarea>
167
+ </label>
168
+ <button type="submit" ${state.pending ? "disabled" : ""}>Continue analysis</button>
169
+ </form>`;
170
+ }
171
+ function renderProgressDecisions(decisions) {
172
+ const visible = decisions.filter((decision) => decision.trim()).slice(0, 3);
173
+ if (!visible.length) {
174
+ return "";
175
+ }
176
+ return `<ul>${visible.map((decision) => `<li>${escapeHtml(decision)}</li>`).join("")}</ul>`;
177
+ }
178
+ function renderMockWarning(metadata) {
179
+ const mockModes = [
180
+ ["SQL generation", metadata?.sql_generation_mode],
181
+ ["Result interpretation", metadata?.result_interpretation_mode],
182
+ ["Output classification", metadata?.output_classification_mode]
183
+ ].filter(([, mode]) => mode === "mock").map(([label]) => label);
184
+ if (!mockModes.length) {
185
+ return "";
186
+ }
187
+ return `
188
+ <div class="mock-warning" role="status">
189
+ This response used mock data processing: ${escapeHtml(mockModes.join(", "))}.
190
+ </div>`;
191
+ }
192
+ function renderMeta(message, rows) {
193
+ const metadata = message.response?.metadata || {};
194
+ const buttonText = message.dataOpen ? "Hide data" : `Data (${rows.length})`;
195
+ const mode = metadata.analysis_mode === "analysis" ? "analysis" : metadata.query_mode || message.mode;
196
+ return `
197
+ <div class="meta-row">
198
+ <dl class="meta">
199
+ <div><dt>Time</dt><dd>${escapeHtml(formatDuration(metadata.duration_ms))}</dd></div>
200
+ <div><dt>Datasource</dt><dd>${escapeHtml(metadata.datasource_id || "-")}</dd></div>
201
+ <div><dt>Mode</dt><dd>${escapeHtml(formatMode(mode))}</dd></div>
202
+ <div><dt>Output</dt><dd>${escapeHtml(metadata.output_classification || "unknown")}</dd></div>
203
+ </dl>
204
+ <button class="data-toggle" type="button" data-toggle-data="${message.id}" aria-expanded="${message.dataOpen ? "true" : "false"}">
205
+ ${escapeHtml(buttonText)}
206
+ </button>
207
+ </div>`;
208
+ }
209
+ function formatDuration(value) {
210
+ const numeric = Number(value);
211
+ if (!Number.isFinite(numeric)) {
212
+ return "-";
213
+ }
214
+ return `${numeric} ms`;
215
+ }
216
+ function formatMode(value) {
217
+ return value === "analysis" ? "Analysis" : "SQL";
218
+ }
219
+ function normalizeQueryMode(value) {
220
+ return value === "analysis" ? "analysis" : "sql";
221
+ }
222
+ function handleModeChange(event) {
223
+ state.queryMode = normalizeQueryMode(event.currentTarget.value);
224
+ render();
225
+ }
226
+ function handleQuestionKeydown(event) {
227
+ if (event.key === "Enter" && !event.shiftKey) {
228
+ event.preventDefault();
229
+ event.currentTarget.form?.requestSubmit();
230
+ }
231
+ }
232
+ function toggleDataTable(event) {
233
+ const id = Number(event.currentTarget.dataset.toggleData);
234
+ const message = state.messages.find((item) => item.id === id);
235
+ if (!message) return;
236
+ message.dataOpen = !message.dataOpen;
237
+ const latestMessage = state.messages[state.messages.length - 1];
238
+ render({
239
+ scrollToLatest: message.dataOpen && latestMessage?.id === message.id
240
+ });
241
+ }
242
+ function toggleAnalysisProgress(event) {
243
+ const details = event.currentTarget;
244
+ const id = Number(details.dataset.analysisProgress);
245
+ const message = state.messages.find((item) => item.id === id);
246
+ if (message) {
247
+ message.progressOpen = details.open;
248
+ }
249
+ }
250
+ function retryQuestion(event) {
251
+ const id = Number(event.currentTarget.dataset.retryQuestion);
252
+ const message = state.messages.find((item) => item.id === id);
253
+ if (!message || state.pending) return;
254
+ state.queryMode = message.mode;
255
+ render();
256
+ const refreshedInput = document.querySelector("#question-input");
257
+ if (!refreshedInput || refreshedInput.disabled) return;
258
+ refreshedInput.value = message.question;
259
+ refreshedInput.focus();
260
+ refreshedInput.setSelectionRange(refreshedInput.value.length, refreshedInput.value.length);
261
+ }
262
+ async function saveWidgetFromMessage(event) {
263
+ const id = Number(event.currentTarget.dataset.saveWidget);
264
+ const message = state.messages.find((item) => item.id === id);
265
+ const sql = message?.response?.sql?.trim() || "";
266
+ if (!message || !sql || message.saveStatus === "saving" || message.saveStatus === "saved") {
267
+ return;
268
+ }
269
+ message.saveStatus = "saving";
270
+ message.saveError = "";
271
+ render();
272
+ try {
273
+ const response = await fetch("/api/widgets/from-query", {
274
+ method: "POST",
275
+ headers: {
276
+ "Content-Type": "application/json"
277
+ },
278
+ body: JSON.stringify({
279
+ label: buildWidgetLabel(message.question),
280
+ widget_type: inferWidgetType(getRows(message.response)),
281
+ datasource_key: message.response?.metadata?.datasource_id || "default",
282
+ question: message.question,
283
+ sql,
284
+ result_mode: "data",
285
+ backend_url: state.backendUrl
286
+ })
287
+ });
288
+ const payload = await response.json().catch(() => ({}));
289
+ if (!response.ok) {
290
+ throw new Error(extractErrorMessage(payload));
291
+ }
292
+ message.saveStatus = "saved";
293
+ } catch (error) {
294
+ message.saveStatus = "error";
295
+ message.saveError = error.message || "Widget could not be saved.";
296
+ } finally {
297
+ render();
298
+ }
299
+ }
300
+ function buildWidgetLabel(question) {
301
+ const compact = question.replace(/\s+/g, " ").trim();
302
+ return compact.length > 64 ? `${compact.slice(0, 61)}...` : compact || "Saved query";
303
+ }
304
+ function inferWidgetType(rows) {
305
+ if (rows.length === 1 && Object.keys(rows[0] || {}).length === 1) {
306
+ return "scalar";
307
+ }
308
+ return "table";
309
+ }
310
+ function getSelectedMode(form) {
311
+ const value = new FormData(form).get("mode");
312
+ return normalizeQueryMode(value);
313
+ }
314
+ async function submitQuestion(event) {
315
+ event.preventDefault();
316
+ if (state.pending) return;
317
+ const form = event.currentTarget;
318
+ const input = form.elements.namedItem("question");
319
+ const question = String(input?.value || "").trim();
320
+ const mode = getSelectedMode(form);
321
+ if (!question) return;
322
+ if (input) input.value = "";
323
+ state.error = "";
324
+ state.pending = true;
325
+ const message = {
326
+ id: state.nextMessageId,
327
+ question,
328
+ mode,
329
+ status: "pending",
330
+ response: null,
331
+ error: "",
332
+ dataOpen: false,
333
+ saveStatus: "idle",
334
+ saveError: "",
335
+ progress: [],
336
+ progressOpen: false,
337
+ analysisSessionId: "",
338
+ userQuestion: ""
339
+ };
340
+ state.nextMessageId += 1;
341
+ state.messages.push(message);
342
+ render({ scrollToLatest: true });
343
+ try {
344
+ if (mode === "analysis") {
345
+ await submitAnalysisQuestion(message, question);
346
+ } else {
347
+ const response = await fetch("/api/query", {
348
+ method: "POST",
349
+ headers: {
350
+ "Content-Type": "application/json"
351
+ },
352
+ body: JSON.stringify({
353
+ question,
354
+ mode,
355
+ backend_url: state.backendUrl
356
+ })
357
+ });
358
+ const payload = await response.json().catch(() => ({}));
359
+ if (!response.ok) {
360
+ throw new Error(extractErrorMessage(payload));
361
+ }
362
+ message.status = "ok";
363
+ message.response = payload;
364
+ }
365
+ } catch (error) {
366
+ message.status = "error";
367
+ message.error = error.message || "Request failed.";
368
+ } finally {
369
+ state.pending = false;
370
+ render({ scrollToLatest: true });
371
+ }
372
+ }
373
+ async function submitAnalysisQuestion(message, question) {
374
+ const response = await fetch("/api/analysis/stream", {
375
+ method: "POST",
376
+ headers: {
377
+ "Content-Type": "application/json"
378
+ },
379
+ body: JSON.stringify({
380
+ question,
381
+ backend_url: state.backendUrl
382
+ })
383
+ });
384
+ if (!response.ok) {
385
+ const payload = await response.json().catch(() => ({}));
386
+ throw new Error(extractErrorMessage(payload));
387
+ }
388
+ await readAnalysisStream(message, response);
389
+ }
390
+ async function submitAnalysisReply(event) {
391
+ event.preventDefault();
392
+ if (state.pending) return;
393
+ const form = event.currentTarget;
394
+ const id = Number(form.dataset.analysisReplyForm);
395
+ const message = state.messages.find((item) => item.id === id);
396
+ const input = form.elements.namedItem("reply");
397
+ const reply = String(input?.value || "").trim();
398
+ if (!message || !message.analysisSessionId || !reply) return;
399
+ state.pending = true;
400
+ message.status = "pending";
401
+ message.userQuestion = "";
402
+ render({ scrollToLatest: true });
403
+ try {
404
+ await continueAnalysis(message, reply);
405
+ } catch (error) {
406
+ message.status = "error";
407
+ message.error = error.message || "Request failed.";
408
+ } finally {
409
+ state.pending = false;
410
+ render({ scrollToLatest: true });
411
+ }
412
+ }
413
+ async function continueAnalysis(message, reply) {
414
+ const response = await fetch(`/api/analysis/${encodeURIComponent(message.analysisSessionId)}/messages/stream`, {
415
+ method: "POST",
416
+ headers: {
417
+ "Content-Type": "application/json"
418
+ },
419
+ body: JSON.stringify({
420
+ message: reply,
421
+ backend_url: state.backendUrl
422
+ })
423
+ });
424
+ if (!response.ok) {
425
+ const payload = await response.json().catch(() => ({}));
426
+ throw new Error(extractErrorMessage(payload));
427
+ }
428
+ await readAnalysisStream(message, response);
429
+ }
430
+ async function readAnalysisStream(message, response) {
431
+ if (!response.body) {
432
+ throw new Error("Streaming response is not available.");
433
+ }
434
+ const reader = response.body.getReader();
435
+ const decoder = new TextDecoder();
436
+ let buffer = "";
437
+ let finalReceived = false;
438
+ while (true) {
439
+ const { done, value } = await reader.read();
440
+ if (done) break;
441
+ buffer += decoder.decode(value, { stream: true });
442
+ const lines = buffer.split("\n");
443
+ buffer = lines.pop() || "";
444
+ for (const line of lines) {
445
+ finalReceived = handleAnalysisStreamLine(message, line) || finalReceived;
446
+ }
447
+ }
448
+ buffer += decoder.decode();
449
+ if (buffer.trim()) {
450
+ finalReceived = handleAnalysisStreamLine(message, buffer) || finalReceived;
451
+ }
452
+ if (!finalReceived && message.status !== "waiting") {
453
+ throw new Error("Analysis stream ended without a final response.");
454
+ }
455
+ }
456
+ function handleAnalysisStreamLine(message, line) {
457
+ const trimmed = line.trim();
458
+ if (!trimmed) return false;
459
+ const payload = JSON.parse(trimmed);
460
+ if (payload?.error?.message) {
461
+ throw new Error(payload.error.message);
462
+ }
463
+ if (payload?.final) {
464
+ message.status = "ok";
465
+ message.response = payload.final;
466
+ message.dataOpen = message.mode === "analysis" && getRows(payload.final).length > 0;
467
+ message.userQuestion = "";
468
+ render({ scrollToLatest: true });
469
+ return true;
470
+ }
471
+ if (payload?.session_id && !message.analysisSessionId) {
472
+ message.analysisSessionId = String(payload.session_id);
473
+ }
474
+ if (payload?.event === "user_question") {
475
+ const question = extractUserQuestion(payload);
476
+ message.status = "waiting";
477
+ message.userQuestion = question;
478
+ message.progress = [
479
+ ...message.progress,
480
+ {
481
+ event: "user_question",
482
+ title: "GAARD needs your clarification",
483
+ detail: question,
484
+ items: []
485
+ }
486
+ ];
487
+ render({ scrollToLatest: true });
488
+ return false;
489
+ }
490
+ const progress = progressFromAnalysisEvent(payload);
491
+ if (progress) {
492
+ message.progress = [...message.progress, progress];
493
+ render({ scrollToLatest: true });
494
+ }
495
+ return false;
496
+ }
497
+ function firstText(...values) {
498
+ for (const value of values) {
499
+ const text = String(value || "").trim();
500
+ if (text) return text;
501
+ }
502
+ return "";
503
+ }
504
+ function extractUserQuestion(payload) {
505
+ const userQuestion = payload?.user_question;
506
+ return firstText(
507
+ typeof userQuestion === "string" ? userQuestion : "",
508
+ userQuestion?.question,
509
+ userQuestion?.message,
510
+ userQuestion?.visible_question,
511
+ payload?.question,
512
+ payload?.decision?.user_question,
513
+ payload?.decision?.visible_question,
514
+ "GAARD needs a clarification."
515
+ );
516
+ }
517
+ function progressFromAnalysisEvent(payload) {
518
+ const event = String(payload?.event || "");
519
+ if (event === "analysis_step") {
520
+ const step = payload.analysis_step || {};
521
+ return {
522
+ event,
523
+ title: step.visible_question || "GAARD is checking the next analysis step.",
524
+ detail: step.visible_reasoning || "",
525
+ items: [`Iteration ${step.iteration || payload.sequence || ""}`].filter(Boolean)
526
+ };
527
+ }
528
+ if (event === "decision") {
529
+ const decision = payload.decision || {};
530
+ return {
531
+ event,
532
+ title: `Decision: ${formatAnalysisAction(decision.action)}`,
533
+ detail: decision.visible_reasoning || decision.visible_question || "",
534
+ items: [
535
+ decision.user_question ? `Question for you: ${decision.user_question}` : "",
536
+ decision.database_question ? `Database question: ${decision.database_question}` : "",
537
+ decision.final_question ? `Final query question: ${decision.final_question}` : "",
538
+ decision.answer ? `Context answer prepared.` : ""
539
+ ].filter(Boolean)
540
+ };
541
+ }
542
+ if (event === "database_question") {
543
+ const question = payload.database_question || {};
544
+ return {
545
+ event,
546
+ title: question.final ? "GAARD asks the final database question" : "GAARD asks the database",
547
+ detail: question.question || "",
548
+ items: []
549
+ };
550
+ }
551
+ if (event === "database_result") {
552
+ const result = payload.database_result || {};
553
+ return {
554
+ event,
555
+ title: "Database result received",
556
+ detail: result.answer || "",
557
+ items: [
558
+ result.sql ? `SQL: ${result.sql}` : "",
559
+ Array.isArray(result.rows) ? `Rows: ${result.rows.length}` : ""
560
+ ].filter(Boolean)
561
+ };
562
+ }
563
+ if (event === "business_logic_suggestion") {
564
+ const suggestion = payload.business_logic_suggestion || {};
565
+ return {
566
+ event,
567
+ title: suggestion.enabled ? "Business logic finding enabled" : "Business logic finding saved for review",
568
+ detail: suggestion.title || suggestion.rule_text || "",
569
+ items: [
570
+ suggestion.error_category ? `Type: ${suggestion.error_category}` : "",
571
+ suggestion.confidence !== void 0 ? `Confidence: ${suggestion.confidence}` : ""
572
+ ].filter(Boolean)
573
+ };
574
+ }
575
+ if (event === "limit_reached") {
576
+ return {
577
+ event,
578
+ title: "Analysis loop limit reached",
579
+ detail: `Limit: ${payload.limit_reached?.analysis_loop_count || "-"}`,
580
+ items: []
581
+ };
582
+ }
583
+ if (event === "session_started" || event === "session_resumed") {
584
+ return null;
585
+ }
586
+ return null;
587
+ }
588
+ function formatAnalysisAction(value) {
589
+ return String(value || "unknown").replaceAll("_", " ");
590
+ }
591
+ function extractErrorMessage(payload) {
592
+ const detail = payload?.detail;
593
+ if (typeof detail === "string") {
594
+ return detail;
595
+ }
596
+ if (detail?.error?.message) {
597
+ return detail.error.message;
598
+ }
599
+ if (payload?.error?.message) {
600
+ return payload.error.message;
601
+ }
602
+ return "Request failed.";
603
+ }
604
+ function getRows(response) {
605
+ return Array.isArray(response?.rows) ? response.rows : [];
606
+ }
607
+ function getColumns(rows) {
608
+ const columns = [];
609
+ rows.forEach((row) => {
610
+ if (!row || typeof row !== "object" || Array.isArray(row)) return;
611
+ Object.keys(row).forEach((column) => {
612
+ if (!columns.includes(column)) {
613
+ columns.push(column);
614
+ }
615
+ });
616
+ });
617
+ return columns;
618
+ }
619
+ function renderDataTable(rows) {
620
+ const columns = getColumns(rows);
621
+ if (!rows.length) {
622
+ return `<div class="data-table-empty">No rows returned.</div>`;
623
+ }
624
+ if (!columns.length) {
625
+ return `<div class="data-table-empty">Rows are not table-shaped.</div>`;
626
+ }
627
+ return `
628
+ <div class="data-table-wrap" tabindex="0">
629
+ <table class="data-table">
630
+ <thead>
631
+ <tr>${columns.map((column) => `<th scope="col">${escapeHtml(column)}</th>`).join("")}</tr>
632
+ </thead>
633
+ <tbody>
634
+ ${rows.map((row) => `
635
+ <tr>
636
+ ${columns.map((column) => `<td>${escapeHtml(formatCellValue(row?.[column]))}</td>`).join("")}
637
+ </tr>`).join("")}
638
+ </tbody>
639
+ </table>
640
+ </div>`;
641
+ }
642
+ function formatCellValue(value) {
643
+ if (value === null) {
644
+ return "null";
645
+ }
646
+ if (typeof value === "object") {
647
+ return JSON.stringify(value);
648
+ }
649
+ return String(value ?? "");
650
+ }
651
+ function scrollToLatest() {
652
+ const scroll = () => {
653
+ const history = document.querySelector(".history");
654
+ if (!history) return;
655
+ history.scrollTo({
656
+ top: history.scrollHeight,
657
+ behavior: "auto"
658
+ });
659
+ };
660
+ requestAnimationFrame(() => {
661
+ scroll();
662
+ requestAnimationFrame(scroll);
663
+ });
664
+ }
665
+ render();