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,268 @@
|
|
|
1
|
+
<% if data.present? && data.any? %>
|
|
2
|
+
<%# Warning: only when there is no LIMIT and no explicit time filter %>
|
|
3
|
+
<% sql_text = @message&.code.to_s.downcase %>
|
|
4
|
+
<% has_no_limit = !sql_text.match?(/\blimit\b|\btop\s+\d/) %>
|
|
5
|
+
<% has_time_filter = sql_text.match?(/interval|date_sub|date_add|date_format|curdate|now\s*\(|getdate|dateadd|datediff|sysdate|between\s+[\w'"]+\s+and\s+[\w'"]+|\d{4}-\d{2}-\d{2}/) %>
|
|
6
|
+
<% is_large = data.size >= 500 %>
|
|
7
|
+
<% if (has_no_limit && !has_time_filter) || is_large %>
|
|
8
|
+
<div class="flex items-center gap-2 px-3 py-2 bg-amber-50 dark:bg-amber-950 border-b border-amber-100 dark:border-amber-900 text-xs text-amber-700 dark:text-amber-400">
|
|
9
|
+
<svg class="w-3.5 h-3.5 flex-shrink-0" aria-hidden="true"><use href="#icon-warning"/></svg>
|
|
10
|
+
<% if is_large %>
|
|
11
|
+
<%= t("glancer.messages.large_warning", count: data.size) %>
|
|
12
|
+
<% else %>
|
|
13
|
+
<%= t("glancer.messages.no_limit_warning") %>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<%# Chart grid — one card per chart config %>
|
|
19
|
+
<% raw_cd = local_assigns[:chart_data]
|
|
20
|
+
chart_configs = (raw_cd.is_a?(Array) ? raw_cd : raw_cd.is_a?(Hash) ? [raw_cd] : [])
|
|
21
|
+
.compact
|
|
22
|
+
.select { |c| c.is_a?(Hash) && c.key?(:type) && c.key?(:datasets) } %>
|
|
23
|
+
<% if chart_configs.any? %>
|
|
24
|
+
<% msg_id = @message&.id || SecureRandom.hex(4) %>
|
|
25
|
+
<div class="border-b border-gray-100 dark:border-gray-800">
|
|
26
|
+
<div class="<%= chart_configs.size > 1 ? 'grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-gray-100 dark:divide-gray-800' : '' %>">
|
|
27
|
+
<% chart_configs.each_with_index do |cfg, idx| %>
|
|
28
|
+
<div class="relative px-4 pt-4 pb-3">
|
|
29
|
+
<button onclick="glancerOpenChartFullscreen('<%= "#{msg_id}-#{idx}" %>')"
|
|
30
|
+
class="absolute top-3 right-3 p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors z-10"
|
|
31
|
+
aria-label="Expand chart fullscreen">
|
|
32
|
+
<svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-maximize"/></svg>
|
|
33
|
+
</button>
|
|
34
|
+
<canvas id="chart-<%= msg_id %>-<%= idx %>" style="max-height:260px;"></canvas>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<script>
|
|
41
|
+
(function() {
|
|
42
|
+
if (typeof Chart === 'undefined') return;
|
|
43
|
+
|
|
44
|
+
window._glancerChartCfg = window._glancerChartCfg || {};
|
|
45
|
+
|
|
46
|
+
var PALETTE = [
|
|
47
|
+
'rgba(147,51,234,0.8)','rgba(59,130,246,0.8)','rgba(16,185,129,0.8)',
|
|
48
|
+
'rgba(245,158,11,0.8)','rgba(239,68,68,0.8)','rgba(236,72,153,0.8)',
|
|
49
|
+
'rgba(20,184,166,0.8)','rgba(249,115,22,0.8)','rgba(132,204,22,0.8)',
|
|
50
|
+
'rgba(251,146,60,0.8)'
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function styleDatasets(chartCfg) {
|
|
54
|
+
if (!chartCfg || !Array.isArray(chartCfg.datasets)) return;
|
|
55
|
+
chartCfg.datasets.forEach(function(ds, i) {
|
|
56
|
+
var color = PALETTE[i % PALETTE.length];
|
|
57
|
+
if (chartCfg.type === 'line') {
|
|
58
|
+
ds.borderColor = color;
|
|
59
|
+
ds.backgroundColor = color.replace('0.8', '0.12');
|
|
60
|
+
ds.tension = 0.35;
|
|
61
|
+
ds.fill = chartCfg.datasets.length === 1;
|
|
62
|
+
ds.pointRadius = Math.max(2, Math.min(5, Math.floor(200 / Math.max((chartCfg.labels || []).length, 1))));
|
|
63
|
+
ds.pointHoverRadius = ds.pointRadius + 2;
|
|
64
|
+
} else if (chartCfg.type === 'doughnut') {
|
|
65
|
+
ds.backgroundColor = PALETTE;
|
|
66
|
+
ds.borderWidth = 2;
|
|
67
|
+
} else if (chartCfg.type === 'scatter') {
|
|
68
|
+
ds.backgroundColor = color;
|
|
69
|
+
ds.borderColor = color.replace('0.8', '1');
|
|
70
|
+
ds.pointRadius = 5;
|
|
71
|
+
ds.pointHoverRadius = 7;
|
|
72
|
+
} else {
|
|
73
|
+
ds.backgroundColor = color;
|
|
74
|
+
ds.borderColor = color.replace('0.8', '1');
|
|
75
|
+
ds.borderWidth = 1;
|
|
76
|
+
ds.borderRadius = 3;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildScales(chartCfg) {
|
|
82
|
+
if (chartCfg.type === 'doughnut') return {};
|
|
83
|
+
if (chartCfg.type === 'scatter') {
|
|
84
|
+
return {
|
|
85
|
+
x: { type: 'linear', title: { display: !!chartCfg.xLabel, text: chartCfg.xLabel || '', font: { size: 10 } }, ticks: { font: { size: 10 } } },
|
|
86
|
+
y: { title: { display: !!chartCfg.yLabel, text: chartCfg.yLabel || '', font: { size: 10 } }, ticks: { font: { size: 10 } }, beginAtZero: chartCfg.type === 'bar' }
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
x: { ticks: { font: { size: 10 }, maxRotation: 45, maxTicksLimit: 14 } },
|
|
91
|
+
y: { ticks: { font: { size: 10 } }, beginAtZero: chartCfg.type === 'bar' }
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
window.glancerOpenChartFullscreen = function(key) {
|
|
96
|
+
var orig = window._glancerChartCfg[key];
|
|
97
|
+
if (!orig || typeof Chart === 'undefined') return;
|
|
98
|
+
var cfg = JSON.parse(JSON.stringify(orig));
|
|
99
|
+
styleDatasets(cfg);
|
|
100
|
+
|
|
101
|
+
var dialog = document.createElement('dialog');
|
|
102
|
+
dialog.className = 'glancer-fullscreen-dialog';
|
|
103
|
+
var typeLabel = cfg.type.charAt(0).toUpperCase() + cfg.type.slice(1) + ' Chart';
|
|
104
|
+
dialog.innerHTML =
|
|
105
|
+
'<div class="glancer-fullscreen-inner">' +
|
|
106
|
+
'<div class="glancer-fullscreen-toolbar">' +
|
|
107
|
+
'<span class="glancer-fullscreen-title">' + typeLabel + '</span>' +
|
|
108
|
+
'<button class="glancer-fullscreen-close" aria-label="Close">' +
|
|
109
|
+
'<svg class="w-4 h-4" aria-hidden="true"><use href="#icon-x"/></svg>' +
|
|
110
|
+
'</button>' +
|
|
111
|
+
'</div>' +
|
|
112
|
+
'<div class="glancer-fullscreen-body" style="display:flex;align-items:center;justify-content:center;padding:1.5rem;">' +
|
|
113
|
+
'<canvas style="width:100%;height:100%;"></canvas>' +
|
|
114
|
+
'</div>' +
|
|
115
|
+
'</div>';
|
|
116
|
+
document.body.appendChild(dialog);
|
|
117
|
+
dialog.showModal();
|
|
118
|
+
|
|
119
|
+
var ctx = dialog.querySelector('canvas');
|
|
120
|
+
new Chart(ctx, {
|
|
121
|
+
type: cfg.type,
|
|
122
|
+
data: {
|
|
123
|
+
labels: cfg.type === 'scatter' ? undefined : cfg.labels,
|
|
124
|
+
datasets: cfg.datasets
|
|
125
|
+
},
|
|
126
|
+
options: {
|
|
127
|
+
responsive: true,
|
|
128
|
+
maintainAspectRatio: false,
|
|
129
|
+
animation: { duration: 400 },
|
|
130
|
+
plugins: {
|
|
131
|
+
legend: {
|
|
132
|
+
display: cfg.datasets.length > 1 || cfg.type === 'doughnut',
|
|
133
|
+
position: 'bottom',
|
|
134
|
+
labels: { boxWidth: 14, font: { size: 13 } }
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
scales: buildScales(cfg)
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
var close = function() { dialog.close(); dialog.remove(); };
|
|
142
|
+
dialog.querySelector('.glancer-fullscreen-close').addEventListener('click', close);
|
|
143
|
+
dialog.addEventListener('click', function(e) { if (e.target === dialog) close(); });
|
|
144
|
+
dialog.addEventListener('close', function() { dialog.remove(); });
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
<% chart_configs.each_with_index do |chart_rb, idx| %>
|
|
148
|
+
(function() {
|
|
149
|
+
var key = '<%= "#{msg_id}-#{idx}" %>';
|
|
150
|
+
var cfg = <%= chart_rb.to_json.gsub('</', '<\/').html_safe %>;
|
|
151
|
+
if (!cfg || !Array.isArray(cfg.datasets)) return;
|
|
152
|
+
window._glancerChartCfg[key] = cfg;
|
|
153
|
+
|
|
154
|
+
var ctx = document.getElementById('chart-' + key);
|
|
155
|
+
if (!ctx) return;
|
|
156
|
+
var chartCfg = JSON.parse(JSON.stringify(cfg));
|
|
157
|
+
styleDatasets(chartCfg);
|
|
158
|
+
new Chart(ctx, {
|
|
159
|
+
type: chartCfg.type,
|
|
160
|
+
data: {
|
|
161
|
+
labels: chartCfg.type === 'scatter' ? undefined : chartCfg.labels,
|
|
162
|
+
datasets: chartCfg.datasets
|
|
163
|
+
},
|
|
164
|
+
options: {
|
|
165
|
+
responsive: true,
|
|
166
|
+
maintainAspectRatio: true,
|
|
167
|
+
animation: { duration: 400 },
|
|
168
|
+
plugins: {
|
|
169
|
+
legend: {
|
|
170
|
+
display: chartCfg.datasets.length > 1 || chartCfg.type === 'doughnut',
|
|
171
|
+
position: 'bottom',
|
|
172
|
+
labels: { boxWidth: 12, font: { size: 11 } }
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
scales: buildScales(chartCfg)
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
})();
|
|
179
|
+
<% end %>
|
|
180
|
+
})();
|
|
181
|
+
</script>
|
|
182
|
+
<% end %>
|
|
183
|
+
|
|
184
|
+
<%# Data table %>
|
|
185
|
+
<div>
|
|
186
|
+
<div class="overflow-x-auto">
|
|
187
|
+
<table class="min-w-full text-xs">
|
|
188
|
+
<thead>
|
|
189
|
+
<tr class="bg-gray-50 dark:bg-gray-800">
|
|
190
|
+
<% data.first&.keys&.each do |key| %>
|
|
191
|
+
<th class="px-3 py-2 text-left font-semibold text-gray-500 dark:text-gray-400 whitespace-nowrap border-b border-gray-200 dark:border-gray-700">
|
|
192
|
+
<%= key %>
|
|
193
|
+
</th>
|
|
194
|
+
<% end %>
|
|
195
|
+
</tr>
|
|
196
|
+
</thead>
|
|
197
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
|
198
|
+
<% data.each do |row| %>
|
|
199
|
+
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
|
200
|
+
<% row.values.each do |value| %>
|
|
201
|
+
<td class="px-3 py-2 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
|
202
|
+
<%= value.nil? ? '<span class="text-gray-300 dark:text-gray-600 italic">null</span>'.html_safe : value %>
|
|
203
|
+
</td>
|
|
204
|
+
<% end %>
|
|
205
|
+
</tr>
|
|
206
|
+
<% end %>
|
|
207
|
+
</tbody>
|
|
208
|
+
</table>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<script>
|
|
213
|
+
(function() {
|
|
214
|
+
var msgId = '<%= @message&.id %>';
|
|
215
|
+
var section = document.getElementById('results-section-' + msgId);
|
|
216
|
+
var header = document.getElementById('results-header-' + msgId);
|
|
217
|
+
var meta = document.getElementById('results-meta-' + msgId);
|
|
218
|
+
var container = document.getElementById('results-' + msgId);
|
|
219
|
+
var arrow = document.getElementById('results-arrow-' + msgId);
|
|
220
|
+
var scope = container?.closest('[data-controller="message"]');
|
|
221
|
+
var dlBtn = scope?.querySelector('[data-message-target="downloadBtn"]');
|
|
222
|
+
|
|
223
|
+
if (dlBtn) dlBtn.classList.remove('hidden');
|
|
224
|
+
|
|
225
|
+
if (section && header && container) {
|
|
226
|
+
section.dataset.resultsOpen = 'true';
|
|
227
|
+
header.classList.remove('hidden');
|
|
228
|
+
header.classList.add('flex');
|
|
229
|
+
container.style.maxHeight = 'none';
|
|
230
|
+
container.style.overflow = '';
|
|
231
|
+
|
|
232
|
+
if (typeof Chart !== 'undefined') {
|
|
233
|
+
requestAnimationFrame(function() {
|
|
234
|
+
container.querySelectorAll('canvas').forEach(function(canvas) {
|
|
235
|
+
var chart = Chart.getChart(canvas);
|
|
236
|
+
if (chart) chart.resize();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
var rowCount = <%= data.size %>;
|
|
242
|
+
var rowLabel = rowCount === 1 ? '<%= j t("glancer.messages.row") %>' : '<%= j t("glancer.messages.rows") %>';
|
|
243
|
+
var timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
244
|
+
var ranPrefix = '<%= j t("glancer.messages.ran_at", time: "").strip.chomp(":").strip %>';
|
|
245
|
+
if (meta) meta.textContent = rowCount + ' ' + rowLabel + ' · ' + ranPrefix + ' ' + timeStr;
|
|
246
|
+
if (arrow) arrow.style.transform = 'rotate(0deg)';
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
</script>
|
|
250
|
+
<% else %>
|
|
251
|
+
<div class="px-4 py-3 text-xs text-center text-gray-400 dark:text-gray-500">
|
|
252
|
+
<%= t("glancer.messages.no_results") %>
|
|
253
|
+
</div>
|
|
254
|
+
<script>
|
|
255
|
+
(function() {
|
|
256
|
+
var msgId = '<%= @message&.id %>';
|
|
257
|
+
var section = document.getElementById('results-section-' + msgId);
|
|
258
|
+
var header = document.getElementById('results-header-' + msgId);
|
|
259
|
+
if (section && header) {
|
|
260
|
+
section.dataset.resultsOpen = 'true';
|
|
261
|
+
header.classList.remove('hidden');
|
|
262
|
+
header.classList.add('flex');
|
|
263
|
+
var container = document.getElementById('results-' + msgId);
|
|
264
|
+
if (container) { container.style.maxHeight = 'none'; container.style.overflow = ''; }
|
|
265
|
+
}
|
|
266
|
+
})();
|
|
267
|
+
</script>
|
|
268
|
+
<% end %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<div class="px-4 py-3 border-t border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950">
|
|
2
|
+
<div class="flex items-start gap-2">
|
|
3
|
+
<svg class="w-3.5 h-3.5 text-red-500 flex-shrink-0 mt-0.5" aria-hidden="true"><use href="#icon-x"/></svg>
|
|
4
|
+
<div>
|
|
5
|
+
<p class="text-xs font-medium text-red-700 dark:text-red-400"><%= t("glancer.messages.execution_failed") %></p>
|
|
6
|
+
<p class="text-[11px] text-red-600 dark:text-red-500 mt-0.5 font-mono"><%= error_message %></p>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
<script>
|
|
10
|
+
(function() {
|
|
11
|
+
var msgId = '<%= @message&.id %>';
|
|
12
|
+
var section = document.getElementById('results-section-' + msgId);
|
|
13
|
+
var header = document.getElementById('results-header-' + msgId);
|
|
14
|
+
var container = document.getElementById('results-' + msgId);
|
|
15
|
+
if (section && header && container) {
|
|
16
|
+
section.dataset.resultsOpen = 'true';
|
|
17
|
+
header.classList.remove('hidden');
|
|
18
|
+
header.classList.add('flex');
|
|
19
|
+
container.style.maxHeight = 'none';
|
|
20
|
+
container.style.overflow = '';
|
|
21
|
+
var meta = document.getElementById('results-meta-' + msgId);
|
|
22
|
+
if (meta) meta.textContent = '<%= j t("glancer.messages.execution_failed") %>';
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
</script>
|
|
26
|
+
</div>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<%# Compute indexed table names for @ autocomplete.
|
|
2
|
+
Only paths with "#" are real table entries (e.g. "schema.rb#users").
|
|
3
|
+
Paths without "#" are auxiliary chunks (inflections file, etc.). %>
|
|
4
|
+
<% glancer_tables = begin
|
|
5
|
+
Glancer::Embedding.where(source_type: "schema")
|
|
6
|
+
.pluck(:source_path)
|
|
7
|
+
.filter_map do |p|
|
|
8
|
+
next unless p.include?("#")
|
|
9
|
+
n = p.split("#").last
|
|
10
|
+
n unless n == "foreign_keys"
|
|
11
|
+
end
|
|
12
|
+
.uniq.sort
|
|
13
|
+
rescue StandardError
|
|
14
|
+
[]
|
|
15
|
+
end %>
|
|
16
|
+
|
|
17
|
+
<div id="message-form" class="max-w-3xl lg:max-w-4xl xl:max-w-5xl 2xl:max-w-6xl mx-auto">
|
|
18
|
+
<%= form_with model: [chat, Glancer::Message.new],
|
|
19
|
+
url: glancer.chat_messages_path(chat),
|
|
20
|
+
data: {
|
|
21
|
+
controller: "message",
|
|
22
|
+
action: "submit->message#submit",
|
|
23
|
+
message_target: "form",
|
|
24
|
+
message_tables_value: glancer_tables.to_json,
|
|
25
|
+
message_step_labels_value: {
|
|
26
|
+
enriching: t("glancer.workflow_steps.enriching"),
|
|
27
|
+
retrieving_context: t("glancer.workflow_steps.retrieving_context"),
|
|
28
|
+
generating_code: t("glancer.workflow_steps.generating_code"),
|
|
29
|
+
validating: t("glancer.workflow_steps.validating"),
|
|
30
|
+
executing: t("glancer.workflow_steps.executing"),
|
|
31
|
+
humanizing: t("glancer.workflow_steps.humanizing")
|
|
32
|
+
}.to_json
|
|
33
|
+
} do |f| %>
|
|
34
|
+
<div class="relative">
|
|
35
|
+
<%# @ mention autocomplete dropdown — shown above the form when @ is typed %>
|
|
36
|
+
<div id="mention-dropdown" class="hidden absolute left-0 right-0 bottom-full mb-1 max-h-52 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 text-sm" role="listbox" aria-label="Table suggestions"></div>
|
|
37
|
+
|
|
38
|
+
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm focus-within:ring-2 focus-within:ring-primary-500 focus-within:border-transparent transition-shadow">
|
|
39
|
+
<%= f.text_area :content,
|
|
40
|
+
rows: 1,
|
|
41
|
+
maxlength: 2000,
|
|
42
|
+
placeholder: t("glancer.chat.placeholder"),
|
|
43
|
+
class: "block w-full px-4 pt-3.5 pb-2 bg-transparent resize-none outline-none text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 min-h-[42px] max-h-[200px] relative z-10",
|
|
44
|
+
data: {
|
|
45
|
+
message_target: "input",
|
|
46
|
+
action: "keydown->message#handleKeydown input->message#autoResize input->message#updateCharCount input->message#handleMentionInput keydown->message#handleMentionKeydown"
|
|
47
|
+
},
|
|
48
|
+
aria: { label: t("glancer.chat.placeholder"), autocomplete: "off" } %>
|
|
49
|
+
|
|
50
|
+
<%# @ mention chips — shown when @tablename is detected in input %>
|
|
51
|
+
<div id="mention-chips" class="hidden px-3 pb-1.5 flex flex-wrap gap-1.5" data-message-target="mentionChips" aria-label="Referenced tables"></div>
|
|
52
|
+
|
|
53
|
+
<div class="flex items-center justify-between px-3 pb-2.5">
|
|
54
|
+
<div class="flex items-center gap-3">
|
|
55
|
+
<span class="text-[11px] text-gray-300 dark:text-gray-600 select-none" data-message-target="charCount" aria-live="polite">0 / 2000</span>
|
|
56
|
+
<% if glancer_tables.any? %>
|
|
57
|
+
<span class="hidden sm:inline-flex items-center gap-1 text-[11px] text-gray-300 dark:text-gray-600 select-none" title="<%= t('glancer.chat.mention_tip_title') %>">
|
|
58
|
+
<kbd class="inline-flex items-center px-1 py-px rounded border border-gray-200 dark:border-gray-700 font-mono text-[10px] leading-none text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800">@</kbd>
|
|
59
|
+
<%= t("glancer.chat.mention_tip") %>
|
|
60
|
+
</span>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="flex items-center gap-2">
|
|
64
|
+
<span class="hidden sm:block text-[11px] text-gray-300 dark:text-gray-600 select-none"><%= t("glancer.chat.hint") %></span>
|
|
65
|
+
<%# Microphone button %>
|
|
66
|
+
<button type="button"
|
|
67
|
+
class="flex items-center justify-center w-8 h-8 rounded-xl text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-950 transition-colors"
|
|
68
|
+
data-message-target="micBtn"
|
|
69
|
+
data-action="click->message#toggleRecording"
|
|
70
|
+
aria-label="<%= t('glancer.chat.record') %>">
|
|
71
|
+
<svg class="w-4 h-4" aria-hidden="true"><use href="#icon-mic"/></svg>
|
|
72
|
+
</button>
|
|
73
|
+
<%# Cancel button — visible only while processing %>
|
|
74
|
+
<button type="button"
|
|
75
|
+
class="hidden flex items-center justify-center w-8 h-8 rounded-xl bg-gray-100 dark:bg-gray-700 hover:bg-red-50 dark:hover:bg-red-950 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
|
76
|
+
data-message-target="cancelBtn"
|
|
77
|
+
data-action="click->message#cancelSubmit"
|
|
78
|
+
aria-label="Cancel">
|
|
79
|
+
<svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-x"/></svg>
|
|
80
|
+
</button>
|
|
81
|
+
<%# Send button %>
|
|
82
|
+
<button type="submit"
|
|
83
|
+
class="flex items-center justify-center w-8 h-8 rounded-xl bg-primary-600 hover:bg-primary-700 active:bg-primary-800 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
|
|
84
|
+
data-message-target="submitBtn"
|
|
85
|
+
aria-label="<%= t('glancer.chat.send') %>">
|
|
86
|
+
<svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-send"/></svg>
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
<% end %>
|
|
93
|
+
</div>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<% time_label = message.created_at.strftime("%-d %b, %H:%M") %>
|
|
2
|
+
<% time_full = message.created_at.strftime("%-d de %B de %Y às %H:%M:%S") %>
|
|
3
|
+
|
|
4
|
+
<% if message.user? %>
|
|
5
|
+
<%# User message: right-aligned bubble %>
|
|
6
|
+
<div class="message user flex justify-end">
|
|
7
|
+
<div class="max-w-[78%] sm:max-w-[65%] min-w-0">
|
|
8
|
+
<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">
|
|
9
|
+
<%= raw Glancer::Utils::MarkdownHelper.markdown_to_html(message.content, schema_base: glancer.db_schema_path, valid_tables: glancer_table_names) %>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="flex items-center justify-end gap-2 mt-1.5 px-1">
|
|
12
|
+
<span class="text-[11px] text-gray-400 dark:text-gray-500" title="<%= time_full %>"><%= time_label %></span>
|
|
13
|
+
<button type="button"
|
|
14
|
+
class="p-0.5 rounded text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400 transition-colors"
|
|
15
|
+
data-controller="chat"
|
|
16
|
+
data-action="click->chat#copy"
|
|
17
|
+
data-message="<%= j message.content %>"
|
|
18
|
+
aria-label="<%= t('glancer.messages.copy') %>">
|
|
19
|
+
<svg class="w-3 h-3" aria-hidden="true"><use href="#icon-copy"/></svg>
|
|
20
|
+
</button>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<% elsif message.assistant? %>
|
|
26
|
+
<%# ── Processing placeholder ─────────────────────────────────────────── %>
|
|
27
|
+
<% if message.processing? || message.pending? %>
|
|
28
|
+
<div id="message-<%= message.id %>"
|
|
29
|
+
class="message assistant flex items-start gap-3"
|
|
30
|
+
data-controller="message"
|
|
31
|
+
data-message-poll-url-value="<%= glancer.poll_message_path(message) %>"
|
|
32
|
+
data-message-is-processing-value="true">
|
|
33
|
+
<div class="flex-shrink-0 mt-0.5 w-7 h-7 rounded-lg bg-primary-100 dark:bg-primary-950 flex items-center justify-center" aria-hidden="true">
|
|
34
|
+
<svg class="w-3.5 h-3.5 text-primary-600 dark:text-primary-400"><use href="#icon-database"/></svg>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400 dark:text-gray-500" role="status" aria-live="polite">
|
|
37
|
+
<span data-message-target="processingLabel">Processing…</span>
|
|
38
|
+
<span class="flex gap-0.5" aria-hidden="true">
|
|
39
|
+
<span class="w-1.5 h-1.5 rounded-full bg-primary-400 dark:bg-primary-500 animate-bounce" style="animation-delay:0ms"></span>
|
|
40
|
+
<span class="w-1.5 h-1.5 rounded-full bg-primary-400 dark:bg-primary-500 animate-bounce" style="animation-delay:150ms"></span>
|
|
41
|
+
<span class="w-1.5 h-1.5 rounded-full bg-primary-400 dark:bg-primary-500 animate-bounce" style="animation-delay:300ms"></span>
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<% else %>
|
|
47
|
+
<%# ── Completed assistant message ──────────────────────────────────────── %>
|
|
48
|
+
<%# Assistant message: left-aligned with avatar %>
|
|
49
|
+
<div id="message-<%= message.id %>" class="message assistant flex items-start gap-3" data-controller="message">
|
|
50
|
+
<div class="flex-shrink-0 mt-0.5 w-7 h-7 rounded-lg bg-primary-100 dark:bg-primary-950 flex items-center justify-center" aria-hidden="true">
|
|
51
|
+
<svg class="w-3.5 h-3.5 text-primary-600 dark:text-primary-400"><use href="#icon-database"/></svg>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="flex-1 min-w-0">
|
|
55
|
+
<%# Response text %>
|
|
56
|
+
<div class="message-content prose prose-sm dark:prose-invert max-w-none text-gray-800 dark:text-gray-200 leading-relaxed">
|
|
57
|
+
<%= raw Glancer::Utils::MarkdownHelper.markdown_to_html(message.content, schema_base: glancer.db_schema_path, valid_tables: glancer_table_names) %>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<%# Code block for successful queries — hidden initially for new messages; revealed after typewriter %>
|
|
61
|
+
<% if message.successful? && message.code.present? %>
|
|
62
|
+
<% code_label = message.code_type == "activerecord" ? "Ruby" : "SQL" %>
|
|
63
|
+
<div class="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden" data-sql-block>
|
|
64
|
+
<%# Code block header %>
|
|
65
|
+
<div class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
66
|
+
<div class="flex items-center gap-2">
|
|
67
|
+
<span class="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"><%= code_label %></span>
|
|
68
|
+
<% if message.user_edited_code? %>
|
|
69
|
+
<span class="text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 px-2 py-0.5 rounded-full"><%= t("glancer.messages.edited_badge") %></span>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="flex items-center gap-1.5">
|
|
73
|
+
<%# Copy code %>
|
|
74
|
+
<button class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
75
|
+
data-action="click->message#copySql"
|
|
76
|
+
data-sql="<%= j message.code.strip %>"
|
|
77
|
+
aria-label="<%= t('glancer.messages.copy_sql') %>">
|
|
78
|
+
<svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-copy"/></svg>
|
|
79
|
+
</button>
|
|
80
|
+
|
|
81
|
+
<%# Edit code — icon swaps to X while in edit mode %>
|
|
82
|
+
<button class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
83
|
+
data-action="click->message#toggleEditSql"
|
|
84
|
+
data-message-id="<%= message.id %>"
|
|
85
|
+
data-message-target="editBtn"
|
|
86
|
+
aria-label="<%= t('glancer.messages.edit_sql') %>">
|
|
87
|
+
<svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-edit"/></svg>
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
<%# Open in Blazer — only for SQL mode %>
|
|
91
|
+
<% if Glancer.configuration.resolved_blazer_path.present? && message.code_type != "activerecord" %>
|
|
92
|
+
<%= form_with url: glancer.open_message_in_blazer_path(message), method: :post, data: { turbo: false }, class: "contents" do %>
|
|
93
|
+
<button type="submit"
|
|
94
|
+
class="flex items-center gap-1 px-2 py-1 text-[11px] font-medium rounded-md border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
95
|
+
aria-label="<%= t('glancer.messages.open_blazer') %>">
|
|
96
|
+
<svg class="w-2.5 h-2.5" aria-hidden="true"><use href="#icon-external"/></svg>
|
|
97
|
+
<%= t("glancer.messages.open_blazer") %>
|
|
98
|
+
</button>
|
|
99
|
+
<% end %>
|
|
100
|
+
<% end %>
|
|
101
|
+
|
|
102
|
+
<%# Run SQL — label changes to "Save & Run" via JS when in edit mode %>
|
|
103
|
+
<button class="flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-medium rounded-md bg-primary-600 hover:bg-primary-700 active:bg-primary-800 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
104
|
+
data-action="click->message#runQuery"
|
|
105
|
+
data-message-id="<%= message.id %>"
|
|
106
|
+
data-message-target="runBtn"
|
|
107
|
+
aria-label="<%= t('glancer.messages.run') %>">
|
|
108
|
+
<svg class="w-2.5 h-2.5" aria-hidden="true"><use href="#icon-play"/></svg>
|
|
109
|
+
<span><%= t("glancer.messages.run") %></span>
|
|
110
|
+
</button>
|
|
111
|
+
|
|
112
|
+
<%# Export CSV (hidden until results load) %>
|
|
113
|
+
<button class="hidden flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-medium rounded-md border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
114
|
+
data-action="click->message#exportToCSV"
|
|
115
|
+
data-message-target="downloadBtn"
|
|
116
|
+
aria-label="<%= t('glancer.messages.csv') %>">
|
|
117
|
+
<svg class="w-2.5 h-2.5" aria-hidden="true"><use href="#icon-download"/></svg>
|
|
118
|
+
<span><%= t("glancer.messages.csv") %></span>
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<%# Code block (normal view) %>
|
|
124
|
+
<% lang_class = message.code_type == "activerecord" ? "language-ruby" : "language-sql" %>
|
|
125
|
+
<div id="sql-code-wrapper-<%= message.id %>">
|
|
126
|
+
<pre class="!m-0 !rounded-none !border-0 text-xs p-3"><code id="sql-code-<%= message.id %>" class="<%= lang_class %>"><%= message.code.strip %></code></pre>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<%# Code editor (edit mode, hidden by default) — contenteditable with live Prism highlighting %>
|
|
130
|
+
<div id="sql-editor-wrapper-<%= message.id %>" class="hidden">
|
|
131
|
+
<div class="px-3 py-1.5 bg-[#1e1e2e] border-t border-gray-700 flex items-center gap-1.5">
|
|
132
|
+
<svg class="w-3 h-3 text-primary-400" aria-hidden="true"><use href="#icon-edit"/></svg>
|
|
133
|
+
<span class="text-[10px] font-medium text-gray-500 uppercase tracking-wider"><%= message.code_type == "activerecord" ? "Editing Ruby" : "Editing SQL" %></span>
|
|
134
|
+
</div>
|
|
135
|
+
<div id="sql-editor-<%= message.id %>"
|
|
136
|
+
class="glancer-code-editor language-<%= message.code_type == "activerecord" ? "ruby" : "sql" %>"
|
|
137
|
+
contenteditable="true"
|
|
138
|
+
spellcheck="false"
|
|
139
|
+
role="textbox"
|
|
140
|
+
aria-multiline="true"
|
|
141
|
+
aria-label="<%= t('glancer.messages.edit_sql') %>"></div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<%# Results section: persistent toggle strip + collapsible container %>
|
|
145
|
+
<div id="results-section-<%= message.id %>" data-results-open="false">
|
|
146
|
+
<%# Toggle strip — hidden until results load %>
|
|
147
|
+
<div id="results-header-<%= message.id %>"
|
|
148
|
+
class="hidden items-center justify-between px-3 py-1.5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
|
149
|
+
<span id="results-meta-<%= message.id %>" class="text-[11px] text-gray-400 dark:text-gray-500"></span>
|
|
150
|
+
<div class="flex items-center gap-1">
|
|
151
|
+
<button data-action="click->message#openFullscreenTable"
|
|
152
|
+
data-message-id="<%= message.id %>"
|
|
153
|
+
class="p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
154
|
+
aria-label="Expand table fullscreen">
|
|
155
|
+
<svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-maximize"/></svg>
|
|
156
|
+
</button>
|
|
157
|
+
<button data-action="click->message#toggleResults"
|
|
158
|
+
data-message-id="<%= message.id %>"
|
|
159
|
+
class="p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
160
|
+
aria-label="Toggle results">
|
|
161
|
+
<svg id="results-arrow-<%= message.id %>"
|
|
162
|
+
class="w-3.5 h-3.5 transition-transform duration-200"
|
|
163
|
+
aria-hidden="true">
|
|
164
|
+
<use href="#icon-chevron-down"/>
|
|
165
|
+
</svg>
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
<%# Collapsible container %>
|
|
170
|
+
<div id="results-<%= message.id %>"
|
|
171
|
+
data-message-target="resultsContainer"
|
|
172
|
+
style="overflow:hidden;transition:max-height 0.3s ease;max-height:0">
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<% end %>
|
|
177
|
+
|
|
178
|
+
<%# Timestamp + action buttons %>
|
|
179
|
+
<div class="flex items-center gap-2 mt-1.5 px-0.5 flex-wrap">
|
|
180
|
+
<span class="text-[11px] text-gray-400 dark:text-gray-500" title="<%= time_full %>"><%= time_label %></span>
|
|
181
|
+
<% if message.llm_model.present? %>
|
|
182
|
+
<span class="text-[10px] text-gray-300 dark:text-gray-600 select-none" title="Model used for this response">· <%= message.llm_model.split('/').last %></span>
|
|
183
|
+
<% end %>
|
|
184
|
+
|
|
185
|
+
<%# Copy full response %>
|
|
186
|
+
<button type="button"
|
|
187
|
+
class="p-0.5 rounded text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400 transition-colors"
|
|
188
|
+
data-action="click->message#copyText"
|
|
189
|
+
aria-label="<%= t('glancer.messages.copy_response') %>">
|
|
190
|
+
<svg class="w-3 h-3" aria-hidden="true"><use href="#icon-copy"/></svg>
|
|
191
|
+
</button>
|
|
192
|
+
|
|
193
|
+
<%# Info panel %>
|
|
194
|
+
<button type="button"
|
|
195
|
+
class="p-0.5 rounded text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400 transition-colors"
|
|
196
|
+
data-action="click->message#openMessageInfo"
|
|
197
|
+
data-message-id="<%= message.id %>"
|
|
198
|
+
aria-label="<%= t('glancer.messages.details') %>"
|
|
199
|
+
aria-expanded="false">
|
|
200
|
+
<svg class="w-3 h-3" aria-hidden="true"><use href="#icon-info"/></svg>
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
<% end %><%# end complete/processing branch %>
|
|
206
|
+
<% end %><%# end message.assistant? %>
|