glancer 1.0.0
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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +96 -0
- data/.rubocop.yml +54 -0
- data/CHANGELOG.md +88 -0
- data/CLAUDE.md +115 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +354 -0
- data/app/assets/config/glancer_manifest.js +1 -0
- data/app/assets/javascripts/glancer/application.js +15 -0
- data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
- data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
- data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
- data/app/assets/stylesheets/glancer/application.css +350 -0
- data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
- data/app/assets/stylesheets/glancer/list.css +31 -0
- data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
- data/app/assets/stylesheets/glancer/table.css +97 -0
- data/app/controllers/glancer/application_controller.rb +33 -0
- data/app/controllers/glancer/chats_controller.rb +49 -0
- data/app/controllers/glancer/messages_controller.rb +144 -0
- data/app/controllers/glancer/schema_controller.rb +29 -0
- data/app/controllers/glancer/settings_controller.rb +23 -0
- data/app/helpers/glancer/application_helper.rb +17 -0
- data/app/jobs/glancer/application_job.rb +6 -0
- data/app/jobs/glancer/process_message_job.rb +38 -0
- data/app/models/glancer/audit.rb +12 -0
- data/app/models/glancer/chat.rb +8 -0
- data/app/models/glancer/code_version.rb +12 -0
- data/app/models/glancer/embedding.rb +6 -0
- data/app/models/glancer/message.rb +25 -0
- data/app/models/glancer/setting.rb +23 -0
- data/app/models/glancer/sql_version.rb +6 -0
- data/app/views/glancer/_data/_importmap.json.erb +7 -0
- data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
- data/app/views/glancer/chats/_show.html.erb +52 -0
- data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
- data/app/views/glancer/chats/index.html.erb +10 -0
- data/app/views/glancer/chats/show.html.erb +1 -0
- data/app/views/glancer/messages/_data_table.html.erb +268 -0
- data/app/views/glancer/messages/_execution_error.html.erb +26 -0
- data/app/views/glancer/messages/_form.html.erb +93 -0
- data/app/views/glancer/messages/_message.html.erb +206 -0
- data/app/views/glancer/messages/_message_info.html.erb +176 -0
- data/app/views/glancer/messages/_temp_form.html.erb +100 -0
- data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
- data/app/views/glancer/schema/show.html.erb +123 -0
- data/app/views/glancer/settings/show.html.erb +306 -0
- data/app/views/glancer/shared/_icons.html.erb +126 -0
- data/app/views/layouts/glancer/application.html.erb +234 -0
- data/config/locales/glancer.en.yml +90 -0
- data/config/locales/glancer.es.yml +90 -0
- data/config/locales/glancer.pt-BR.yml +90 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
- data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
- data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
- data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
- data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
- data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
- data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
- data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
- data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
- data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
- data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
- data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
- data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
- data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
- data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
- data/lib/generators/glancer/install/install_generator.rb +74 -0
- data/lib/generators/glancer/install/templates/glancer.rb +227 -0
- data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
- data/lib/glancer/async_runner.rb +50 -0
- data/lib/glancer/chart_analyzer.rb +230 -0
- data/lib/glancer/configuration.rb +372 -0
- data/lib/glancer/engine.rb +90 -0
- data/lib/glancer/indexer/context_indexer.rb +58 -0
- data/lib/glancer/indexer/model_indexer.rb +64 -0
- data/lib/glancer/indexer/schema_indexer.rb +171 -0
- data/lib/glancer/indexer.rb +50 -0
- data/lib/glancer/retriever.rb +114 -0
- data/lib/glancer/utils/logger.rb +83 -0
- data/lib/glancer/utils/markdown_helper.rb +56 -0
- data/lib/glancer/utils/result_formatter.rb +25 -0
- data/lib/glancer/utils/table_stats.rb +18 -0
- data/lib/glancer/utils/transaction.rb +59 -0
- data/lib/glancer/version.rb +5 -0
- data/lib/glancer/workflow/ar_executor.rb +104 -0
- data/lib/glancer/workflow/ar_extractor.rb +25 -0
- data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
- data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
- data/lib/glancer/workflow/builder.rb +129 -0
- data/lib/glancer/workflow/cache.rb +55 -0
- data/lib/glancer/workflow/executor.rb +72 -0
- data/lib/glancer/workflow/llm.rb +123 -0
- data/lib/glancer/workflow/prompt_builder.rb +143 -0
- data/lib/glancer/workflow/query_enricher.rb +117 -0
- data/lib/glancer/workflow/sql_extractor.rb +42 -0
- data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
- data/lib/glancer/workflow/sql_validator.rb +67 -0
- data/lib/glancer/workflow.rb +158 -0
- data/lib/glancer.rb +50 -0
- data/lib/tasks/glancer/tailwind.rake +8 -0
- data/lib/tasks/glancer.rake +99 -0
- data/spec/glancer_spec.rb +62 -0
- data/spec/lib/glancer/async_runner_spec.rb +133 -0
- data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
- data/spec/lib/glancer/configuration_spec.rb +858 -0
- data/spec/lib/glancer/engine_spec.rb +209 -0
- data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
- data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
- data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
- data/spec/lib/glancer/indexer_spec.rb +95 -0
- data/spec/lib/glancer/retriever_spec.rb +179 -0
- data/spec/lib/glancer/utils/logger_spec.rb +85 -0
- data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
- data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
- data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
- data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
- data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
- data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
- data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
- data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
- data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
- data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
- data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
- data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
- data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
- data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
- data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
- data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
- data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
- data/spec/lib/glancer/workflow_spec.rb +308 -0
- data/spec/models/glancer/audit_spec.rb +82 -0
- data/spec/models/glancer/chat_spec.rb +60 -0
- data/spec/models/glancer/code_version_spec.rb +71 -0
- data/spec/models/glancer/embedding_spec.rb +73 -0
- data/spec/models/glancer/message_spec.rb +144 -0
- data/spec/models/glancer/setting_spec.rb +88 -0
- data/spec/models/glancer/sql_version_spec.rb +4 -0
- data/spec/spec_helper.rb +128 -0
- data/spec/support/schema.rb +55 -0
- metadata +255 -0
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Fallback step labels (English). Overridden at runtime by server-rendered
|
|
4
|
+
// translations passed through the `data-message-step-labels-value` attribute.
|
|
5
|
+
const DEFAULT_STEP_LABELS = {
|
|
6
|
+
enriching: "Enriching question…",
|
|
7
|
+
retrieving_context: "Retrieving context…",
|
|
8
|
+
generating_code: "Generating query…",
|
|
9
|
+
validating: "Validating query…",
|
|
10
|
+
executing: "Executing on database…",
|
|
11
|
+
humanizing: "Preparing response…",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
static targets = ["input", "form", "submitBtn", "cancelBtn", "charCount", "runBtn", "downloadBtn", "resultsContainer", "micBtn", "mentionChips", "editBtn", "processingLabel"]
|
|
16
|
+
static values = { startUrl: String, tables: Array, stepLabels: Object, pollUrl: String, isProcessing: Boolean }
|
|
17
|
+
|
|
18
|
+
connect() {
|
|
19
|
+
if (this.hasInputTarget) {
|
|
20
|
+
this._setupMentionBackdrop();
|
|
21
|
+
this.autoResize();
|
|
22
|
+
this.updateCharCount();
|
|
23
|
+
this.scrollToBottom();
|
|
24
|
+
this.inputTarget.focus();
|
|
25
|
+
this.inputTarget.addEventListener("blur", () => {
|
|
26
|
+
setTimeout(() => this._closeMentionDropdown(), 120);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Processing-placeholder mode: poll the server until the job finishes.
|
|
31
|
+
if (this.hasIsProcessingValue && this.isProcessingValue) {
|
|
32
|
+
this._startPolling();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
disconnect() {
|
|
37
|
+
this._stopPolling();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Form submission ──────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
handleKeydown(event) {
|
|
43
|
+
if (event.key === "Escape") {
|
|
44
|
+
const dropdown = document.getElementById("mention-dropdown");
|
|
45
|
+
if (dropdown && !dropdown.classList.contains("hidden")) return;
|
|
46
|
+
if (this._isSubmitting) { event.preventDefault(); this.cancelSubmit(); }
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
if (this._isSubmitting) return;
|
|
52
|
+
this.formTarget.requestSubmit();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
cancelSubmit() {
|
|
57
|
+
if (this._abortController) this._abortController.abort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async submit(event) {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
|
|
63
|
+
const input = this.inputTarget;
|
|
64
|
+
const content = input?.value?.trim();
|
|
65
|
+
if (!content) return;
|
|
66
|
+
|
|
67
|
+
// Temp chat mode: create chat via /start, redirect to new chat page.
|
|
68
|
+
if (this.hasStartUrlValue) {
|
|
69
|
+
await this._startNewSession(content);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
document.getElementById("chat-empty-state")?.remove();
|
|
74
|
+
this._showTempUserMessage(content);
|
|
75
|
+
|
|
76
|
+
this.setSubmitting(true);
|
|
77
|
+
const formData = new FormData(this.formTarget);
|
|
78
|
+
input.value = "";
|
|
79
|
+
this.autoResize();
|
|
80
|
+
this.updateCharCount();
|
|
81
|
+
this._updateMentionChips();
|
|
82
|
+
this._updateMentionBackdrop();
|
|
83
|
+
this._closeMentionDropdown();
|
|
84
|
+
|
|
85
|
+
this.showThinking();
|
|
86
|
+
this._abortController = new AbortController();
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Server enqueues the job and returns a Turbo Stream immediately.
|
|
90
|
+
// The stream inserts a processing placeholder; polling handles the rest.
|
|
91
|
+
const response = await fetch(this.formTarget.action, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: formData,
|
|
94
|
+
signal: this._abortController.signal,
|
|
95
|
+
headers: {
|
|
96
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
97
|
+
"X-CSRF-Token": this.csrfToken,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (response.ok) {
|
|
102
|
+
const html = await response.text();
|
|
103
|
+
this.removeThinking();
|
|
104
|
+
Turbo.renderStreamMessage(html);
|
|
105
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
106
|
+
this.scrollToBottom();
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
this.removeThinking();
|
|
110
|
+
document.getElementById("temp-user-message")?.remove();
|
|
111
|
+
if (error.name !== "AbortError") this.toast("Failed to send message", "error");
|
|
112
|
+
} finally {
|
|
113
|
+
this.setSubmitting(false);
|
|
114
|
+
this._abortController = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Background-job polling ───────────────────────────────────────────────────
|
|
119
|
+
// Activated on processing-placeholder elements (data-message-is-processing-value).
|
|
120
|
+
// Polls every 2 s; on completion the server returns a Turbo Stream that
|
|
121
|
+
// replaces this element with the rendered final message.
|
|
122
|
+
|
|
123
|
+
_startPolling() {
|
|
124
|
+
const labels = this.hasStepLabelsValue
|
|
125
|
+
? Object.values(this.stepLabelsValue)
|
|
126
|
+
: Object.values(DEFAULT_STEP_LABELS);
|
|
127
|
+
let i = 0;
|
|
128
|
+
this._labelTimer = setInterval(() => {
|
|
129
|
+
if (!this.hasProcessingLabelTarget) return;
|
|
130
|
+
this.processingLabelTarget.textContent = labels[i % labels.length];
|
|
131
|
+
i++;
|
|
132
|
+
}, 1500);
|
|
133
|
+
|
|
134
|
+
this._pollTimer = setInterval(() => this._poll(), 2000);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_stopPolling() {
|
|
138
|
+
clearInterval(this._labelTimer);
|
|
139
|
+
clearInterval(this._pollTimer);
|
|
140
|
+
this._labelTimer = null;
|
|
141
|
+
this._pollTimer = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async _poll() {
|
|
145
|
+
if (!this.hasPollUrlValue) return;
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetch(this.pollUrlValue, {
|
|
148
|
+
headers: {
|
|
149
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
150
|
+
"X-CSRF-Token": this.csrfToken,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (response.status === 204) return; // still processing
|
|
155
|
+
|
|
156
|
+
if (response.ok) {
|
|
157
|
+
this._stopPolling();
|
|
158
|
+
const html = await response.text();
|
|
159
|
+
Turbo.renderStreamMessage(html);
|
|
160
|
+
// Give Turbo two frames to finish DOM updates, then animate the new element.
|
|
161
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
162
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
163
|
+
this.scrollToBottom();
|
|
164
|
+
await this.typewriterEffect();
|
|
165
|
+
this.highlightCode();
|
|
166
|
+
if (window.glancerUpgradeMentions) window.glancerUpgradeMentions();
|
|
167
|
+
}
|
|
168
|
+
} catch { /* network error — retry on next tick */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_showTempUserMessage(content) {
|
|
172
|
+
const messagesEl = document.getElementById("messages-list") || document.getElementById("chat-messages");
|
|
173
|
+
if (!messagesEl) return;
|
|
174
|
+
|
|
175
|
+
const now = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
176
|
+
const rendered = this._renderMarkdown(content);
|
|
177
|
+
|
|
178
|
+
const el = document.createElement("div");
|
|
179
|
+
el.id = "temp-user-message";
|
|
180
|
+
el.className = "message user flex justify-end";
|
|
181
|
+
el.innerHTML = `
|
|
182
|
+
<div class="max-w-[78%] sm:max-w-[65%] min-w-0">
|
|
183
|
+
<div class="user-message-prose bg-primary-600 dark:bg-primary-700 text-white rounded-2xl rounded-tr-sm px-4 py-3 text-sm leading-relaxed prose prose-sm max-w-none overflow-hidden break-words">${rendered}</div>
|
|
184
|
+
<div class="flex items-center justify-end mt-1.5 px-1">
|
|
185
|
+
<span class="text-[11px] text-gray-400 dark:text-gray-500">${now}</span>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
`;
|
|
189
|
+
messagesEl.appendChild(el);
|
|
190
|
+
this.scrollToBottom();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Minimal safe markdown renderer for temp messages (no external deps)
|
|
194
|
+
_renderMarkdown(text) {
|
|
195
|
+
// 1. Extract fenced code blocks to protect them from further processing
|
|
196
|
+
const blocks = [];
|
|
197
|
+
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
|
198
|
+
const escaped = code.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
199
|
+
blocks.push(`<pre class="user-message-prose"><code>${escaped}</code></pre>`);
|
|
200
|
+
return `\x00BLOCK${blocks.length - 1}\x00`;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// 2. HTML-escape remaining text
|
|
204
|
+
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
205
|
+
|
|
206
|
+
// 3. Extract inline code
|
|
207
|
+
const inlines = [];
|
|
208
|
+
text = text.replace(/`([^`\n]+)`/g, (_, code) => {
|
|
209
|
+
inlines.push(`<code>${code}</code>`);
|
|
210
|
+
return `\x00INLINE${inlines.length - 1}\x00`;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// 4. Bold, italic, line breaks
|
|
214
|
+
text = text
|
|
215
|
+
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
|
|
216
|
+
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
|
|
217
|
+
.replace(/\n/g, "<br>");
|
|
218
|
+
|
|
219
|
+
// 5. Restore inline codes and blocks
|
|
220
|
+
text = text.replace(/\x00INLINE(\d+)\x00/g, (_, i) => inlines[+i]);
|
|
221
|
+
text = text.replace(/\x00BLOCK(\d+)\x00/g, (_, i) => blocks[+i]);
|
|
222
|
+
|
|
223
|
+
return text;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── New session (temp chat) ──────────────────────────────────────────────
|
|
227
|
+
// Uses a plain JSON POST (no SSE) — the server creates the chat and runs the
|
|
228
|
+
// full workflow synchronously, then returns the new chat_id for redirect.
|
|
229
|
+
// The thinking-indicator timer provides visual feedback during the wait.
|
|
230
|
+
|
|
231
|
+
async _startNewSession(content) {
|
|
232
|
+
document.getElementById("chat-empty-state")?.remove();
|
|
233
|
+
this._showTempUserMessage(content);
|
|
234
|
+
this.inputTarget.value = "";
|
|
235
|
+
this.autoResize();
|
|
236
|
+
this.setSubmitting(true);
|
|
237
|
+
this.showThinking();
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const body = new FormData();
|
|
241
|
+
body.append("content", content);
|
|
242
|
+
|
|
243
|
+
const response = await fetch(this.startUrlValue, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
body,
|
|
246
|
+
headers: {
|
|
247
|
+
"Accept": "application/json",
|
|
248
|
+
"X-CSRF-Token": this.csrfToken,
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const data = await response.json();
|
|
253
|
+
if (!response.ok || !data.chat_id) {
|
|
254
|
+
throw new Error(data.error || "Failed to start chat");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.removeThinking();
|
|
258
|
+
Turbo.visit(`/glancer/chats/${data.chat_id}`);
|
|
259
|
+
|
|
260
|
+
} catch (err) {
|
|
261
|
+
this.removeThinking();
|
|
262
|
+
document.getElementById("temp-user-message")?.remove();
|
|
263
|
+
this.toast(err.message || "Failed to start chat", "error");
|
|
264
|
+
} finally {
|
|
265
|
+
this.setSubmitting(false);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── SQL execution ─────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
async runQuery(event) {
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
const btn = event.currentTarget;
|
|
274
|
+
const messageId = btn.dataset.messageId;
|
|
275
|
+
|
|
276
|
+
// Close all other open result sections
|
|
277
|
+
document.querySelectorAll("[data-results-open='true']").forEach(section => {
|
|
278
|
+
if (section.id !== `results-section-${messageId}`) {
|
|
279
|
+
this._collapseResults(section);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Show loading in current container
|
|
284
|
+
const container = document.getElementById(`results-${messageId}`);
|
|
285
|
+
if (container) {
|
|
286
|
+
container.style.transition = "none";
|
|
287
|
+
container.style.maxHeight = "none";
|
|
288
|
+
container.style.overflow = "";
|
|
289
|
+
container.innerHTML = `
|
|
290
|
+
<div class="flex items-center gap-2 px-4 py-3 text-xs text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
|
291
|
+
<span class="inline-block w-3 h-3 rounded-full border-2 border-gray-300 border-t-primary-500 animate-spin"></span>
|
|
292
|
+
Executing query…
|
|
293
|
+
</div>
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
btn.disabled = true;
|
|
298
|
+
|
|
299
|
+
const editorWrapper = document.getElementById(`sql-editor-wrapper-${messageId}`);
|
|
300
|
+
const editorEl = document.getElementById(`sql-editor-${messageId}`);
|
|
301
|
+
const isEditing = editorWrapper && !editorWrapper.classList.contains("hidden");
|
|
302
|
+
const customCode = isEditing ? editorEl?.textContent?.trim() : null;
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const body = new FormData();
|
|
306
|
+
if (customCode) body.append("custom_code", customCode);
|
|
307
|
+
|
|
308
|
+
const response = await fetch(`/glancer/messages/${messageId}/run_code`, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
body: customCode ? body : undefined,
|
|
311
|
+
headers: {
|
|
312
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
313
|
+
"X-CSRF-Token": this.csrfToken,
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const html = await response.text();
|
|
318
|
+
Turbo.renderStreamMessage(html);
|
|
319
|
+
|
|
320
|
+
// Update code display with the saved code
|
|
321
|
+
if (isEditing && customCode) {
|
|
322
|
+
const codeEl = document.getElementById(`sql-code-${messageId}`);
|
|
323
|
+
if (codeEl) {
|
|
324
|
+
codeEl.textContent = customCode;
|
|
325
|
+
if (window.Prism) Prism.highlightElement(codeEl);
|
|
326
|
+
}
|
|
327
|
+
// Update the copy button's data-sql attribute
|
|
328
|
+
const scope = btn.closest("[data-controller='message']");
|
|
329
|
+
const copyBtn = scope?.querySelector("[data-action='click->message#copySql']");
|
|
330
|
+
if (copyBtn) copyBtn.dataset.sql = customCode;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Exit edit mode and reset button text
|
|
334
|
+
if (isEditing) {
|
|
335
|
+
this._exitEditMode(messageId);
|
|
336
|
+
this._setRunBtnText(messageId, null); // null = use default from DOM
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (container) {
|
|
341
|
+
container.innerHTML = `<div class="px-4 py-3 text-xs text-red-500 border-t border-gray-200 dark:border-gray-700">Error: ${error.message}</div>`;
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
btn.disabled = false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
exportToCSV(event) {
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
|
|
351
|
+
const scope = event.currentTarget.closest("[data-controller='message']");
|
|
352
|
+
const table = scope?.querySelector("table");
|
|
353
|
+
|
|
354
|
+
if (!table) {
|
|
355
|
+
this.toast("No data to export", "info");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const rows = Array.from(table.querySelectorAll("tr"));
|
|
360
|
+
const csv = rows.map(row =>
|
|
361
|
+
Array.from(row.querySelectorAll("th, td"))
|
|
362
|
+
.map(cell => `"${cell.innerText.trim().replace(/"/g, '""')}"`)
|
|
363
|
+
.join(",")
|
|
364
|
+
).join("\r\n");
|
|
365
|
+
|
|
366
|
+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
367
|
+
const url = URL.createObjectURL(blob);
|
|
368
|
+
const link = document.createElement("a");
|
|
369
|
+
link.href = url;
|
|
370
|
+
link.download = `glancer_${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.csv`;
|
|
371
|
+
link.click();
|
|
372
|
+
URL.revokeObjectURL(url);
|
|
373
|
+
this.toast("CSV exported", "success");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Results accordion ────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
toggleResults(event) {
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
const messageId = event.currentTarget.dataset.messageId;
|
|
381
|
+
const section = document.getElementById(`results-section-${messageId}`);
|
|
382
|
+
const container = document.getElementById(`results-${messageId}`);
|
|
383
|
+
const arrow = document.getElementById(`results-arrow-${messageId}`);
|
|
384
|
+
if (!section || !container) return;
|
|
385
|
+
|
|
386
|
+
const isOpen = section.dataset.resultsOpen === "true";
|
|
387
|
+
|
|
388
|
+
if (isOpen) {
|
|
389
|
+
this._collapseResults(section);
|
|
390
|
+
} else {
|
|
391
|
+
this._expandResults(section);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
_collapseResults(section) {
|
|
396
|
+
const id = section.id.replace("results-section-", "");
|
|
397
|
+
const container = document.getElementById(`results-${id}`);
|
|
398
|
+
const arrow = document.getElementById(`results-arrow-${id}`);
|
|
399
|
+
if (!container) return;
|
|
400
|
+
|
|
401
|
+
// Animate from current height to 0
|
|
402
|
+
const h = container.scrollHeight;
|
|
403
|
+
container.style.transition = "none";
|
|
404
|
+
container.style.maxHeight = h + "px";
|
|
405
|
+
container.style.overflow = "hidden";
|
|
406
|
+
container.offsetHeight; // force reflow
|
|
407
|
+
container.style.transition = "max-height 0.3s ease";
|
|
408
|
+
container.style.maxHeight = "0";
|
|
409
|
+
section.dataset.resultsOpen = "false";
|
|
410
|
+
if (arrow) arrow.style.transform = "rotate(-90deg)";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
_expandResults(section) {
|
|
414
|
+
const id = section.id.replace("results-section-", "");
|
|
415
|
+
const container = document.getElementById(`results-${id}`);
|
|
416
|
+
const arrow = document.getElementById(`results-arrow-${id}`);
|
|
417
|
+
if (!container) return;
|
|
418
|
+
|
|
419
|
+
container.style.transition = "max-height 0.3s ease";
|
|
420
|
+
container.style.overflow = "hidden";
|
|
421
|
+
container.style.maxHeight = container.scrollHeight + "px";
|
|
422
|
+
section.dataset.resultsOpen = "true";
|
|
423
|
+
if (arrow) arrow.style.transform = "rotate(0deg)";
|
|
424
|
+
container.addEventListener("transitionend", () => {
|
|
425
|
+
container.style.maxHeight = "none";
|
|
426
|
+
container.style.overflow = "";
|
|
427
|
+
}, { once: true });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Opens a full-screen dialog with the result table for the given message.
|
|
431
|
+
openFullscreenTable(event) {
|
|
432
|
+
const messageId = event.currentTarget.dataset.messageId;
|
|
433
|
+
const source = document.getElementById(`results-${messageId}`);
|
|
434
|
+
const tableEl = source?.querySelector("table");
|
|
435
|
+
if (!tableEl) return;
|
|
436
|
+
|
|
437
|
+
const dialog = document.createElement("dialog");
|
|
438
|
+
dialog.className = "glancer-fullscreen-dialog";
|
|
439
|
+
dialog.innerHTML = `
|
|
440
|
+
<div class="glancer-fullscreen-inner">
|
|
441
|
+
<div class="glancer-fullscreen-toolbar">
|
|
442
|
+
<span class="glancer-fullscreen-title">Results</span>
|
|
443
|
+
<button class="glancer-fullscreen-close" aria-label="Close">
|
|
444
|
+
<svg class="w-4 h-4" aria-hidden="true"><use href="#icon-x"/></svg>
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
<div class="glancer-fullscreen-body">
|
|
448
|
+
${tableEl.outerHTML}
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
`;
|
|
452
|
+
document.body.appendChild(dialog);
|
|
453
|
+
dialog.showModal();
|
|
454
|
+
|
|
455
|
+
const close = () => { dialog.close(); dialog.remove(); };
|
|
456
|
+
dialog.querySelector(".glancer-fullscreen-close").addEventListener("click", close);
|
|
457
|
+
dialog.addEventListener("click", e => { if (e.target === dialog) close(); });
|
|
458
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── SQL editing ──────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
toggleEditSql(event) {
|
|
464
|
+
const messageId = event.currentTarget.dataset.messageId;
|
|
465
|
+
this._toggleEditMode(messageId);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
_toggleEditMode(messageId) {
|
|
469
|
+
const codeWrapper = document.getElementById(`sql-code-wrapper-${messageId}`);
|
|
470
|
+
const editorWrapper = document.getElementById(`sql-editor-wrapper-${messageId}`);
|
|
471
|
+
const editorEl = document.getElementById(`sql-editor-${messageId}`);
|
|
472
|
+
const codeEl = document.getElementById(`sql-code-${messageId}`);
|
|
473
|
+
if (!editorWrapper || !codeWrapper) return;
|
|
474
|
+
|
|
475
|
+
const isEditing = !editorWrapper.classList.contains("hidden");
|
|
476
|
+
if (isEditing) {
|
|
477
|
+
this._exitEditMode(messageId);
|
|
478
|
+
this._setRunBtnText(messageId, null);
|
|
479
|
+
} else {
|
|
480
|
+
const code = codeEl?.textContent?.trim() || "";
|
|
481
|
+
editorEl.textContent = code;
|
|
482
|
+
this._setupCodeEditor(editorEl, messageId);
|
|
483
|
+
if (window.Prism) {
|
|
484
|
+
const lang = editorEl.classList.contains("language-ruby") ? "ruby" : "sql";
|
|
485
|
+
const grammar = Prism.languages[lang];
|
|
486
|
+
if (grammar) editorEl.innerHTML = Prism.highlight(code, grammar, lang);
|
|
487
|
+
}
|
|
488
|
+
editorWrapper.classList.remove("hidden");
|
|
489
|
+
codeWrapper.classList.add("hidden");
|
|
490
|
+
editorEl.focus();
|
|
491
|
+
// Move cursor to end
|
|
492
|
+
const range = document.createRange();
|
|
493
|
+
range.selectNodeContents(editorEl);
|
|
494
|
+
range.collapse(false);
|
|
495
|
+
const sel = window.getSelection();
|
|
496
|
+
sel.removeAllRanges();
|
|
497
|
+
sel.addRange(range);
|
|
498
|
+
this._setRunBtnText(messageId, "save_run");
|
|
499
|
+
this._setEditBtnState(true);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
_exitEditMode(messageId) {
|
|
504
|
+
document.getElementById(`sql-editor-wrapper-${messageId}`)?.classList.add("hidden");
|
|
505
|
+
document.getElementById(`sql-code-wrapper-${messageId}`)?.classList.remove("hidden");
|
|
506
|
+
this._setEditBtnState(false);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
_setEditBtnState(editing) {
|
|
510
|
+
if (!this.hasEditBtnTarget) return;
|
|
511
|
+
const use = this.editBtnTarget.querySelector("use");
|
|
512
|
+
if (use) use.setAttribute("href", editing ? "#icon-x" : "#icon-edit");
|
|
513
|
+
this.editBtnTarget.setAttribute("aria-label", editing ? "Cancel edit" : "Edit code");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
_setRunBtnText(messageId, key) {
|
|
517
|
+
const btn = document.querySelector(`[data-message-id="${messageId}"][data-message-target="runBtn"] span`);
|
|
518
|
+
if (!btn) return;
|
|
519
|
+
if (key === "save_run") {
|
|
520
|
+
btn.textContent = btn.closest("button")?.dataset?.saveRunLabel || "Save & Run";
|
|
521
|
+
} else {
|
|
522
|
+
btn.textContent = btn.closest("button")?.dataset?.runLabel || "Run";
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── Copy actions ─────────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
copySql(event) {
|
|
529
|
+
const sql = event.currentTarget.dataset.sql;
|
|
530
|
+
navigator.clipboard.writeText(sql)
|
|
531
|
+
.then(() => this.toast("SQL copied", "success"))
|
|
532
|
+
.catch(() => this.toast("Failed to copy", "error"));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
copyText(event) {
|
|
536
|
+
const msgEl = event.currentTarget.closest(".message.assistant");
|
|
537
|
+
const text = msgEl?.querySelector(".message-content")?.innerText || "";
|
|
538
|
+
navigator.clipboard.writeText(text)
|
|
539
|
+
.then(() => this.toast("Text copied", "success"))
|
|
540
|
+
.catch(() => this.toast("Failed to copy", "error"));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── Message info panel ───────────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
async openMessageInfo(event) {
|
|
546
|
+
const messageId = event.currentTarget.dataset.messageId;
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
const response = await fetch(`/glancer/messages/${messageId}/info`, {
|
|
550
|
+
headers: {
|
|
551
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
552
|
+
"X-CSRF-Token": this.csrfToken,
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const html = await response.text();
|
|
557
|
+
Turbo.renderStreamMessage(html);
|
|
558
|
+
|
|
559
|
+
requestAnimationFrame(() => {
|
|
560
|
+
setTimeout(() => {
|
|
561
|
+
document.getElementById("message-info--content")?.classList.remove("translate-x-full");
|
|
562
|
+
this.highlightCode();
|
|
563
|
+
}, 50);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
} catch (error) {
|
|
567
|
+
this.toast("Could not load message details", "error");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
closeMessageInfo() {
|
|
572
|
+
const panel = document.getElementById("message-info--content");
|
|
573
|
+
if (!panel) return;
|
|
574
|
+
|
|
575
|
+
panel.classList.add("translate-x-full");
|
|
576
|
+
panel.addEventListener("transitionend", () => {
|
|
577
|
+
document.getElementById("message-info--area")?.remove();
|
|
578
|
+
}, { once: true });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
stopPropagation(event) {
|
|
582
|
+
event.stopPropagation();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Audio recording (Web Speech API) ────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
toggleRecording() {
|
|
588
|
+
if (this.recording) {
|
|
589
|
+
this.recognition?.stop();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
594
|
+
if (!SR) {
|
|
595
|
+
this.toast("Speech recognition not supported in this browser", "error");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Determine language: use setting from <html> data attr, fallback to browser lang
|
|
600
|
+
const htmlEl = document.documentElement;
|
|
601
|
+
const settingLang = htmlEl.dataset.speechLang;
|
|
602
|
+
const lang = (settingLang && settingLang !== "auto") ? settingLang : (navigator.language || "en-US");
|
|
603
|
+
|
|
604
|
+
this.recognition = new SR();
|
|
605
|
+
this.recognition.lang = lang;
|
|
606
|
+
this.recognition.continuous = false;
|
|
607
|
+
this.recognition.interimResults = false;
|
|
608
|
+
|
|
609
|
+
this.recognition.onstart = () => {
|
|
610
|
+
this.recording = true;
|
|
611
|
+
if (this.hasMicBtnTarget) {
|
|
612
|
+
this.micBtnTarget.classList.add("text-red-500", "animate-pulse");
|
|
613
|
+
this.micBtnTarget.querySelector("use")?.setAttribute("href", "#icon-mic-off");
|
|
614
|
+
this.micBtnTarget.setAttribute("aria-label", "Stop recording");
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
this.recognition.onresult = (e) => {
|
|
619
|
+
const transcript = e.results[0][0].transcript;
|
|
620
|
+
if (this.hasInputTarget) {
|
|
621
|
+
this.inputTarget.value += (this.inputTarget.value ? " " : "") + transcript;
|
|
622
|
+
this.autoResize();
|
|
623
|
+
this.updateCharCount();
|
|
624
|
+
this.inputTarget.focus();
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
this.recognition.onerror = () => {
|
|
629
|
+
this.toast("Speech recognition error", "error");
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
this.recognition.onend = () => {
|
|
633
|
+
this.recording = false;
|
|
634
|
+
if (this.hasMicBtnTarget) {
|
|
635
|
+
this.micBtnTarget.classList.remove("text-red-500", "animate-pulse");
|
|
636
|
+
this.micBtnTarget.querySelector("use")?.setAttribute("href", "#icon-mic");
|
|
637
|
+
this.micBtnTarget.setAttribute("aria-label", "Record audio");
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
this.recognition.start();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── @ mention autocomplete ───────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
handleMentionInput() {
|
|
647
|
+
this._updateMentionDropdown();
|
|
648
|
+
this._updateMentionChips();
|
|
649
|
+
this._updateMentionBackdrop();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
handleMentionKeydown(event) {
|
|
653
|
+
const dropdown = document.getElementById("mention-dropdown");
|
|
654
|
+
if (!dropdown || dropdown.classList.contains("hidden")) return;
|
|
655
|
+
|
|
656
|
+
const items = [...dropdown.querySelectorAll("[data-mention-table]")];
|
|
657
|
+
if (!items.length) return;
|
|
658
|
+
|
|
659
|
+
const activeIdx = items.findIndex(el => el.classList.contains("bg-primary-50"));
|
|
660
|
+
|
|
661
|
+
if (event.key === "ArrowDown") {
|
|
662
|
+
event.preventDefault();
|
|
663
|
+
const next = (activeIdx + 1) % items.length;
|
|
664
|
+
items.forEach(el => el.classList.remove("bg-primary-50", "dark:bg-primary-950/40"));
|
|
665
|
+
items[next].classList.add("bg-primary-50", "dark:bg-primary-950/40");
|
|
666
|
+
} else if (event.key === "ArrowUp") {
|
|
667
|
+
event.preventDefault();
|
|
668
|
+
const prev = (activeIdx - 1 + items.length) % items.length;
|
|
669
|
+
items.forEach(el => el.classList.remove("bg-primary-50", "dark:bg-primary-950/40"));
|
|
670
|
+
items[prev].classList.add("bg-primary-50", "dark:bg-primary-950/40");
|
|
671
|
+
} else if (event.key === "Enter" || event.key === "Tab") {
|
|
672
|
+
const active = activeIdx >= 0 ? items[activeIdx] : items[0];
|
|
673
|
+
if (active) {
|
|
674
|
+
event.preventDefault();
|
|
675
|
+
this._selectMention(active.dataset.mentionTable);
|
|
676
|
+
}
|
|
677
|
+
} else if (event.key === "Escape") {
|
|
678
|
+
this._closeMentionDropdown();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
_getTables() {
|
|
683
|
+
return this.hasTablesValue ? this.tablesValue : [];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
_updateMentionDropdown() {
|
|
687
|
+
if (!this.hasInputTarget) return;
|
|
688
|
+
|
|
689
|
+
const input = this.inputTarget;
|
|
690
|
+
const pos = input.selectionStart;
|
|
691
|
+
const before = input.value.slice(0, pos);
|
|
692
|
+
const match = before.match(/@(\w*)$/);
|
|
693
|
+
|
|
694
|
+
if (!match) { this._closeMentionDropdown(); return; }
|
|
695
|
+
|
|
696
|
+
const query = match[1].toLowerCase();
|
|
697
|
+
const tables = this._getTables().filter(t => !query || t.toLowerCase().includes(query));
|
|
698
|
+
|
|
699
|
+
if (!tables.length) { this._closeMentionDropdown(); return; }
|
|
700
|
+
|
|
701
|
+
this._mentionStart = pos - match[0].length;
|
|
702
|
+
|
|
703
|
+
let dropdown = document.getElementById("mention-dropdown");
|
|
704
|
+
if (!dropdown) return;
|
|
705
|
+
|
|
706
|
+
dropdown.innerHTML = tables.slice(0, 12).map((t, i) => {
|
|
707
|
+
const highlighted = t.replace(
|
|
708
|
+
new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"),
|
|
709
|
+
'<mark class="bg-primary-100 dark:bg-primary-900/60 text-primary-700 dark:text-primary-300 not-italic rounded">$1</mark>'
|
|
710
|
+
);
|
|
711
|
+
return `<button type="button"
|
|
712
|
+
class="w-full text-left px-4 py-2 flex items-center gap-2 text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-950/40 transition-colors ${i === 0 ? "bg-primary-50 dark:bg-primary-950/40" : ""}"
|
|
713
|
+
data-mention-table="${t}"
|
|
714
|
+
role="option">
|
|
715
|
+
<svg class="w-3.5 h-3.5 text-primary-400 flex-shrink-0" aria-hidden="true"><use href="#icon-table"/></svg>
|
|
716
|
+
<span>${highlighted}</span>
|
|
717
|
+
</button>`;
|
|
718
|
+
}).join("");
|
|
719
|
+
|
|
720
|
+
dropdown.classList.remove("hidden");
|
|
721
|
+
|
|
722
|
+
dropdown.querySelectorAll("[data-mention-table]").forEach(btn => {
|
|
723
|
+
btn.addEventListener("mousedown", (e) => {
|
|
724
|
+
e.preventDefault();
|
|
725
|
+
this._selectMention(btn.dataset.mentionTable);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
_closeMentionDropdown() {
|
|
731
|
+
const dropdown = document.getElementById("mention-dropdown");
|
|
732
|
+
if (dropdown) dropdown.classList.add("hidden");
|
|
733
|
+
this._mentionStart = null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
_selectMention(tableName) {
|
|
737
|
+
if (!this.hasInputTarget || this._mentionStart == null) return;
|
|
738
|
+
|
|
739
|
+
const input = this.inputTarget;
|
|
740
|
+
const pos = input.selectionStart;
|
|
741
|
+
const val = input.value;
|
|
742
|
+
|
|
743
|
+
input.value = val.slice(0, this._mentionStart) + "@" + tableName + " " + val.slice(pos);
|
|
744
|
+
|
|
745
|
+
const newPos = this._mentionStart + tableName.length + 2;
|
|
746
|
+
input.setSelectionRange(newPos, newPos);
|
|
747
|
+
|
|
748
|
+
this._closeMentionDropdown();
|
|
749
|
+
this._updateMentionChips();
|
|
750
|
+
this._updateMentionBackdrop();
|
|
751
|
+
this.autoResize();
|
|
752
|
+
this.updateCharCount();
|
|
753
|
+
input.focus();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
_updateMentionChips() {
|
|
757
|
+
if (!this.hasMentionChipsTarget || !this.hasInputTarget) return;
|
|
758
|
+
|
|
759
|
+
const tables = new Set(this._getTables());
|
|
760
|
+
const matches = [...this.inputTarget.value.matchAll(/@(\w+)/g)]
|
|
761
|
+
.map(m => m[1])
|
|
762
|
+
.filter(n => tables.has(n));
|
|
763
|
+
const unique = [...new Set(matches)];
|
|
764
|
+
const chipsEl = this.mentionChipsTarget;
|
|
765
|
+
|
|
766
|
+
if (!unique.length) {
|
|
767
|
+
chipsEl.classList.add("hidden");
|
|
768
|
+
chipsEl.innerHTML = "";
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const schemaBase = document.querySelector("meta[name='glancer-schema-path']")?.content || "/glancer/db-schema";
|
|
773
|
+
chipsEl.classList.remove("hidden");
|
|
774
|
+
chipsEl.innerHTML = unique.map(t =>
|
|
775
|
+
`<a href="${schemaBase}?table=${encodeURIComponent(t)}" target="_blank" rel="noopener" class="mention-chip">
|
|
776
|
+
<svg class="w-3 h-3 opacity-60" aria-hidden="true"><use href="#icon-table"/></svg>
|
|
777
|
+
${t}
|
|
778
|
+
</a>`
|
|
779
|
+
).join("");
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ── @ mention backdrop (highlights @table in the textarea) ───────────────
|
|
783
|
+
|
|
784
|
+
_setupMentionBackdrop() {
|
|
785
|
+
if (!this.hasInputTarget) return;
|
|
786
|
+
const ta = this.inputTarget;
|
|
787
|
+
const container = ta.parentElement;
|
|
788
|
+
if (!container || container.querySelector(".mention-backdrop")) return;
|
|
789
|
+
|
|
790
|
+
container.style.position = "relative";
|
|
791
|
+
|
|
792
|
+
const bd = document.createElement("div");
|
|
793
|
+
bd.className = "mention-backdrop";
|
|
794
|
+
bd.setAttribute("aria-hidden", "true");
|
|
795
|
+
container.insertBefore(bd, ta);
|
|
796
|
+
|
|
797
|
+
// Copy computed layout styles so backdrop aligns pixel-perfectly with the textarea
|
|
798
|
+
requestAnimationFrame(() => {
|
|
799
|
+
const cs = window.getComputedStyle(ta);
|
|
800
|
+
bd.style.padding = cs.padding;
|
|
801
|
+
bd.style.fontFamily = cs.fontFamily;
|
|
802
|
+
bd.style.fontSize = cs.fontSize;
|
|
803
|
+
bd.style.fontWeight = cs.fontWeight;
|
|
804
|
+
bd.style.lineHeight = cs.lineHeight;
|
|
805
|
+
bd.style.letterSpacing = cs.letterSpacing;
|
|
806
|
+
bd.style.wordBreak = "break-word";
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
ta.addEventListener("scroll", () => { bd.scrollTop = ta.scrollTop; });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
_updateMentionBackdrop() {
|
|
813
|
+
if (!this.hasInputTarget) return;
|
|
814
|
+
const bd = this.inputTarget.parentElement?.querySelector(".mention-backdrop");
|
|
815
|
+
if (!bd) return;
|
|
816
|
+
|
|
817
|
+
const tables = new Set(this._getTables());
|
|
818
|
+
const escaped = (this.inputTarget.value || "")
|
|
819
|
+
.replace(/&/g, "&")
|
|
820
|
+
.replace(/</g, "<")
|
|
821
|
+
.replace(/>/g, ">");
|
|
822
|
+
|
|
823
|
+
bd.innerHTML = escaped.replace(/@(\w+)/g, (match, name) =>
|
|
824
|
+
tables.has(name) ? `<mark class="mention-hl">${match}</mark>` : match
|
|
825
|
+
);
|
|
826
|
+
bd.scrollTop = this.inputTarget.scrollTop;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ── Code editor (contenteditable + Prism live highlighting) ──────────────
|
|
830
|
+
|
|
831
|
+
_getCaretOffset(el) {
|
|
832
|
+
const sel = window.getSelection();
|
|
833
|
+
if (!sel || !sel.rangeCount) return 0;
|
|
834
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
835
|
+
range.selectNodeContents(el);
|
|
836
|
+
range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
|
|
837
|
+
return range.toString().length;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
_setCaretOffset(el, offset) {
|
|
841
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
842
|
+
let node;
|
|
843
|
+
let rem = offset;
|
|
844
|
+
while ((node = walker.nextNode())) {
|
|
845
|
+
if (rem <= node.length) {
|
|
846
|
+
const range = document.createRange();
|
|
847
|
+
range.setStart(node, rem);
|
|
848
|
+
range.collapse(true);
|
|
849
|
+
const sel = window.getSelection();
|
|
850
|
+
sel.removeAllRanges();
|
|
851
|
+
sel.addRange(range);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
rem -= node.length;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
_rehighlightEditor(el) {
|
|
859
|
+
if (!window.Prism) return;
|
|
860
|
+
const lang = el.classList.contains("language-ruby") ? "ruby" : "sql";
|
|
861
|
+
const grammar = Prism.languages[lang];
|
|
862
|
+
if (!grammar) return;
|
|
863
|
+
|
|
864
|
+
const offset = this._getCaretOffset(el);
|
|
865
|
+
el.innerHTML = Prism.highlight(el.textContent, grammar, lang);
|
|
866
|
+
this._setCaretOffset(el, offset);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
_setupCodeEditor(editorEl, messageId) {
|
|
870
|
+
if (editorEl._glancerSetup) return;
|
|
871
|
+
editorEl._glancerSetup = true;
|
|
872
|
+
|
|
873
|
+
editorEl.addEventListener("input", () => this._rehighlightEditor(editorEl));
|
|
874
|
+
|
|
875
|
+
// Plain-text paste only
|
|
876
|
+
editorEl.addEventListener("paste", (e) => {
|
|
877
|
+
e.preventDefault();
|
|
878
|
+
const text = e.clipboardData.getData("text/plain");
|
|
879
|
+
document.execCommand("insertText", false, text);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
editorEl.addEventListener("keydown", (e) => {
|
|
883
|
+
if (e.key === "Tab") {
|
|
884
|
+
e.preventDefault();
|
|
885
|
+
document.execCommand("insertText", false, " ");
|
|
886
|
+
} else if (e.key === "Escape") {
|
|
887
|
+
e.preventDefault();
|
|
888
|
+
const mid = messageId || editorEl.id.replace("sql-editor-", "");
|
|
889
|
+
this._exitEditMode(mid);
|
|
890
|
+
this._setRunBtnText(mid, null);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ── UX helpers ──────────────────────────────────────────────────────────
|
|
896
|
+
|
|
897
|
+
autoResize() {
|
|
898
|
+
if (!this.hasInputTarget) return;
|
|
899
|
+
const el = this.inputTarget;
|
|
900
|
+
el.style.height = "auto";
|
|
901
|
+
el.style.height = `${Math.min(Math.max(el.scrollHeight, 42), 200)}px`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
updateCharCount() {
|
|
905
|
+
if (!this.hasInputTarget || !this.hasCharCountTarget) return;
|
|
906
|
+
const len = this.inputTarget.value.length;
|
|
907
|
+
this.charCountTarget.textContent = `${len} / 2000`;
|
|
908
|
+
this.charCountTarget.classList.toggle("text-red-400", len > 1800);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
scrollToBottom() {
|
|
912
|
+
const el = document.getElementById("chat-messages");
|
|
913
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
setSubmitting(loading) {
|
|
917
|
+
this._isSubmitting = loading;
|
|
918
|
+
if (this.hasSubmitBtnTarget) this.submitBtnTarget.disabled = loading;
|
|
919
|
+
if (this.hasCancelBtnTarget) this.cancelBtnTarget.classList.toggle("hidden", !loading);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ── Thinking indicator ────────────────────────────────────────────────────
|
|
923
|
+
// Label updates are driven by real SSE status events from the server.
|
|
924
|
+
// A local timer also cycles labels so users always see progress even when
|
|
925
|
+
// the server buffers all SSE events and delivers them in one batch.
|
|
926
|
+
|
|
927
|
+
showThinking() {
|
|
928
|
+
this.removeThinking();
|
|
929
|
+
|
|
930
|
+
const el = document.createElement("div");
|
|
931
|
+
el.id = "thinking-indicator";
|
|
932
|
+
el.className = "flex items-start gap-3";
|
|
933
|
+
el.innerHTML = `
|
|
934
|
+
<div class="flex-shrink-0 w-7 h-7 rounded-lg bg-primary-100 dark:bg-primary-950 flex items-center justify-center" aria-hidden="true">
|
|
935
|
+
<svg class="w-3.5 h-3.5 text-primary-600 dark:text-primary-400"><use href="#icon-database"/></svg>
|
|
936
|
+
</div>
|
|
937
|
+
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400 dark:text-gray-500" role="status" aria-live="polite">
|
|
938
|
+
<span id="thinking-label">Processing…</span>
|
|
939
|
+
<span class="flex gap-0.5" aria-hidden="true">
|
|
940
|
+
<span class="w-1.5 h-1.5 rounded-full bg-primary-400 dark:bg-primary-500 animate-bounce" style="animation-delay:0ms"></span>
|
|
941
|
+
<span class="w-1.5 h-1.5 rounded-full bg-primary-400 dark:bg-primary-500 animate-bounce" style="animation-delay:150ms"></span>
|
|
942
|
+
<span class="w-1.5 h-1.5 rounded-full bg-primary-400 dark:bg-primary-500 animate-bounce" style="animation-delay:300ms"></span>
|
|
943
|
+
</span>
|
|
944
|
+
</div>
|
|
945
|
+
`;
|
|
946
|
+
|
|
947
|
+
(document.getElementById("messages-list") || document.getElementById("chat-messages"))?.appendChild(el);
|
|
948
|
+
this.scrollToBottom();
|
|
949
|
+
|
|
950
|
+
// Cycle step labels on a timer so the UI always shows progress,
|
|
951
|
+
// even when SSE events arrive buffered in a single chunk at the end.
|
|
952
|
+
const labels = this.hasStepLabelsValue
|
|
953
|
+
? Object.values(this.stepLabelsValue)
|
|
954
|
+
: Object.values(DEFAULT_STEP_LABELS);
|
|
955
|
+
let i = 0;
|
|
956
|
+
this._thinkingTimer = setInterval(() => {
|
|
957
|
+
const labelEl = document.getElementById("thinking-label");
|
|
958
|
+
if (!labelEl) { clearInterval(this._thinkingTimer); return; }
|
|
959
|
+
labelEl.textContent = labels[i % labels.length];
|
|
960
|
+
i++;
|
|
961
|
+
}, 1500);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
removeThinking() {
|
|
965
|
+
if (this._thinkingTimer) {
|
|
966
|
+
clearInterval(this._thinkingTimer);
|
|
967
|
+
this._thinkingTimer = null;
|
|
968
|
+
}
|
|
969
|
+
document.getElementById("thinking-indicator")?.remove();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ── Typewriter effect ─────────────────────────────────────────────────────
|
|
973
|
+
|
|
974
|
+
async typewriterEffect() {
|
|
975
|
+
const msgEl = document.querySelector(".message.assistant:last-of-type");
|
|
976
|
+
const el = msgEl?.querySelector(".message-content");
|
|
977
|
+
if (!el) return;
|
|
978
|
+
|
|
979
|
+
// Hide SQL block — will slide in after text animation completes
|
|
980
|
+
const sqlBlock = msgEl.querySelector("[data-sql-block]");
|
|
981
|
+
if (sqlBlock) {
|
|
982
|
+
sqlBlock.style.overflow = "hidden";
|
|
983
|
+
sqlBlock.style.maxHeight = "0";
|
|
984
|
+
sqlBlock.style.opacity = "0";
|
|
985
|
+
sqlBlock.style.transition = "";
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const html = el.innerHTML;
|
|
989
|
+
el.innerHTML = '<span class="cursor-blink" aria-hidden="true">|</span>';
|
|
990
|
+
|
|
991
|
+
const append = async (node, target) => {
|
|
992
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
993
|
+
for (const ch of node.textContent) {
|
|
994
|
+
target.append(ch);
|
|
995
|
+
await new Promise(r => setTimeout(r, 8));
|
|
996
|
+
}
|
|
997
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
998
|
+
const clone = node.cloneNode(false);
|
|
999
|
+
target.appendChild(clone);
|
|
1000
|
+
for (const child of node.childNodes) await append(child, clone);
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
const tmp = document.createElement("div");
|
|
1005
|
+
tmp.innerHTML = html;
|
|
1006
|
+
const cursor = Object.assign(document.createElement("span"), {
|
|
1007
|
+
className: "cursor-blink",
|
|
1008
|
+
textContent: "|",
|
|
1009
|
+
ariaHidden: "true"
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
for (const node of tmp.childNodes) {
|
|
1013
|
+
if (el.lastChild?.classList?.contains("cursor-blink")) el.removeChild(el.lastChild);
|
|
1014
|
+
await append(node, el);
|
|
1015
|
+
el.appendChild(cursor);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
el.innerHTML = html;
|
|
1019
|
+
this.highlightCode();
|
|
1020
|
+
|
|
1021
|
+
// Slide in the SQL block now that text is fully rendered
|
|
1022
|
+
if (sqlBlock) {
|
|
1023
|
+
requestAnimationFrame(() => {
|
|
1024
|
+
sqlBlock.style.transition = "max-height 0.6s ease, opacity 0.45s ease 0.1s";
|
|
1025
|
+
sqlBlock.style.maxHeight = `${sqlBlock.scrollHeight + 400}px`;
|
|
1026
|
+
sqlBlock.style.opacity = "1";
|
|
1027
|
+
sqlBlock.addEventListener("transitionend", () => {
|
|
1028
|
+
sqlBlock.style.maxHeight = "none";
|
|
1029
|
+
sqlBlock.style.overflow = "";
|
|
1030
|
+
}, { once: true });
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ── Syntax highlighting ───────────────────────────────────────────────────
|
|
1036
|
+
|
|
1037
|
+
highlightCode() {
|
|
1038
|
+
if (window.Prism) {
|
|
1039
|
+
setTimeout(() => Prism.highlightAll(), 50);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ── Toast ────────────────────────────────────────────────────────────────
|
|
1044
|
+
|
|
1045
|
+
toast(message, type = "info") {
|
|
1046
|
+
document.dispatchEvent(new CustomEvent("glancer:toast", { detail: { message, type } }));
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
get csrfToken() {
|
|
1050
|
+
return document.querySelector("[name='csrf-token']")?.content ?? "";
|
|
1051
|
+
}
|
|
1052
|
+
}
|