gaard-client 0.1.0__py3-none-any.whl
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.
- gaard_client/__init__.py +1 -0
- gaard_client/cli.py +25 -0
- gaard_client/cli_commands.py +18 -0
- gaard_client/client-web/assets/main.js +590 -0
- gaard_client/client-web/assets/styles.css +511 -0
- gaard_client/client-web/index.html +14 -0
- gaard_client/client-web/src/main.ts +658 -0
- gaard_client/main.py +201 -0
- gaard_client-0.1.0.dist-info/METADATA +37 -0
- gaard_client-0.1.0.dist-info/RECORD +13 -0
- gaard_client-0.1.0.dist-info/WHEEL +5 -0
- gaard_client-0.1.0.dist-info/entry_points.txt +5 -0
- gaard_client-0.1.0.dist-info/top_level.txt +1 -0
gaard_client/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
gaard_client/cli.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
|
|
4
|
+
from gaard_client.cli_commands import run_client
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_parser(prog: str = "gaard-client") -> argparse.ArgumentParser:
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
prog=prog,
|
|
10
|
+
description="Run the GAARD community client.",
|
|
11
|
+
)
|
|
12
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
13
|
+
|
|
14
|
+
start_parser = subparsers.add_parser("start", help="Start the client server.")
|
|
15
|
+
start_parser.add_argument("--host", default="127.0.0.1")
|
|
16
|
+
start_parser.add_argument("--port", type=int, default=8001)
|
|
17
|
+
start_parser.add_argument("--reload", action="store_true")
|
|
18
|
+
start_parser.set_defaults(func=run_client)
|
|
19
|
+
|
|
20
|
+
return parser
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
24
|
+
args = create_parser().parse_args(argv)
|
|
25
|
+
args.func(args)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def register(subparsers):
|
|
5
|
+
parser = subparsers.add_parser("client")
|
|
6
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
7
|
+
parser.add_argument("--port", type=int, default=8001)
|
|
8
|
+
parser.add_argument("--reload", action="store_true")
|
|
9
|
+
parser.set_defaults(func=run_client)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_client(args):
|
|
13
|
+
uvicorn.run(
|
|
14
|
+
"gaard_client.main:app",
|
|
15
|
+
host=args.host,
|
|
16
|
+
port=args.port,
|
|
17
|
+
reload=args.reload,
|
|
18
|
+
)
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
const app = document.querySelector("#app");
|
|
2
|
+
|
|
3
|
+
const params = new URLSearchParams(window.location.search);
|
|
4
|
+
const configuredBackendUrl = (
|
|
5
|
+
params.get("backendUrl") ||
|
|
6
|
+
params.get("apiUrl") ||
|
|
7
|
+
window.GAARD_CLIENT_CONFIG?.backendUrl ||
|
|
8
|
+
"http://localhost:8000"
|
|
9
|
+
).replace(/\/+$/, "");
|
|
10
|
+
|
|
11
|
+
const state = {
|
|
12
|
+
backendUrl: configuredBackendUrl,
|
|
13
|
+
queryMode: normalizeQueryMode(params.get("mode")),
|
|
14
|
+
messages: [],
|
|
15
|
+
nextMessageId: 1,
|
|
16
|
+
pending: false,
|
|
17
|
+
error: ""
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function escapeHtml(value) {
|
|
21
|
+
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function render(options = {}) {
|
|
25
|
+
if (!app) return;
|
|
26
|
+
|
|
27
|
+
app.innerHTML = `
|
|
28
|
+
<main class="shell">
|
|
29
|
+
<header class="header">
|
|
30
|
+
<h1>GAARD - Governed AI Access to Relational Data</h1>
|
|
31
|
+
</header>
|
|
32
|
+
<section class="history" aria-live="polite">
|
|
33
|
+
${state.messages.length ? state.messages.map(renderMessage).join("") : `<div class="empty-state">Ask a governed data question.</div>`}
|
|
34
|
+
</section>
|
|
35
|
+
<form id="query-form" class="query-bar">
|
|
36
|
+
<fieldset class="mode-control" ${state.pending ? "disabled" : ""}>
|
|
37
|
+
<legend>Mode</legend>
|
|
38
|
+
<label class="${state.queryMode === "sql" ? "active" : ""}">
|
|
39
|
+
<input type="radio" name="mode" value="sql" ${state.queryMode === "sql" ? "checked" : ""}>
|
|
40
|
+
<span>SQL</span>
|
|
41
|
+
</label>
|
|
42
|
+
<label class="${state.queryMode === "investigation" ? "active" : ""}">
|
|
43
|
+
<input type="radio" name="mode" value="investigation" ${state.queryMode === "investigation" ? "checked" : ""}>
|
|
44
|
+
<span>Investigation</span>
|
|
45
|
+
</label>
|
|
46
|
+
</fieldset>
|
|
47
|
+
<textarea id="question-input" name="question" placeholder="Ask a question" rows="1" ${state.pending ? "disabled" : ""}></textarea>
|
|
48
|
+
<button class="send-button" type="submit" aria-label="Send question" title="Send" ${state.pending ? "disabled" : ""}>
|
|
49
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
50
|
+
<path d="M22 2 11 13" />
|
|
51
|
+
<path d="m22 2-7 20-4-9-9-4Z" />
|
|
52
|
+
</svg>
|
|
53
|
+
</button>
|
|
54
|
+
</form>
|
|
55
|
+
</main>`;
|
|
56
|
+
|
|
57
|
+
document.querySelector("#query-form")?.addEventListener("submit", submitQuestion);
|
|
58
|
+
document.querySelectorAll('input[name="mode"]').forEach((input) => {
|
|
59
|
+
input.addEventListener("change", handleModeChange);
|
|
60
|
+
});
|
|
61
|
+
document.querySelectorAll("[data-toggle-data]").forEach((button) => {
|
|
62
|
+
button.addEventListener("click", toggleDataTable);
|
|
63
|
+
});
|
|
64
|
+
document.querySelectorAll("[data-retry-question]").forEach((button) => {
|
|
65
|
+
button.addEventListener("click", retryQuestion);
|
|
66
|
+
});
|
|
67
|
+
document.querySelectorAll("[data-save-widget]").forEach((button) => {
|
|
68
|
+
button.addEventListener("click", saveWidgetFromMessage);
|
|
69
|
+
});
|
|
70
|
+
const input = document.querySelector("#question-input");
|
|
71
|
+
input?.addEventListener("keydown", handleQuestionKeydown);
|
|
72
|
+
input?.focus();
|
|
73
|
+
|
|
74
|
+
if (options.scrollToLatest) {
|
|
75
|
+
scrollToLatest();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderMessage(message) {
|
|
80
|
+
const rows = getRows(message.response);
|
|
81
|
+
const meta = message.status === "ok" ? renderMeta(message, rows) : "";
|
|
82
|
+
const answer = message.status === "pending"
|
|
83
|
+
? "Processing..."
|
|
84
|
+
: message.status === "error"
|
|
85
|
+
? message.error
|
|
86
|
+
: message.response?.answer || "";
|
|
87
|
+
const dataTable = message.status === "ok" && message.dataOpen ? renderDataTable(rows) : "";
|
|
88
|
+
const mockWarning = message.status === "ok" ? renderMockWarning(message.response?.metadata) : "";
|
|
89
|
+
const saveNotice = renderSaveNotice(message);
|
|
90
|
+
const progress = message.status === "pending" && message.mode === "investigation" ? renderInvestigationProgress(message) : "";
|
|
91
|
+
|
|
92
|
+
return `
|
|
93
|
+
<article class="exchange ${message.status}">
|
|
94
|
+
<div class="exchange-top">
|
|
95
|
+
<div class="question">
|
|
96
|
+
<span>Question · ${escapeHtml(formatMode(message.mode))}</span>
|
|
97
|
+
<p>${escapeHtml(message.question)}</p>
|
|
98
|
+
</div>
|
|
99
|
+
${renderMessageActions(message)}
|
|
100
|
+
</div>
|
|
101
|
+
<div class="answer">
|
|
102
|
+
<span>Answer</span>
|
|
103
|
+
<p>${escapeHtml(answer)}</p>
|
|
104
|
+
</div>
|
|
105
|
+
${progress}
|
|
106
|
+
${mockWarning}
|
|
107
|
+
${saveNotice}
|
|
108
|
+
${meta}
|
|
109
|
+
${dataTable}
|
|
110
|
+
</article>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderMessageActions(message) {
|
|
114
|
+
const saveDisabled = state.pending || message.saveStatus === "saving" || message.saveStatus === "saved";
|
|
115
|
+
const saveTitle = message.saveStatus === "saved"
|
|
116
|
+
? "Saved as widget"
|
|
117
|
+
: message.saveStatus === "saving"
|
|
118
|
+
? "Saving widget"
|
|
119
|
+
: "Save as widget";
|
|
120
|
+
|
|
121
|
+
return `
|
|
122
|
+
<div class="message-actions">
|
|
123
|
+
<button class="retry-button" type="button" data-retry-question="${message.id}" aria-label="Copy question to input" title="Retry question" ${state.pending ? "disabled" : ""}>
|
|
124
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
125
|
+
<path d="M3 12a9 9 0 1 0 2.64-6.36L3 8" />
|
|
126
|
+
<path d="M3 3v5h5" />
|
|
127
|
+
</svg>
|
|
128
|
+
</button>
|
|
129
|
+
${canSaveWidget(message) ? `
|
|
130
|
+
<button class="save-widget-button" type="button" data-save-widget="${message.id}" aria-label="Save question as widget" title="${escapeHtml(saveTitle)}" ${saveDisabled ? "disabled" : ""}>
|
|
131
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
132
|
+
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
|
|
133
|
+
<path d="M17 21v-8H7v8" />
|
|
134
|
+
<path d="M7 3v5h8" />
|
|
135
|
+
</svg>
|
|
136
|
+
</button>` : ""}
|
|
137
|
+
</div>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function canSaveWidget(message) {
|
|
141
|
+
return message.status === "ok" && Boolean(message.response?.sql?.trim());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderSaveNotice(message) {
|
|
145
|
+
if (message.saveStatus === "saved") {
|
|
146
|
+
return `<div class="save-notice success" role="status">Saved as inactive widget.</div>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (message.saveStatus === "error") {
|
|
150
|
+
return `<div class="save-notice error" role="alert">${escapeHtml(message.saveError || "Widget could not be saved.")}</div>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderInvestigationProgress(message) {
|
|
157
|
+
if (!message.progress.length) {
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return `
|
|
162
|
+
<ol class="investigation-progress" aria-label="Investigation progress">
|
|
163
|
+
${message.progress.map((update, index) => `
|
|
164
|
+
<li class="${index === message.progress.length - 1 ? "active" : "done"}">
|
|
165
|
+
<div>
|
|
166
|
+
<p>${escapeHtml(update.data_question)}</p>
|
|
167
|
+
${renderProgressDecisions(update.decisions)}
|
|
168
|
+
</div>
|
|
169
|
+
</li>`).join("")}
|
|
170
|
+
</ol>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderProgressDecisions(decisions) {
|
|
174
|
+
const visible = decisions.filter((decision) => decision.trim()).slice(0, 3);
|
|
175
|
+
|
|
176
|
+
if (!visible.length) {
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return `<ul>${visible.map((decision) => `<li>${escapeHtml(decision)}</li>`).join("")}</ul>`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderMockWarning(metadata) {
|
|
184
|
+
const mockModes = [
|
|
185
|
+
["SQL generation", metadata?.sql_generation_mode],
|
|
186
|
+
["Result interpretation", metadata?.result_interpretation_mode],
|
|
187
|
+
["Output classification", metadata?.output_classification_mode]
|
|
188
|
+
].filter(([, mode]) => mode === "mock").map(([label]) => label);
|
|
189
|
+
|
|
190
|
+
if (!mockModes.length) {
|
|
191
|
+
return "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return `
|
|
195
|
+
<div class="mock-warning" role="status">
|
|
196
|
+
This response used mock data processing: ${escapeHtml(mockModes.join(", "))}.
|
|
197
|
+
</div>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderMeta(message, rows) {
|
|
201
|
+
const metadata = message.response?.metadata || {};
|
|
202
|
+
const buttonText = message.dataOpen ? "Hide data" : `Data (${rows.length})`;
|
|
203
|
+
|
|
204
|
+
return `
|
|
205
|
+
<div class="meta-row">
|
|
206
|
+
<dl class="meta">
|
|
207
|
+
<div><dt>Time</dt><dd>${escapeHtml(formatDuration(metadata.duration_ms))}</dd></div>
|
|
208
|
+
<div><dt>Datasource</dt><dd>${escapeHtml(metadata.datasource_id || "-")}</dd></div>
|
|
209
|
+
<div><dt>Mode</dt><dd>${escapeHtml(formatMode(metadata.query_mode || message.mode))}</dd></div>
|
|
210
|
+
<div><dt>Output</dt><dd>${escapeHtml(metadata.output_classification || "unknown")}</dd></div>
|
|
211
|
+
</dl>
|
|
212
|
+
<button class="data-toggle" type="button" data-toggle-data="${message.id}" aria-expanded="${message.dataOpen ? "true" : "false"}">
|
|
213
|
+
${escapeHtml(buttonText)}
|
|
214
|
+
</button>
|
|
215
|
+
</div>`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function formatDuration(value) {
|
|
219
|
+
const numeric = Number(value);
|
|
220
|
+
|
|
221
|
+
if (!Number.isFinite(numeric)) {
|
|
222
|
+
return "-";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return `${numeric} ms`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatMode(value) {
|
|
229
|
+
return value === "investigation" ? "Investigation" : "SQL";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeQueryMode(value) {
|
|
233
|
+
return value === "investigation" ? "investigation" : "sql";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function handleModeChange(event) {
|
|
237
|
+
state.queryMode = normalizeQueryMode(event.currentTarget.value);
|
|
238
|
+
render();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function handleQuestionKeydown(event) {
|
|
242
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
event.currentTarget.form?.requestSubmit();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function toggleDataTable(event) {
|
|
249
|
+
const id = Number(event.currentTarget.dataset.toggleData);
|
|
250
|
+
const message = state.messages.find((item) => item.id === id);
|
|
251
|
+
|
|
252
|
+
if (!message) return;
|
|
253
|
+
|
|
254
|
+
message.dataOpen = !message.dataOpen;
|
|
255
|
+
const latestMessage = state.messages[state.messages.length - 1];
|
|
256
|
+
|
|
257
|
+
render({
|
|
258
|
+
scrollToLatest: message.dataOpen && latestMessage?.id === message.id
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function retryQuestion(event) {
|
|
263
|
+
const id = Number(event.currentTarget.dataset.retryQuestion);
|
|
264
|
+
const message = state.messages.find((item) => item.id === id);
|
|
265
|
+
|
|
266
|
+
if (!message || state.pending) return;
|
|
267
|
+
|
|
268
|
+
state.queryMode = message.mode;
|
|
269
|
+
render();
|
|
270
|
+
const input = document.querySelector("#question-input");
|
|
271
|
+
|
|
272
|
+
if (!input || input.disabled) return;
|
|
273
|
+
|
|
274
|
+
input.value = message.question;
|
|
275
|
+
input.focus();
|
|
276
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function saveWidgetFromMessage(event) {
|
|
280
|
+
const id = Number(event.currentTarget.dataset.saveWidget);
|
|
281
|
+
const message = state.messages.find((item) => item.id === id);
|
|
282
|
+
const sql = message?.response?.sql?.trim() || "";
|
|
283
|
+
|
|
284
|
+
if (!message || !sql || message.saveStatus === "saving" || message.saveStatus === "saved") {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
message.saveStatus = "saving";
|
|
289
|
+
message.saveError = "";
|
|
290
|
+
render();
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const response = await fetch("/api/widgets/from-query", {
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: {
|
|
296
|
+
"Content-Type": "application/json"
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify({
|
|
299
|
+
label: buildWidgetLabel(message.question),
|
|
300
|
+
widget_type: inferWidgetType(getRows(message.response)),
|
|
301
|
+
datasource_key: message.response?.metadata?.datasource_id || "default",
|
|
302
|
+
question: message.question,
|
|
303
|
+
sql,
|
|
304
|
+
result_mode: "data",
|
|
305
|
+
backend_url: state.backendUrl
|
|
306
|
+
})
|
|
307
|
+
});
|
|
308
|
+
const payload = await response.json().catch(() => ({}));
|
|
309
|
+
|
|
310
|
+
if (!response.ok) {
|
|
311
|
+
throw new Error(extractErrorMessage(payload));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
message.saveStatus = "saved";
|
|
315
|
+
} catch (error) {
|
|
316
|
+
message.saveStatus = "error";
|
|
317
|
+
message.saveError = error.message || "Widget could not be saved.";
|
|
318
|
+
} finally {
|
|
319
|
+
render();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildWidgetLabel(question) {
|
|
324
|
+
const compact = question.replace(/\s+/g, " ").trim();
|
|
325
|
+
|
|
326
|
+
return compact.length > 64 ? `${compact.slice(0, 61)}...` : compact || "Saved query";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function inferWidgetType(rows) {
|
|
330
|
+
if (rows.length === 1 && Object.keys(rows[0] || {}).length === 1) {
|
|
331
|
+
return "scalar";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return "table";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getSelectedMode(form) {
|
|
338
|
+
const value = new FormData(form).get("mode");
|
|
339
|
+
|
|
340
|
+
return normalizeQueryMode(value);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function submitQuestion(event) {
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
|
|
346
|
+
if (state.pending) return;
|
|
347
|
+
|
|
348
|
+
const input = event.currentTarget.elements.namedItem("question");
|
|
349
|
+
const question = String(input?.value || "").trim();
|
|
350
|
+
const mode = getSelectedMode(event.currentTarget);
|
|
351
|
+
|
|
352
|
+
if (!question) return;
|
|
353
|
+
|
|
354
|
+
input.value = "";
|
|
355
|
+
state.error = "";
|
|
356
|
+
state.pending = true;
|
|
357
|
+
const message = {
|
|
358
|
+
id: state.nextMessageId,
|
|
359
|
+
question,
|
|
360
|
+
mode,
|
|
361
|
+
status: "pending",
|
|
362
|
+
response: null,
|
|
363
|
+
error: "",
|
|
364
|
+
dataOpen: false,
|
|
365
|
+
saveStatus: "idle",
|
|
366
|
+
saveError: "",
|
|
367
|
+
progress: []
|
|
368
|
+
};
|
|
369
|
+
state.nextMessageId += 1;
|
|
370
|
+
state.messages.push(message);
|
|
371
|
+
render({ scrollToLatest: true });
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
if (mode === "investigation") {
|
|
375
|
+
await submitInvestigationQuestion(message, question, mode);
|
|
376
|
+
} else {
|
|
377
|
+
const response = await fetch("/api/query", {
|
|
378
|
+
method: "POST",
|
|
379
|
+
headers: {
|
|
380
|
+
"Content-Type": "application/json"
|
|
381
|
+
},
|
|
382
|
+
body: JSON.stringify({
|
|
383
|
+
question,
|
|
384
|
+
mode,
|
|
385
|
+
backend_url: state.backendUrl
|
|
386
|
+
})
|
|
387
|
+
});
|
|
388
|
+
const payload = await response.json().catch(() => ({}));
|
|
389
|
+
|
|
390
|
+
if (!response.ok) {
|
|
391
|
+
throw new Error(extractErrorMessage(payload));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
message.status = "ok";
|
|
395
|
+
message.response = payload;
|
|
396
|
+
}
|
|
397
|
+
} catch (error) {
|
|
398
|
+
message.status = "error";
|
|
399
|
+
message.error = error.message || "Request failed.";
|
|
400
|
+
} finally {
|
|
401
|
+
state.pending = false;
|
|
402
|
+
render({ scrollToLatest: true });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function submitInvestigationQuestion(message, question, mode) {
|
|
407
|
+
const response = await fetch("/api/query/stream", {
|
|
408
|
+
method: "POST",
|
|
409
|
+
headers: {
|
|
410
|
+
"Content-Type": "application/json"
|
|
411
|
+
},
|
|
412
|
+
body: JSON.stringify({
|
|
413
|
+
question,
|
|
414
|
+
mode,
|
|
415
|
+
backend_url: state.backendUrl
|
|
416
|
+
})
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
if (!response.ok) {
|
|
420
|
+
const payload = await response.json().catch(() => ({}));
|
|
421
|
+
throw new Error(extractErrorMessage(payload));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!response.body) {
|
|
425
|
+
throw new Error("Streaming response is not available.");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const reader = response.body.getReader();
|
|
429
|
+
const decoder = new TextDecoder();
|
|
430
|
+
let buffer = "";
|
|
431
|
+
let finalReceived = false;
|
|
432
|
+
|
|
433
|
+
while (true) {
|
|
434
|
+
const { done, value } = await reader.read();
|
|
435
|
+
if (done) break;
|
|
436
|
+
|
|
437
|
+
buffer += decoder.decode(value, { stream: true });
|
|
438
|
+
const lines = buffer.split("\n");
|
|
439
|
+
buffer = lines.pop() || "";
|
|
440
|
+
|
|
441
|
+
for (const line of lines) {
|
|
442
|
+
finalReceived = handleStreamLine(message, line) || finalReceived;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
buffer += decoder.decode();
|
|
447
|
+
if (buffer.trim()) {
|
|
448
|
+
finalReceived = handleStreamLine(message, buffer) || finalReceived;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!finalReceived) {
|
|
452
|
+
throw new Error("Investigation stream ended without a final response.");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function handleStreamLine(message, line) {
|
|
457
|
+
const trimmed = line.trim();
|
|
458
|
+
|
|
459
|
+
if (!trimmed) return false;
|
|
460
|
+
|
|
461
|
+
const payload = JSON.parse(trimmed);
|
|
462
|
+
|
|
463
|
+
if (payload?.error?.message) {
|
|
464
|
+
throw new Error(payload.error.message);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (payload?.final) {
|
|
468
|
+
message.status = "ok";
|
|
469
|
+
message.response = payload.final;
|
|
470
|
+
render({ scrollToLatest: true });
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (isProgressUpdate(payload)) {
|
|
475
|
+
message.progress = [
|
|
476
|
+
...message.progress,
|
|
477
|
+
payload
|
|
478
|
+
];
|
|
479
|
+
render({ scrollToLatest: true });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function isProgressUpdate(value) {
|
|
486
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return (typeof value.data_question === "string" &&
|
|
491
|
+
Array.isArray(value.decisions) &&
|
|
492
|
+
value.decisions.every((decision) => typeof decision === "string"));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function extractErrorMessage(payload) {
|
|
496
|
+
const detail = payload?.detail;
|
|
497
|
+
|
|
498
|
+
if (typeof detail === "string") {
|
|
499
|
+
return detail;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (detail?.error?.message) {
|
|
503
|
+
return detail.error.message;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (payload?.error?.message) {
|
|
507
|
+
return payload.error.message;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return "Request failed.";
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function getRows(response) {
|
|
514
|
+
return Array.isArray(response?.rows) ? response.rows : [];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function getColumns(rows) {
|
|
518
|
+
const columns = [];
|
|
519
|
+
|
|
520
|
+
rows.forEach((row) => {
|
|
521
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return;
|
|
522
|
+
|
|
523
|
+
Object.keys(row).forEach((column) => {
|
|
524
|
+
if (!columns.includes(column)) {
|
|
525
|
+
columns.push(column);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
return columns;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function renderDataTable(rows) {
|
|
534
|
+
const columns = getColumns(rows);
|
|
535
|
+
|
|
536
|
+
if (!rows.length) {
|
|
537
|
+
return `<div class="data-table-empty">No rows returned.</div>`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!columns.length) {
|
|
541
|
+
return `<div class="data-table-empty">Rows are not table-shaped.</div>`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return `
|
|
545
|
+
<div class="data-table-wrap" tabindex="0">
|
|
546
|
+
<table class="data-table">
|
|
547
|
+
<thead>
|
|
548
|
+
<tr>${columns.map((column) => `<th scope="col">${escapeHtml(column)}</th>`).join("")}</tr>
|
|
549
|
+
</thead>
|
|
550
|
+
<tbody>
|
|
551
|
+
${rows.map((row) => `
|
|
552
|
+
<tr>
|
|
553
|
+
${columns.map((column) => `<td>${escapeHtml(formatCellValue(row?.[column]))}</td>`).join("")}
|
|
554
|
+
</tr>`).join("")}
|
|
555
|
+
</tbody>
|
|
556
|
+
</table>
|
|
557
|
+
</div>`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function formatCellValue(value) {
|
|
561
|
+
if (value === null) {
|
|
562
|
+
return "null";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (typeof value === "object") {
|
|
566
|
+
return JSON.stringify(value);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return String(value ?? "");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function scrollToLatest() {
|
|
573
|
+
const scroll = () => {
|
|
574
|
+
const history = document.querySelector(".history");
|
|
575
|
+
|
|
576
|
+
if (!history) return;
|
|
577
|
+
|
|
578
|
+
history.scrollTo({
|
|
579
|
+
top: history.scrollHeight,
|
|
580
|
+
behavior: "auto"
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
requestAnimationFrame(() => {
|
|
585
|
+
scroll();
|
|
586
|
+
requestAnimationFrame(scroll);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
render();
|