crewlyze 3.1.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.
Files changed (48) hide show
  1. package/.dockerignore +12 -0
  2. package/.gitattributes +2 -0
  3. package/CHANGELOG.md +86 -0
  4. package/Dockerfile +21 -0
  5. package/LICENSE +21 -0
  6. package/README.md +139 -0
  7. package/USAGE.md +106 -0
  8. package/agents/__init__.py +0 -0
  9. package/agents/cleaner.py +38 -0
  10. package/agents/insights.py +44 -0
  11. package/agents/relation.py +36 -0
  12. package/agents/visualizer.py +41 -0
  13. package/assets/badge_crewai.svg +4 -0
  14. package/assets/badge_matplotlib.svg +4 -0
  15. package/assets/badge_ollama.svg +4 -0
  16. package/assets/badge_pandas.svg +4 -0
  17. package/assets/badge_seaborn.svg +4 -0
  18. package/assets/branding_image.png +0 -0
  19. package/assets/complete_workflow.svg +216 -0
  20. package/assets/favicon.png +0 -0
  21. package/assets/logo.png +0 -0
  22. package/assets/stars.svg +12 -0
  23. package/bin/crewlyze.js +79 -0
  24. package/config/README.md +129 -0
  25. package/config/__init__.py +1 -0
  26. package/config/context.py +16 -0
  27. package/config/llm_config.py +300 -0
  28. package/config/metrics_tracker.py +70 -0
  29. package/crew.py +870 -0
  30. package/crewlyze-3.1.0.tgz +0 -0
  31. package/fix_syntax.py +54 -0
  32. package/main.py +1279 -0
  33. package/package.json +22 -0
  34. package/pyproject.toml +32 -0
  35. package/requirements.txt +33 -0
  36. package/tools/__init__.py +0 -0
  37. package/tools/dataset_tools.py +803 -0
  38. package/ui/__init__.py +3 -0
  39. package/ui/copilot.py +200 -0
  40. package/ui/export.py +800 -0
  41. package/update_appjs.py +54 -0
  42. package/update_llm.py +21 -0
  43. package/update_main.py +20 -0
  44. package/web/app.js +3142 -0
  45. package/web/index.html +1105 -0
  46. package/web/style.css +2561 -0
  47. package/workflows/__init__.py +0 -0
  48. package/workflows/pipeline.py +254 -0
package/web/app.js ADDED
@@ -0,0 +1,3142 @@
1
+ /**
2
+ * Crewlyze — Web App JavaScript
3
+ * Connects to the FastAPI backend at the same origin.
4
+ *
5
+ * Features:
6
+ * - Multi-project sidebar with create / delete / switch
7
+ * - LLM provider + model picker with test-connection
8
+ * - Smart task-card selection with dependency enforcement
9
+ * - CSV upload + drag-and-drop
10
+ * - SSE-based live log streaming
11
+ * - Collapsible data preview table
12
+ * - Interactive Plotly chart rendering from JSON
13
+ * - AI Copilot chat with /column slash-command picker
14
+ * - PDF & CSV download
15
+ * - Toast notifications
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const DEFAULT_THUMBNAIL_SVG = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMjAiIGhlaWdodD0iMTgwIiB2aWV3Qm94PSIwIDAgMzIwIDE4MCI+PHJlY3Qgd2lkdGg9IjMyMCIgaGVpZ2h0PSIxODAiIGZpbGw9IiMxODE4MWIiLz48Y2lyY2xlIGN4PSIxNjAiIGN5PSI5MCIgcj0iNDAiIGZpbGw9IiM3YzNhZWQiIGZpbGwtb3BhY2l0eT0iMC4xIi8+PHBhdGggZD0iTTE0MCAxMDAgTDE2MCA4MCBMMTgwIDEwMCIgc3Ryb2tlPSIjN2MzYWVkIiBzdHJva2Utd2lkdGg9IjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0xMzAgMTE1IEwxOTAgMTE1IiBzdHJva2U9IiMyMmQzZWUiIHN0cm9rZS13aWR0aD0iMiIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHRleHQgeD0iMTYwIiB5PSIxNTAiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjExIiBmaWxsPSIjYTFhMWFhIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5BR0VOVElDIExPR1M8L3RleHQ+PC9zdmc+';
21
+
22
+ // ────────────────────────────────────────────────────────────────────────────
23
+ // Model catalogue (mirrors the sidebar in the old Streamlit app)
24
+ // ────────────────────────────────────────────────────────────────────────────
25
+ const MODEL_OPTIONS = {
26
+ nvidia: [
27
+ 'nvidia_nim/meta/llama-3.1-8b-instruct',
28
+ 'nvidia_nim/meta/llama-3.1-70b-instruct',
29
+ 'nvidia_nim/nvidia/mistral-nemo-minitron-8b-8k-instruct',
30
+ 'nvidia_nim/mistralai/mistral-large-2407',
31
+ ],
32
+ minimax: ['minimaxai/minimax-m3'],
33
+ groq: ['groq/llama-3.1-8b-instant','groq/llama-3.3-70b-versatile','groq/mixtral-8x7b-32768','groq/gemma2-9b-it'],
34
+ openai: ['gpt-4o','gpt-4o-mini','gpt-4-turbo','gpt-3.5-turbo'],
35
+ anthropic: ['claude-3-5-sonnet-20241022','claude-3-opus-20240229','claude-3-sonnet-20240229','claude-3-haiku-20240307'],
36
+ gemini: ['gemini/gemini-pro','gemini/gemini-1.5-pro','gemini/gemini-1.5-flash'],
37
+ mistral: ['mistral/mistral-tiny','mistral/mistral-small','mistral/mistral-medium','mistral/mistral-large-latest'],
38
+ huggingface: ['huggingface/HuggingFaceH4/zephyr-7b-beta','huggingface/meta-llama/Llama-2-7b-chat-hf'],
39
+ ollama: ['ollama/llama3','ollama/mistral','ollama/gemma2'],
40
+ custom: ['custom/model'],
41
+ custom: ['gpt-3.5-turbo', 'gpt-4'],
42
+ cohere: ['cohere/command-r-plus', 'cohere/command-r', 'cohere/command-light'],
43
+ together: ['together_ai/meta-llama/Llama-3-70b-chat-hf', 'together_ai/meta-llama/Llama-3-8b-chat-hf', 'together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1'],
44
+ openrouter: ['openrouter/google/gemma-2-9b-it', 'openrouter/meta-llama/llama-3-8b-instruct', 'openrouter/anthropic/claude-3.5-sonnet'],
45
+ deepseek: ['deepseek/deepseek-chat', 'deepseek/deepseek-coder'],
46
+ perplexity: ['perplexity/llama-3-sonar-large-32k-chat', 'perplexity/llama-3-sonar-small-32k-chat'],
47
+ };
48
+
49
+ // ────────────────────────────────────────────────────────────────────────────
50
+ // App state
51
+ // ────────────────────────────────────────────────────────────────────────────
52
+ const state = {
53
+ projects: [], // array of project metadata from API
54
+ activeProject: null, // currently viewed project
55
+ uploadedFile: null, // File object pending analysis
56
+ uploadedSession: null, // session_id returned after upload
57
+ results: null, // latest results JSON
58
+ columns: [], // column names from last preview
59
+ colTypes: {}, // column → dtype string
60
+ chatHistory: [], // [{role, content, plot_url}]
61
+ previewMinimized: false,
62
+ sseSource: null, // current EventSource
63
+ resultsCache: {}, // session_id -> results JSON cache
64
+ };
65
+
66
+ // ────────────────────────────────────────────────────────────────────────────
67
+ // DOM refs
68
+ // ────────────────────────────────────────────────────────────────────────────
69
+ const $ = id => document.getElementById(id);
70
+ const $$ = sel => document.querySelectorAll(sel);
71
+
72
+ const els = {
73
+ sidebar: $('sidebar'),
74
+ sidebarToggle: $('sidebarToggle'),
75
+ mobileSidebarBtn: $('mobileSidebarBtn'),
76
+ projectsList: $('projectsList'),
77
+ newProjectBtn: $('newProjectBtn'),
78
+ sidebarLogo: $('sidebarLogo'),
79
+ newProjectWizard: $('newProjectWizard'),
80
+ wizardStep0: $('wizardStep0'),
81
+ startWizardCard: $('startWizardCard'),
82
+ wizardStep1: $('wizardStep1'),
83
+ wizardBack1Btn: $('wizardBack1Btn'),
84
+ wizardStep2: $('wizardStep2'),
85
+ wizardProjectName: $('wizardProjectName'),
86
+ wizardNextBtn: $('wizardNextBtn'),
87
+ wizardBackBtn: $('wizardBackBtn'),
88
+ wizardUploadTitle: $('wizardUploadTitle'),
89
+ dashboardProjectsGrid: $('dashboardProjectsGrid'),
90
+ dashboardProjectsCount: $('dashboardProjectsCount'),
91
+
92
+ // Settings Modal elements
93
+ sidebarSettingsBtn: $('sidebarSettingsBtn'),
94
+ settingsModal: $('settingsModal'),
95
+ closeSettingsModal: $('closeSettingsModal'),
96
+ cancelSettingsBtn: $('cancelSettingsBtn'),
97
+ saveSettingsBtn: $('saveSettingsBtn'),
98
+ settingsCooldown: $('settingsCooldown'),
99
+ settingsCooldownVal: $('settingsCooldownVal'),
100
+ settingsTestConnectionBtn: $('settingsTestConnectionBtn'),
101
+ settingsConnectionStatus: $('settingsConnectionStatus'),
102
+ keyNvidia: $('keyNvidia'),
103
+ keyGroq: $('keyGroq'),
104
+ keyOpenai: $('keyOpenai'),
105
+ keyAnthropic: $('keyAnthropic'),
106
+ keyGemini: $('keyGemini'),
107
+ keyMistral: $('keyMistral'),
108
+ keyHuggingface: $('keyHuggingface'),
109
+ urlOllama: $('urlOllama'),
110
+ urlCustom: $('urlCustom'),
111
+ keyCustom: $('keyCustom'),
112
+ showCustom: $('showCustom'),
113
+ keyCohere: $('keyCohere'),
114
+ keyTogether: $('keyTogether'),
115
+ keyOpenrouter: $('keyOpenrouter'),
116
+ keyDeepseek: $('keyDeepseek'),
117
+ keyPerplexity: $('keyPerplexity'),
118
+
119
+ // Checkboxes
120
+ showNvidia: $('showNvidia'),
121
+ showGroq: $('showGroq'),
122
+ showOpenai: $('showOpenai'),
123
+ showAnthropic: $('showAnthropic'),
124
+ showGemini: $('showGemini'),
125
+ showMistral: $('showMistral'),
126
+ showHuggingface: $('showHuggingface'),
127
+ showOllama: $('showOllama'),
128
+ showCohere: $('showCohere'),
129
+ showTogether: $('showTogether'),
130
+ showOpenrouter: $('showOpenrouter'),
131
+ showDeepseek: $('showDeepseek'),
132
+ showPerplexity: $('showPerplexity'),
133
+
134
+ llmProvider: $('llmProvider'),
135
+ llmModel: $('llmModel'),
136
+ apiKey: $('apiKey'),
137
+ toggleKey: $('toggleKey'),
138
+ keyLabel: $('keyLabel'),
139
+ cooldown: $('cooldown'),
140
+ cooldownVal: $('cooldownVal'),
141
+ testConnectionBtn: $('testConnectionBtn'),
142
+ connectionStatus: $('connectionStatus'),
143
+
144
+ statusPill: $('statusPill'),
145
+ breadcrumb: $('breadcrumb'),
146
+
147
+ // Screens
148
+ landingScreen: $('landingScreen'),
149
+ runningScreen: $('runningScreen'),
150
+ resultsScreen: $('resultsScreen'),
151
+
152
+ // Upload
153
+ uploadZone: $('uploadZone'),
154
+ fileInput: $('fileInput'),
155
+ uploadedFileMeta: $('uploadedFileMeta'),
156
+ uploadedFileActions: $('uploadedFileActions'),
157
+ startAnalysisBtn: $('startAnalysisBtn'),
158
+
159
+ // Config modal
160
+ configModal: $('configModal'),
161
+ closeConfigModal: $('closeConfigModal'),
162
+ cancelConfigBtn: $('cancelConfigBtn'),
163
+ runAnalysisBtn: $('runAnalysisBtn'),
164
+ reportTitle: $('reportTitle'),
165
+
166
+ // Task cards
167
+ taskCleaning: $('taskCleaning'),
168
+ taskRelations: $('taskRelations'),
169
+ taskInsights: $('taskInsights'),
170
+ taskViz: $('taskViz'),
171
+ taskCardCleaning: $('taskCardCleaning'),
172
+ taskCardRelations: $('taskCardRelations'),
173
+ taskCardInsights: $('taskCardInsights'),
174
+ taskCardViz: $('taskCardViz'),
175
+
176
+ // Running
177
+ runningTitle: $('runningTitle'),
178
+ logOutput: $('logOutput'),
179
+ clearLogBtn: $('clearLogBtn'),
180
+
181
+ // Results
182
+ statsRow: $('statsRow'),
183
+ tabBtns: $$('.tab-btn'),
184
+ tabPanels: $$('.tab-panel'),
185
+
186
+ // Preview
187
+ previewTable: $('previewTable'),
188
+ togglePreviewBtn: $('togglePreviewBtn'),
189
+ previewTableWrap: $('previewTableWrap'),
190
+
191
+ // Panels
192
+ cleaningContent: $('cleaningContent'),
193
+ relationsContent: $('relationsContent'),
194
+ insightsContent: $('insightsContent'),
195
+ plotlyChartsWrap: $('plotlyChartsWrap'),
196
+ pngChartsWrap: $('pngChartsWrap'),
197
+ pngCharts: $('pngCharts'),
198
+ vizCodeBlock: $('vizCodeBlock'),
199
+ vizCodeDetails: $('vizCodeDetails'),
200
+
201
+ // Chat
202
+ chatMessages: $('chatMessages'),
203
+ chatBackBtn: $('chatBackBtn'),
204
+ chatInput: $('chatInput'),
205
+ sendChatBtn: $('sendChatBtn'),
206
+ clearChatBtn: $('clearChatBtn'),
207
+ colPickerDropdown: $('colPickerDropdown'),
208
+
209
+ // Export
210
+ exportPdfBtn: $('exportPdfBtn'),
211
+ downloadCsvBtn: $('downloadCsvBtn'),
212
+ reRunBtn: $('reRunBtn'),
213
+
214
+ // Sidebar Export
215
+ sidebarProjectActions: $('sidebarProjectActions'),
216
+ sidebarExportPdfBtn: $('sidebarExportPdfBtn'),
217
+ sidebarExportZipBtn: $('sidebarExportZipBtn'),
218
+ sidebarDownloadCsvBtn: $('sidebarDownloadCsvBtn'),
219
+ sidebarReRunBtn: $('sidebarReRunBtn'),
220
+
221
+ toastContainer: $('toastContainer'),
222
+
223
+ // API Warning Modal
224
+ apiWarningModal: $('apiWarningModal'),
225
+ warningProviderName: $('warningProviderName'),
226
+ guideToApiBtn: $('guideToApiBtn'),
227
+ closeApiWarningBtn: $('closeApiWarningBtn'),
228
+
229
+ // Custom dialog modal elements
230
+ customDialogModal: $('customDialogModal'),
231
+ customDialogTitle: $('customDialogTitle'),
232
+ customDialogMessage: $('customDialogMessage'),
233
+ customDialogPromptContainer: $('customDialogPromptContainer'),
234
+ customDialogInput: $('customDialogInput'),
235
+ customDialogCancelBtn: $('customDialogCancelBtn'),
236
+ customDialogConfirmBtn: $('customDialogConfirmBtn'),
237
+ closeCustomDialogBtn: $('closeCustomDialogBtn'),
238
+ stagePovPanel: $('stagePovPanel'),
239
+ wizardReportTitle: $('wizardReportTitle'),
240
+ wizardProjectGoal: $('wizardProjectGoal'),
241
+ importProjectCard: $('importProjectCard'),
242
+ importZipFileInput: $('importZipFileInput'),
243
+ exportZipBtn: $('exportZipBtn'),
244
+ btnSectionChat: $('btnSectionChat'),
245
+ btnSectionAgentic: $('btnSectionAgentic'),
246
+ areaChat: $('areaChat'),
247
+ areaAgentic: $('areaAgentic'),
248
+ areaHub: $('areaHub'),
249
+ btnEnterChat: $('btnEnterChat'),
250
+ btnEnterAgentic: $('btnEnterAgentic'),
251
+ btnRenameColQuick: $('btnRenameColQuick'),
252
+ btnDeleteColQuick: $('btnDeleteColQuick'),
253
+ chatPreviewDims: $('chatPreviewDims'),
254
+ relationModal: $('relationModal'),
255
+ relationModalXSelect: $('relationModalXSelect'),
256
+ relationModalYSelect: $('relationModalYSelect'),
257
+ relationModalTypeSelect: $('relationModalTypeSelect'),
258
+ relationModalDetails: $('relationModalDetails'),
259
+ relationModalConfirmBtn: $('relationModalConfirmBtn'),
260
+ relationModalCancelBtn: $('relationModalCancelBtn'),
261
+ closeRelationModalBtn: $('closeRelationModalBtn'),
262
+ agenticPlaceholder: $('agenticPlaceholder'),
263
+ agenticTabsBar: $('agenticTabsBar'),
264
+ agenticTabPanels: $('agenticTabPanels'),
265
+ btnRunAgenticPipeline: $('btnRunAgenticPipeline'),
266
+ sidebarMetricsBtn: $('sidebarMetricsBtn'),
267
+ metricsScreen: $('metricsScreen'),
268
+ backToDashboardBtn: $('backToDashboardBtn'),
269
+ metricsTableBody: $('metricsTableBody'),
270
+ };
271
+
272
+ // ────────────────────────────────────────────────────────────────────────────
273
+ // Screen management
274
+ // ────────────────────────────────────────────────────────────────────────────
275
+ function showScreen(name) {
276
+ ['landingScreen','runningScreen','resultsScreen','metricsScreen'].forEach(id => {
277
+ const el = $(id);
278
+ if (el) el.classList.toggle('active', id === name + 'Screen');
279
+ });
280
+ if (name !== 'results' && name !== 'metrics') {
281
+ if (els.sidebarProjectActions) els.sidebarProjectActions.classList.add('hidden');
282
+ } else if (name === 'results') {
283
+ updateSidebarProjectActionsVisibility();
284
+ }
285
+ }
286
+
287
+ // ────────────────────────────────────────────────────────────────────────────
288
+ // Status pill
289
+ // ────────────────────────────────────────────────────────────────────────────
290
+ function setStatus(label, cls) {
291
+ els.statusPill.textContent = label;
292
+ els.statusPill.className = `status-pill ${cls}`;
293
+ }
294
+
295
+ // ────────────────────────────────────────────────────────────────────────────
296
+ // Toast notifications
297
+ // ────────────────────────────────────────────────────────────────────────────
298
+ function toast(msg, type = 'info', durationMs = 3500) {
299
+ const t = document.createElement('div');
300
+ t.className = `toast ${type}`;
301
+ t.textContent = msg;
302
+ els.toastContainer.appendChild(t);
303
+ setTimeout(() => t.remove(), durationMs);
304
+ }
305
+
306
+ // ────────────────────────────────────────────────────────────────────────────
307
+ // Custom Dialog (Alert / Confirm / Prompt)
308
+ // ────────────────────────────────────────────────────────────────────────────
309
+ function showCustomDialog({ title, message, type = 'confirm', placeholder = '', defaultValue = '' }) {
310
+ return new Promise((resolve) => {
311
+ const modal = els.customDialogModal;
312
+ const titleEl = els.customDialogTitle;
313
+ const messageEl = els.customDialogMessage;
314
+ const promptContainer = els.customDialogPromptContainer;
315
+ const inputEl = els.customDialogInput;
316
+ const cancelBtn = els.customDialogCancelBtn;
317
+ const confirmBtn = els.customDialogConfirmBtn;
318
+ const closeBtn = els.closeCustomDialogBtn;
319
+
320
+ titleEl.textContent = title || (type === 'confirm' ? 'Confirm' : type === 'prompt' ? 'Input Required' : 'Alert');
321
+ messageEl.textContent = message || '';
322
+
323
+ if (type === 'prompt') {
324
+ promptContainer.classList.remove('hidden');
325
+ inputEl.value = defaultValue;
326
+ inputEl.placeholder = placeholder;
327
+ } else {
328
+ promptContainer.classList.add('hidden');
329
+ }
330
+
331
+ if (type === 'alert') {
332
+ cancelBtn.classList.add('hidden');
333
+ confirmBtn.textContent = 'OK';
334
+ } else {
335
+ cancelBtn.classList.remove('hidden');
336
+ confirmBtn.textContent = type === 'confirm' ? 'Confirm' : 'Submit';
337
+ }
338
+
339
+ modal.classList.remove('hidden');
340
+ if (type === 'prompt') {
341
+ setTimeout(() => inputEl.focus(), 50);
342
+ }
343
+
344
+ function cleanup() {
345
+ modal.classList.add('hidden');
346
+ confirmBtn.removeEventListener('click', onConfirm);
347
+ cancelBtn.removeEventListener('click', onCancel);
348
+ closeBtn.removeEventListener('click', onCancel);
349
+ inputEl.removeEventListener('keydown', onKeyDown);
350
+ modal.removeEventListener('click', onBackdropClick);
351
+ }
352
+
353
+ function onConfirm() {
354
+ const val = type === 'prompt' ? inputEl.value : true;
355
+ cleanup();
356
+ resolve(val);
357
+ }
358
+
359
+ function onCancel() {
360
+ cleanup();
361
+ resolve(type === 'prompt' ? null : false);
362
+ }
363
+
364
+ function onKeyDown(e) {
365
+ if (e.key === 'Enter') {
366
+ e.preventDefault();
367
+ onConfirm();
368
+ } else if (e.key === 'Escape') {
369
+ e.preventDefault();
370
+ onCancel();
371
+ }
372
+ }
373
+
374
+ function onBackdropClick(e) {
375
+ if (e.target === modal) {
376
+ onCancel();
377
+ }
378
+ }
379
+
380
+ confirmBtn.addEventListener('click', onConfirm);
381
+ cancelBtn.addEventListener('click', onCancel);
382
+ closeBtn.addEventListener('click', onCancel);
383
+ inputEl.addEventListener('keydown', onKeyDown);
384
+ modal.addEventListener('click', onBackdropClick);
385
+ });
386
+ }
387
+
388
+ function customConfirm(message, title = 'Confirm') {
389
+ return showCustomDialog({ title, message, type: 'confirm' });
390
+ }
391
+
392
+ function customPrompt(message, defaultValue = '', placeholder = '', title = 'Input Required') {
393
+ return showCustomDialog({ title, message, type: 'prompt', defaultValue, placeholder });
394
+ }
395
+
396
+ function customAlert(message, title = 'Alert') {
397
+ return showCustomDialog({ title, message, type: 'alert' });
398
+ }
399
+
400
+ // ────────────────────────────────────────────────────────────────────────────
401
+ // Fetch Ollama Models
402
+ // ────────────────────────────────────────────────────────────────────────────
403
+ async function fetchOllamaModels() {
404
+ try {
405
+ const customUrl = localStorage.getItem('api_url_ollama') || 'http://localhost:11434';
406
+ const res = await fetch(`/api/ollama-models?base_url=${encodeURIComponent(customUrl)}`);
407
+ if (res.ok) {
408
+ const data = await res.json();
409
+ if (data && data.models && data.models.length > 0) {
410
+ // Prepend 'ollama/' prefix to models if not already present
411
+ const processedModels = data.models.map(m => {
412
+ if (!m.startsWith('ollama/')) {
413
+ return `ollama/${m}`;
414
+ }
415
+ return m;
416
+ });
417
+ MODEL_OPTIONS.ollama = processedModels;
418
+ return processedModels;
419
+ }
420
+ }
421
+ } catch (e) {
422
+ console.error('Failed to fetch Ollama models:', e);
423
+ }
424
+ return MODEL_OPTIONS.ollama; // fallback
425
+ }
426
+
427
+ // ────────────────────────────────────────────────────────────────────────────
428
+ // LLM settings persistence & Settings Modal logic
429
+ // ────────────────────────────────────────────────────────────────────────────
430
+ const ALL_PROVIDERS = [
431
+ { id: 'nvidia', name: 'NVIDIA NIM' },
432
+ { id: 'groq', name: 'Groq' },
433
+ { id: 'openai', name: 'OpenAI' },
434
+ { id: 'anthropic', name: 'Anthropic' },
435
+ { id: 'gemini', name: 'Google Gemini' },
436
+ { id: 'mistral', name: 'Mistral' },
437
+ { id: 'huggingface', name: 'HuggingFace' },
438
+ { id: 'cohere', name: 'Cohere' },
439
+ { id: 'together', name: 'TogetherAI' },
440
+ { id: 'openrouter', name: 'OpenRouter' },
441
+ { id: 'deepseek', name: 'DeepSeek' },
442
+ { id: 'perplexity', name: 'Perplexity' },
443
+ { id: 'ollama', name: 'Ollama (local)' },
444
+ { id: 'custom', name: 'Custom API' }
445
+ ];
446
+
447
+ const PROVIDER_TEST_MODELS = {
448
+ nvidia: 'nvidia_nim/meta/llama-3.1-8b-instruct',
449
+ groq: 'groq/llama-3.1-8b-instant',
450
+ openai: 'gpt-4o-mini',
451
+ anthropic: 'claude-3-5-sonnet-20241022',
452
+ gemini: 'gemini/gemini-pro',
453
+ mistral: 'mistral/mistral-tiny',
454
+ huggingface: 'huggingface/HuggingFaceH4/zephyr-7b-beta',
455
+ ollama: 'ollama/llama3',
456
+ cohere: 'cohere/command-r-plus',
457
+ together: 'together_ai/meta-llama/Llama-3-70b-chat-hf',
458
+ openrouter: 'openrouter/google/gemma-2-9b-it',
459
+ deepseek: 'deepseek/deepseek-chat',
460
+ perplexity: 'perplexity/llama-3-sonar-large-32k-chat'
461
+ };
462
+
463
+ function getSavedKey(provider) {
464
+ if (provider === 'ollama') {
465
+ return localStorage.getItem('api_url_ollama') || 'http://localhost:11434';
466
+ }
467
+ return localStorage.getItem(`api_key_${provider}`) || '';
468
+ }
469
+
470
+ function syncActiveApiKey() {
471
+ const provider = els.llmProvider.value;
472
+ const key = getSavedKey(provider);
473
+ els.apiKey.value = key;
474
+
475
+ const statusEl = document.getElementById('sidebarApiKeyStatus');
476
+ if (statusEl) {
477
+ if (provider === 'ollama') {
478
+ const url = localStorage.getItem('api_url_ollama') || 'http://localhost:11434';
479
+ statusEl.innerHTML = `<i data-lucide="link" style="width: 14px; height: 14px; color: var(--cyan);"></i> <span style="color: var(--cyan); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 160px;">${url}</span>`;
480
+ } else if (key) {
481
+ statusEl.innerHTML = `<i data-lucide="check-circle" style="width: 14px; height: 14px; color: var(--emerald);"></i> <span style="color: var(--emerald);">API Key Set</span>`;
482
+ } else {
483
+ statusEl.innerHTML = `<i data-lucide="alert-circle" style="width: 14px; height: 14px; color: var(--amber);"></i> <span style="color: var(--amber);">API Key Not Set</span>`;
484
+ }
485
+ if (window.lucide) {
486
+ lucide.createIcons({ attrs: { class: 'icon-svg' } });
487
+ }
488
+ }
489
+ }
490
+
491
+ function saveLlmSettings() {
492
+ localStorage.setItem('llm_provider', els.llmProvider.value);
493
+ localStorage.setItem('llm_model', els.llmModel.value);
494
+ }
495
+
496
+ function populateProvidersDropdown() {
497
+ const currentVal = els.llmProvider.value;
498
+ els.llmProvider.innerHTML = '';
499
+ let selectedExists = false;
500
+
501
+ ALL_PROVIDERS.forEach(p => {
502
+ const isShown = localStorage.getItem(`show_provider_${p.id}`) !== 'false'; // defaults to true
503
+ if (isShown) {
504
+ const opt = document.createElement('option');
505
+ opt.value = p.id;
506
+ opt.textContent = p.name;
507
+ els.llmProvider.appendChild(opt);
508
+ if (p.id === currentVal) selectedExists = true;
509
+ }
510
+ });
511
+
512
+ if (selectedExists) {
513
+ els.llmProvider.value = currentVal;
514
+ } else if (els.llmProvider.options.length > 0) {
515
+ els.llmProvider.selectedIndex = 0;
516
+ }
517
+ }
518
+
519
+ async function loadLlmSettings() {
520
+ try {
521
+ const res = await fetch('/api/config');
522
+ if (res.ok) {
523
+ const backendCfg = await res.json();
524
+ if (backendCfg.NVIDIA_API_KEY) localStorage.setItem('api_key_nvidia', backendCfg.NVIDIA_API_KEY);
525
+ if (backendCfg.GROQ_API_KEY) localStorage.setItem('api_key_groq', backendCfg.GROQ_API_KEY);
526
+ if (backendCfg.OPENAI_API_KEY) localStorage.setItem('api_key_openai', backendCfg.OPENAI_API_KEY);
527
+ if (backendCfg.ANTHROPIC_API_KEY) localStorage.setItem('api_key_anthropic', backendCfg.ANTHROPIC_API_KEY);
528
+ if (backendCfg.GEMINI_API_KEY) localStorage.setItem('api_key_gemini', backendCfg.GEMINI_API_KEY);
529
+ if (backendCfg.MISTRAL_API_KEY) localStorage.setItem('api_key_mistral', backendCfg.MISTRAL_API_KEY);
530
+ if (backendCfg.HUGGINGFACE_API_KEY) localStorage.setItem('api_key_huggingface', backendCfg.HUGGINGFACE_API_KEY);
531
+ if (backendCfg.COHERE_API_KEY) localStorage.setItem('api_key_cohere', backendCfg.COHERE_API_KEY);
532
+ if (backendCfg.TOGETHER_API_KEY) localStorage.setItem('api_key_together', backendCfg.TOGETHER_API_KEY);
533
+ if (backendCfg.OPENROUTER_API_KEY) localStorage.setItem('api_key_openrouter', backendCfg.OPENROUTER_API_KEY);
534
+ if (backendCfg.DEEPSEEK_API_KEY) localStorage.setItem('api_key_deepseek', backendCfg.DEEPSEEK_API_KEY);
535
+ if (backendCfg.PERPLEXITY_API_KEY) localStorage.setItem('api_key_perplexity', backendCfg.PERPLEXITY_API_KEY);
536
+ if (backendCfg.OLLAMA_BASE_URL) localStorage.setItem('api_url_ollama', backendCfg.OLLAMA_BASE_URL);
537
+ if (backendCfg.CUSTOM_BASE_URL) localStorage.setItem('api_url_custom', backendCfg.CUSTOM_BASE_URL);
538
+ if (backendCfg.CUSTOM_API_KEY) localStorage.setItem('api_key_custom', backendCfg.CUSTOM_API_KEY);
539
+ }
540
+ } catch (err) {
541
+ console.warn('Failed to load credentials from backend config:', err);
542
+ }
543
+
544
+ const savedProvider = localStorage.getItem('llm_provider');
545
+ const savedCooldown = localStorage.getItem('llm_cooldown') || '5';
546
+
547
+ populateProvidersDropdown();
548
+
549
+ if (savedProvider && Array.from(els.llmProvider.options).some(opt => opt.value === savedProvider)) {
550
+ els.llmProvider.value = savedProvider;
551
+ } else if (els.llmProvider.options.length > 0) {
552
+ els.llmProvider.selectedIndex = 0;
553
+ }
554
+
555
+ await populateModels(els.llmProvider.value);
556
+
557
+ els.cooldown.value = savedCooldown;
558
+ els.cooldownVal.textContent = savedCooldown;
559
+
560
+ syncActiveApiKey();
561
+ }
562
+
563
+ // Populate Settings Modal text boxes
564
+ function populateSettingsModal() {
565
+ // Checkbox show states
566
+ ALL_PROVIDERS.forEach(p => {
567
+ const showCheck = els[`show${p.id.charAt(0).toUpperCase() + p.id.slice(1)}`];
568
+ if (showCheck) {
569
+ showCheck.checked = localStorage.getItem(`show_provider_${p.id}`) !== 'false';
570
+ }
571
+ });
572
+
573
+ // Keys
574
+ els.keyNvidia.value = localStorage.getItem('api_key_nvidia') || '';
575
+ els.keyGroq.value = localStorage.getItem('api_key_groq') || '';
576
+ els.keyOpenai.value = localStorage.getItem('api_key_openai') || '';
577
+ els.keyAnthropic.value = localStorage.getItem('api_key_anthropic') || '';
578
+ els.keyGemini.value = localStorage.getItem('api_key_gemini') || '';
579
+ els.keyMistral.value = localStorage.getItem('api_key_mistral') || '';
580
+ els.keyHuggingface.value = localStorage.getItem('api_key_huggingface') || '';
581
+ els.keyCohere.value = localStorage.getItem('api_key_cohere') || '';
582
+ els.keyTogether.value = localStorage.getItem('api_key_together') || '';
583
+ els.keyOpenrouter.value = localStorage.getItem('api_key_openrouter') || '';
584
+ els.keyDeepseek.value = localStorage.getItem('api_key_deepseek') || '';
585
+ els.keyPerplexity.value = localStorage.getItem('api_key_perplexity') || '';
586
+ els.urlOllama.value = localStorage.getItem('api_url_ollama') || 'http://localhost:11434';
587
+ els.urlCustom.value = localStorage.getItem('api_url_custom') || 'https://api.openai.com/v1';
588
+ els.keyCustom.value = localStorage.getItem('api_key_custom') || '';
589
+ // 'http://localhost:11434';
590
+
591
+ const cooldown = localStorage.getItem('llm_cooldown') || '5';
592
+ els.settingsCooldown.value = cooldown;
593
+ els.settingsCooldownVal.textContent = cooldown;
594
+
595
+ // Clear all individual statuses
596
+ document.querySelectorAll('.individual-status').forEach(el => {
597
+ el.textContent = '';
598
+ el.className = 'individual-status';
599
+ el.style.color = '';
600
+ });
601
+ }
602
+
603
+ // Save Settings Modal inputs
604
+ function saveSettingsModal() {
605
+ // Checkbox show states
606
+ ALL_PROVIDERS.forEach(p => {
607
+ const showCheck = els[`show${p.id.charAt(0).toUpperCase() + p.id.slice(1)}`];
608
+ if (showCheck) {
609
+ localStorage.setItem(`show_provider_${p.id}`, showCheck.checked ? 'true' : 'false');
610
+ }
611
+ });
612
+
613
+ // Keys
614
+ localStorage.setItem('api_key_nvidia', els.keyNvidia.value.trim());
615
+ localStorage.setItem('api_key_groq', els.keyGroq.value.trim());
616
+ localStorage.setItem('api_key_openai', els.keyOpenai.value.trim());
617
+ localStorage.setItem('api_key_anthropic', els.keyAnthropic.value.trim());
618
+ localStorage.setItem('api_key_gemini', els.keyGemini.value.trim());
619
+ localStorage.setItem('api_key_mistral', els.keyMistral.value.trim());
620
+ localStorage.setItem('api_key_huggingface', els.keyHuggingface.value.trim());
621
+ localStorage.setItem('api_key_cohere', els.keyCohere.value.trim());
622
+ localStorage.setItem('api_key_together', els.keyTogether.value.trim());
623
+ localStorage.setItem('api_key_openrouter', els.keyOpenrouter.value.trim());
624
+ localStorage.setItem('api_key_deepseek', els.keyDeepseek.value.trim());
625
+ localStorage.setItem('api_key_perplexity', els.keyPerplexity.value.trim());
626
+ localStorage.setItem('api_url_ollama', els.urlOllama.value.trim());
627
+ localStorage.setItem('api_url_custom', els.urlCustom.value.trim());
628
+ localStorage.setItem('api_key_custom', els.keyCustom.value.trim());
629
+
630
+ // Save to backend configuration
631
+ const providers_to_save = ['nvidia', 'groq', 'openai', 'anthropic', 'gemini', 'mistral', 'huggingface', 'cohere', 'together', 'openrouter', 'deepseek', 'perplexity', 'ollama', 'custom'];
632
+ providers_to_save.forEach(async (prov) => {
633
+ const fd = new FormData();
634
+ fd.append('provider', prov);
635
+ if (prov === 'ollama') {
636
+ fd.append('base_url', els.urlOllama.value.trim());
637
+ } else if (prov === 'custom') {
638
+ fd.append('base_url', els.urlCustom.value.trim());
639
+ fd.append('api_key', els.keyCustom.value.trim());
640
+ } else {
641
+ const capId = prov.charAt(0).toUpperCase() + prov.slice(1);
642
+ const val = (els[`key${capId}`] ? els[`key${capId}`].value.trim() : '');
643
+ fd.append('api_key', val);
644
+ }
645
+ try {
646
+ await fetch('/api/config', { method: 'POST', body: fd });
647
+ } catch (err) {
648
+ console.error('Failed to sync settings to backend for', prov, err);
649
+ }
650
+ });
651
+
652
+ const cooldown = els.settingsCooldown.value;
653
+ localStorage.setItem('llm_cooldown', cooldown);
654
+ els.cooldown.value = cooldown;
655
+ els.cooldownVal.textContent = cooldown;
656
+
657
+ // Re-populate sidebar provider list
658
+ populateProvidersDropdown();
659
+ syncActiveApiKey();
660
+
661
+ toast('Settings saved permanently!', 'success');
662
+ els.settingsModal.classList.add('hidden');
663
+ }
664
+
665
+ // Wire Settings Modal Buttons & Navigation
666
+ if (els.sidebarSettingsBtn) {
667
+ els.sidebarSettingsBtn.addEventListener('click', () => {
668
+ populateSettingsModal();
669
+ els.settingsModal.classList.remove('hidden');
670
+ });
671
+ }
672
+ if (els.closeSettingsModal) {
673
+ els.closeSettingsModal.addEventListener('click', () => {
674
+ els.settingsModal.classList.add('hidden');
675
+ });
676
+ }
677
+ if (els.cancelSettingsBtn) {
678
+ els.cancelSettingsBtn.addEventListener('click', () => {
679
+ els.settingsModal.classList.add('hidden');
680
+ });
681
+ }
682
+ if (els.settingsModal) {
683
+ els.settingsModal.addEventListener('click', e => {
684
+ if (e.target === els.settingsModal) els.settingsModal.classList.add('hidden');
685
+ });
686
+ }
687
+ if (els.saveSettingsBtn) {
688
+ els.saveSettingsBtn.addEventListener('click', saveSettingsModal);
689
+ }
690
+ if (els.settingsCooldown) {
691
+ els.settingsCooldown.addEventListener('input', () => {
692
+ els.settingsCooldownVal.textContent = els.settingsCooldown.value;
693
+ });
694
+ }
695
+
696
+ // Eye button toggle for Settings modal
697
+ document.querySelectorAll('.toggle-setting-key').forEach(btn => {
698
+ btn.addEventListener('click', e => {
699
+ e.preventDefault();
700
+ const input = btn.previousElementSibling;
701
+ if (input) {
702
+ input.type = input.type === 'password' ? 'text' : 'password';
703
+ }
704
+ });
705
+ });
706
+
707
+ // Test Connection individually inside Settings Modal
708
+ document.querySelectorAll('.test-individual-btn').forEach(btn => {
709
+ btn.addEventListener('click', async (e) => {
710
+ e.preventDefault();
711
+ const provider = btn.dataset.provider;
712
+ const model = PROVIDER_TEST_MODELS[provider];
713
+
714
+ // Find the input element and status element for this provider
715
+ let apiKey = '';
716
+ const capId = provider.charAt(0).toUpperCase() + provider.slice(1);
717
+ const inputEl = els[`key${capId}`] || els[`url${capId}`] || document.getElementById(`key${capId}`) || document.getElementById(`url${capId}`);
718
+ const statusEl = document.getElementById(`status${capId}`);
719
+
720
+ if (inputEl) {
721
+ apiKey = inputEl.value.trim();
722
+ }
723
+
724
+ if (statusEl) {
725
+ statusEl.className = 'individual-status loading';
726
+ statusEl.textContent = 'Testing connection...';
727
+ statusEl.style.color = 'var(--text-secondary)';
728
+ }
729
+
730
+ const fd = new FormData();
731
+ fd.append('provider', provider);
732
+ fd.append('model', model);
733
+ fd.append('api_key', apiKey);
734
+
735
+ try {
736
+ const res = await fetch('/api/validate-key', { method: 'POST', body: fd });
737
+ if (res.ok) {
738
+ if (statusEl) {
739
+ statusEl.className = 'individual-status ok';
740
+ statusEl.textContent = '✓ Connection successful';
741
+ statusEl.style.color = 'var(--emerald)';
742
+ }
743
+ toast(`${provider.toUpperCase()} connection verified!`, 'success');
744
+ } else {
745
+ const data = await res.json().catch(() => ({}));
746
+ if (statusEl) {
747
+ statusEl.className = 'individual-status error';
748
+ statusEl.textContent = '✗ ' + (data.detail || 'Connection failed');
749
+ statusEl.style.color = 'var(--rose)';
750
+ }
751
+ }
752
+ } catch (err) {
753
+ if (statusEl) {
754
+ statusEl.className = 'individual-status error';
755
+ statusEl.textContent = '✗ Network error';
756
+ statusEl.style.color = 'var(--rose)';
757
+ }
758
+ }
759
+ });
760
+ });
761
+
762
+ // ────────────────────────────────────────────────────────────────────────────
763
+ // LLM Provider & Model selector
764
+ // ────────────────────────────────────────────────────────────────────────────
765
+ async function populateModels(provider) {
766
+ let models = MODEL_OPTIONS[provider] || [];
767
+
768
+ if (provider === 'ollama') {
769
+ const sel = els.llmModel;
770
+ sel.innerHTML = '<option value="">🔄 Loading Ollama models...</option>';
771
+ models = await fetchOllamaModels();
772
+ }
773
+
774
+ const sel = els.llmModel;
775
+ sel.innerHTML = '';
776
+ models.forEach(m => {
777
+ const opt = document.createElement('option');
778
+ opt.value = m; opt.textContent = m;
779
+ sel.appendChild(opt);
780
+ });
781
+ // Add custom option
782
+ const customOpt = document.createElement('option');
783
+ customOpt.value = '__custom__';
784
+ customOpt.textContent = '✏️ Custom model…';
785
+ sel.appendChild(customOpt);
786
+
787
+ // Restore saved model selection if applicable
788
+ const savedModel = localStorage.getItem('llm_model');
789
+ if (savedModel && provider === localStorage.getItem('llm_provider')) {
790
+ const modelOptions = Array.from(sel.options).map(opt => opt.value);
791
+ if (!modelOptions.includes(savedModel) && savedModel !== '__custom__') {
792
+ const opt = document.createElement('option');
793
+ opt.value = savedModel;
794
+ opt.textContent = savedModel;
795
+ sel.insertBefore(opt, sel.lastElementChild);
796
+ }
797
+ sel.value = savedModel;
798
+ }
799
+ }
800
+
801
+ els.llmProvider.addEventListener('change', async () => {
802
+ await populateModels(els.llmProvider.value);
803
+ syncActiveApiKey();
804
+ saveLlmSettings();
805
+ });
806
+ els.llmModel.addEventListener('change', async () => {
807
+ if (els.llmModel.value === '__custom__') {
808
+ const custom = await customPrompt('Enter full model identifier:', '', 'e.g. gpt-4o-mini', 'Custom Model');
809
+ if (custom) {
810
+ const opt = document.createElement('option');
811
+ opt.value = custom; opt.textContent = custom; opt.selected = true;
812
+ els.llmModel.insertBefore(opt, els.llmModel.lastElementChild);
813
+ els.llmModel.value = custom;
814
+ }
815
+ }
816
+ saveLlmSettings();
817
+ });
818
+
819
+
820
+ // ────────────────────────────────────────────────────────────────────────────
821
+ // Sidebar collapse
822
+ // ────────────────────────────────────────────────────────────────────────────
823
+ els.sidebarToggle.addEventListener('click', () => {
824
+ const collapsed = els.sidebar.classList.toggle('collapsed');
825
+ els.sidebarToggle.textContent = collapsed ? '›' : '‹';
826
+ });
827
+ els.mobileSidebarBtn.addEventListener('click', () => {
828
+ els.sidebar.classList.toggle('mobile-open');
829
+ });
830
+
831
+ // ────────────────────────────────────────────────────────────────────────────
832
+ // Test LLM Connection
833
+ // ────────────────────────────────────────────────────────────────────────────
834
+ els.testConnectionBtn.addEventListener('click', async () => {
835
+ const provider = els.llmProvider.value;
836
+ const model = els.llmModel.value;
837
+ const apiKey = els.apiKey.value.trim();
838
+
839
+ els.connectionStatus.className = 'connection-status loading';
840
+ els.connectionStatus.textContent = 'Testing…';
841
+
842
+ const fd = new FormData();
843
+ fd.append('provider', provider);
844
+ fd.append('model', model);
845
+ fd.append('api_key', apiKey);
846
+
847
+ try {
848
+ const res = await fetch('/api/validate-key', { method: 'POST', body: fd });
849
+ if (res.ok) {
850
+ els.connectionStatus.className = 'connection-status ok';
851
+ els.connectionStatus.textContent = '✓ Connection successful';
852
+ toast('LLM connection verified!', 'success');
853
+ } else {
854
+ const data = await res.json().catch(() => ({}));
855
+ els.connectionStatus.className = 'connection-status error';
856
+ els.connectionStatus.textContent = '✗ ' + (data.detail || 'Connection failed');
857
+ }
858
+ } catch (e) {
859
+ els.connectionStatus.className = 'connection-status error';
860
+ els.connectionStatus.textContent = '✗ Network error';
861
+ }
862
+ });
863
+
864
+ // ────────────────────────────────────────────────────────────────────────────
865
+ // Projects sidebar & Local Storage Tracking
866
+ // ────────────────────────────────────────────────────────────────────────────
867
+ function getMyProjects() {
868
+ try {
869
+ return JSON.parse(localStorage.getItem('owned_projects') || '[]');
870
+ } catch {
871
+ return [];
872
+ }
873
+ }
874
+
875
+ function addMyProject(id) {
876
+ const projects = getMyProjects();
877
+ if (!projects.includes(id)) {
878
+ projects.push(id);
879
+ localStorage.setItem('owned_projects', JSON.stringify(projects));
880
+ }
881
+ }
882
+
883
+ function removeMyProject(id) {
884
+ let projects = getMyProjects();
885
+ projects = projects.filter(p => p !== id);
886
+ localStorage.setItem('owned_projects', JSON.stringify(projects));
887
+ }
888
+
889
+ async function loadProjects() {
890
+ try {
891
+ const res = await fetch('/api/projects');
892
+ const allProjects = await res.json();
893
+ const myProjectIds = getMyProjects();
894
+ state.projects = allProjects.filter(p => myProjectIds.includes(p.id));
895
+ renderProjectsList();
896
+ } catch {
897
+ // Server not ready yet, retry
898
+ setTimeout(loadProjects, 2000);
899
+ }
900
+ }
901
+
902
+ function formatBytes(bytes) {
903
+ if (!bytes) return '0 Bytes';
904
+ const k = 1024;
905
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
906
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
907
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
908
+ }
909
+
910
+ function renderProjectsList() {
911
+ // 1. Render sidebar list
912
+ const list = els.projectsList;
913
+ if (list) {
914
+ if (!state.projects.length) {
915
+ list.innerHTML = '<div class="projects-empty">No projects yet.<br>Upload a CSV to begin.</div>';
916
+ } else {
917
+ list.innerHTML = '';
918
+ state.projects.forEach(p => {
919
+ const item = document.createElement('div');
920
+ item.className = `project-item ${state.activeProject?.id === p.id ? 'active' : ''}`;
921
+ item.dataset.id = p.id;
922
+ item.innerHTML = `
923
+ <div class="project-dot ${p.status}"></div>
924
+ <div class="project-name" title="${p.name}">${p.name}</div>
925
+ <button class="project-del" title="Delete project" data-id="${p.id}">✕</button>
926
+ `;
927
+ item.addEventListener('click', e => {
928
+ if (e.target.classList.contains('project-del')) return;
929
+ switchToProject(p);
930
+ });
931
+ item.querySelector('.project-del').addEventListener('click', e => {
932
+ e.stopPropagation();
933
+ deleteProject(p.id);
934
+ });
935
+ list.appendChild(item);
936
+ });
937
+ }
938
+ }
939
+
940
+ // 2. Render dashboard projects section
941
+ const dbGrid = els.dashboardProjectsGrid;
942
+ const dbCount = els.dashboardProjectsCount;
943
+
944
+ if (dbGrid && dbCount) {
945
+ dbCount.textContent = `(${state.projects.length})`;
946
+
947
+ if (!state.projects.length) {
948
+ dbGrid.innerHTML = `
949
+ <div class="projects-empty-card">
950
+ <div class="empty-card-icon"><i data-lucide="folder-open" style="width: 48px; height: 48px; color: var(--violet-light);"></i></div>
951
+ <div class="empty-card-title">No projects yet</div>
952
+ <div class="empty-card-desc">Upload a CSV dataset above to start your first agentic analysis.</div>
953
+ </div>
954
+ `;
955
+ } else {
956
+ dbGrid.innerHTML = '';
957
+ state.projects.forEach(p => {
958
+ const card = document.createElement('div');
959
+ card.className = `dashboard-project-card ${state.activeProject?.id === p.id ? 'active' : ''}`;
960
+ card.dataset.id = p.id;
961
+
962
+ const d = p.created_at ? new Date(p.created_at) : new Date();
963
+ const pad = (n) => String(n).padStart(2, '0');
964
+ const formattedDate = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
965
+
966
+ const sizeStr = formatBytes(p.size);
967
+
968
+ card.innerHTML = `
969
+ <div class="db-project-card-header">
970
+ <span class="db-project-date">${formattedDate}</span>
971
+ <span class="db-project-status-badge ${p.status}">${p.status}</span>
972
+ </div>
973
+ <div class="db-project-preview-wrap">
974
+ <img src="${p.thumbnail || DEFAULT_THUMBNAIL_SVG}" class="db-project-preview-img" alt="Project Preview" onerror="this.onerror=null; this.src=DEFAULT_THUMBNAIL_SVG;" />
975
+ <div class="db-project-preview-overlay">
976
+ <div class="db-project-folder-icon ${p.status}">
977
+ <svg viewBox="0 0 24 24" class="folder-svg" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
978
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
979
+ </svg>
980
+ </div>
981
+ </div>
982
+ </div>
983
+ <h3 class="db-project-card-title" title="${p.name}">${p.name}</h3>
984
+ <div class="db-project-card-footer">
985
+ <span>SIZE: ${sizeStr}</span>
986
+ <span class="db-project-filename" title="${p.filename || 'dataset.csv'}">${p.filename || 'dataset.csv'}</span>
987
+ </div>
988
+ <button class="db-project-btn-del" title="Delete project">✕</button>
989
+ `;
990
+
991
+ card.addEventListener('click', e => {
992
+ if (e.target.classList.contains('db-project-btn-del')) return;
993
+ switchToProject(p);
994
+ });
995
+
996
+ card.querySelector('.db-project-btn-del').addEventListener('click', e => {
997
+ e.stopPropagation();
998
+ deleteProject(p.id);
999
+ });
1000
+
1001
+ dbGrid.appendChild(card);
1002
+ });
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ async function refreshPreviewData(sessionId) {
1008
+ try {
1009
+ const res = await fetch(`/api/projects/${sessionId}/preview`);
1010
+ if (res.ok) {
1011
+ const data = await res.json();
1012
+ state.columns = data.columns || [];
1013
+ state.colTypes = data.col_types || {};
1014
+ renderPreview(data.preview || []);
1015
+ if (els.chatPreviewDims) {
1016
+ els.chatPreviewDims.textContent = `(${data.rows_count?.toLocaleString() || 0} rows × ${data.cols_count || 0} columns)`;
1017
+ }
1018
+ }
1019
+ } catch (e) {
1020
+ console.error('Failed to load dynamic preview:', e);
1021
+ }
1022
+ }
1023
+
1024
+ function updateSidebarProjectActionsVisibility(sec) {
1025
+ const isProjectCompleted = state.activeProject && state.activeProject.status === 'completed';
1026
+ if (els.sidebarProjectActions) {
1027
+ if (isProjectCompleted) {
1028
+ els.sidebarProjectActions.classList.remove('hidden');
1029
+ } else {
1030
+ els.sidebarProjectActions.classList.add('hidden');
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ let preChatSidebarCollapsed = false;
1036
+
1037
+ function switchSection(sec) {
1038
+ const resultsScreen = document.getElementById('resultsScreen');
1039
+ if (sec === 'chat') {
1040
+ els.btnSectionChat.classList.add('active');
1041
+ els.btnSectionAgentic.classList.remove('active');
1042
+ els.areaChat.classList.remove('hidden');
1043
+ els.areaAgentic.classList.add('hidden');
1044
+
1045
+ els.btnSectionChat.style.background = 'var(--violet)';
1046
+ els.btnSectionChat.style.color = '#fff';
1047
+ els.btnSectionAgentic.style.background = 'transparent';
1048
+ els.btnSectionAgentic.style.color = 'var(--text-secondary)';
1049
+
1050
+ if (resultsScreen) resultsScreen.classList.add('ai-chat-mode');
1051
+
1052
+ // Auto-collapse sidebar for full screen chat view
1053
+ preChatSidebarCollapsed = els.sidebar.classList.contains('collapsed');
1054
+ if (!preChatSidebarCollapsed) {
1055
+ els.sidebar.classList.add('collapsed');
1056
+ if (els.sidebarToggle) els.sidebarToggle.textContent = '›';
1057
+ }
1058
+ } else {
1059
+ els.btnSectionChat.classList.remove('active');
1060
+ els.btnSectionAgentic.classList.add('active');
1061
+ els.areaChat.classList.add('hidden');
1062
+ els.areaAgentic.classList.remove('hidden');
1063
+
1064
+ els.btnSectionAgentic.style.background = 'var(--violet)';
1065
+ els.btnSectionAgentic.style.color = '#fff';
1066
+ els.btnSectionChat.style.background = 'transparent';
1067
+ els.btnSectionChat.style.color = 'var(--text-secondary)';
1068
+
1069
+ if (resultsScreen) resultsScreen.classList.remove('ai-chat-mode');
1070
+
1071
+ // Restore sidebar state when exiting chat mode
1072
+ if (preChatSidebarCollapsed === false && els.sidebar.classList.contains('collapsed')) {
1073
+ els.sidebar.classList.remove('collapsed');
1074
+ if (els.sidebarToggle) els.sidebarToggle.textContent = '‹';
1075
+ }
1076
+ }
1077
+ // Resize Plotly charts when switching to agentic area
1078
+ if (sec === 'agentic') {
1079
+ setTimeout(() => Plotly.Plots?.resize?.(), 100);
1080
+ }
1081
+ updateSidebarProjectActionsVisibility(sec);
1082
+
1083
+ if (window.lucide) {
1084
+ lucide.createIcons({ attrs: { class: 'icon-svg' } });
1085
+ }
1086
+ }
1087
+
1088
+ async function switchToProject(p) {
1089
+ state.activeProject = p;
1090
+ renderProjectsList();
1091
+ setBreadcrumb(p.name);
1092
+ setStatus('● Loading…', 'running');
1093
+ updateSidebarProjectActionsVisibility();
1094
+
1095
+ if (p.status === 'completed') {
1096
+ if (els.agenticPlaceholder) els.agenticPlaceholder.classList.add('hidden');
1097
+ if (els.agenticTabsBar) els.agenticTabsBar.classList.remove('hidden');
1098
+ if (els.agenticTabPanels) els.agenticTabPanels.classList.remove('hidden');
1099
+ await loadResults(p.id);
1100
+ goToWorkspaceHub();
1101
+ } else if (p.status === 'running') {
1102
+ if (els.agenticPlaceholder) els.agenticPlaceholder.classList.add('hidden');
1103
+ if (els.agenticTabsBar) els.agenticTabsBar.classList.remove('hidden');
1104
+ if (els.agenticTabPanels) els.agenticTabPanels.classList.remove('hidden');
1105
+ showScreen('running');
1106
+ els.runningTitle.textContent = `Analysing "${p.name}"…`;
1107
+ setStatus('● Running', 'running');
1108
+ startSSEStream(p.id);
1109
+ } else {
1110
+ // Idle state
1111
+ if (els.agenticPlaceholder) els.agenticPlaceholder.classList.remove('hidden');
1112
+ if (els.agenticTabsBar) els.agenticTabsBar.classList.add('hidden');
1113
+ if (els.agenticTabPanels) els.agenticTabPanels.classList.add('hidden');
1114
+
1115
+ state.results = null;
1116
+ await refreshPreviewData(p.id);
1117
+ setupExport(p.id);
1118
+ resetChat();
1119
+ goToWorkspaceHub();
1120
+ showScreen('results');
1121
+ setStatus('● Idle', 'idle');
1122
+ }
1123
+ }
1124
+
1125
+ async function deleteProject(id) {
1126
+ const confirmed = await customConfirm('Delete this project and all its data? This action cannot be undone.', 'Delete Project');
1127
+ if (!confirmed) return;
1128
+ delete state.resultsCache[id];
1129
+ await fetch(`/api/projects/${id}`, { method: 'DELETE' });
1130
+ removeMyProject(id);
1131
+ if (state.activeProject?.id === id) {
1132
+ state.activeProject = null;
1133
+ showScreen('landing');
1134
+ setStatus('● Idle', 'idle');
1135
+ }
1136
+ loadProjects();
1137
+ toast('Project deleted.', 'info');
1138
+ }
1139
+
1140
+ // ────────────────────────────────────────────────────────────────────────────
1141
+ // Wizard Setup & Navigation
1142
+ // ────────────────────────────────────────────────────────────────────────────
1143
+ function resetWizardState() {
1144
+ state.newProjectName = '';
1145
+ if (els.wizardProjectName) {
1146
+ const d = new Date();
1147
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1148
+ els.wizardProjectName.value = `Analysis - ${d.getDate()} ${months[d.getMonth()]}`;
1149
+ }
1150
+ if (els.wizardStep0) els.wizardStep0.classList.add('active');
1151
+ if (els.wizardStep1) els.wizardStep1.classList.remove('active');
1152
+ if (els.wizardStep2) els.wizardStep2.classList.remove('active');
1153
+ if (els.uploadedFileMeta) els.uploadedFileMeta.classList.add('hidden');
1154
+ if (els.uploadedFileActions) els.uploadedFileActions.classList.add('hidden');
1155
+ }
1156
+
1157
+ // Import Project ZIP flow
1158
+ if (els.importProjectCard && els.importZipFileInput) {
1159
+ els.importProjectCard.addEventListener('click', () => {
1160
+ els.importZipFileInput.click();
1161
+ });
1162
+
1163
+ els.importZipFileInput.addEventListener('change', async () => {
1164
+ const file = els.importZipFileInput.files[0];
1165
+ if (!file) return;
1166
+ if (!file.name.endsWith('.zip')) {
1167
+ toast('Only ZIP files are supported.', 'error');
1168
+ return;
1169
+ }
1170
+
1171
+ setStatus('● Importing…', 'running');
1172
+ const fd = new FormData();
1173
+ fd.append('file', file);
1174
+
1175
+ try {
1176
+ const res = await fetch('/api/projects/import-zip', { method: 'POST', body: fd });
1177
+ if (!res.ok) throw new Error((await res.json()).detail || 'Import failed');
1178
+ const data = await res.json();
1179
+ toast(`Project imported successfully: ${data.name}`, 'success');
1180
+ addMyProject(data.id);
1181
+
1182
+ // Reset input
1183
+ els.importZipFileInput.value = '';
1184
+
1185
+ // Reload projects and open the imported project
1186
+ await loadProjects();
1187
+ const importedProj = state.projects.find(p => p.id === data.id);
1188
+ if (importedProj) {
1189
+ switchToProject(importedProj);
1190
+ } else {
1191
+ goToDashboardHome();
1192
+ }
1193
+ } catch (e) {
1194
+ toast('Import failed: ' + e.message, 'error');
1195
+ setStatus('● Idle', 'idle');
1196
+ els.importZipFileInput.value = '';
1197
+ }
1198
+ });
1199
+ }
1200
+
1201
+ // Wire wizard events
1202
+ if (els.startWizardCard) {
1203
+ els.startWizardCard.addEventListener('click', () => {
1204
+ if (els.wizardStep0) els.wizardStep0.classList.remove('active');
1205
+ if (els.wizardStep1) els.wizardStep1.classList.add('active');
1206
+ if (els.wizardProjectName) els.wizardProjectName.focus();
1207
+ });
1208
+ }
1209
+
1210
+ if (els.wizardBack1Btn) {
1211
+ els.wizardBack1Btn.addEventListener('click', () => {
1212
+ if (els.wizardStep1) els.wizardStep1.classList.remove('active');
1213
+ if (els.wizardStep0) els.wizardStep0.classList.add('active');
1214
+ });
1215
+ }
1216
+
1217
+ if (els.wizardNextBtn) {
1218
+ els.wizardNextBtn.addEventListener('click', () => {
1219
+ const name = els.wizardProjectName.value.trim();
1220
+ if (!name) {
1221
+ toast('Please enter a project name.', 'warning');
1222
+ els.wizardProjectName.focus();
1223
+ return;
1224
+ }
1225
+ state.newProjectName = name;
1226
+ state.newProjectReportTitle = els.wizardReportTitle ? els.wizardReportTitle.value.trim() : '';
1227
+ state.newProjectGoal = els.wizardProjectGoal ? els.wizardProjectGoal.value.trim() : '';
1228
+
1229
+ if (els.wizardUploadTitle) {
1230
+ els.wizardUploadTitle.innerHTML = `Upload CSV for <strong>${escHtml(name)}</strong>`;
1231
+ }
1232
+ if (els.reportTitle) {
1233
+ els.reportTitle.value = state.newProjectReportTitle || `${name} Executive Analysis`;
1234
+ }
1235
+ if (els.wizardStep1) els.wizardStep1.classList.remove('active');
1236
+ if (els.wizardStep2) els.wizardStep2.classList.add('active');
1237
+ });
1238
+ }
1239
+
1240
+ if (els.wizardBackBtn) {
1241
+ els.wizardBackBtn.addEventListener('click', () => {
1242
+ if (els.wizardStep2) els.wizardStep2.classList.remove('active');
1243
+ if (els.wizardStep1) els.wizardStep1.classList.add('active');
1244
+ });
1245
+ }
1246
+
1247
+ function goToDashboardHome() {
1248
+ resetWizardState();
1249
+ showScreen('landing');
1250
+ state.uploadedFile = null;
1251
+ state.uploadedSession = null;
1252
+ state.activeProject = null;
1253
+ if (els.fileInput) els.fileInput.value = '';
1254
+ setBreadcrumb('New Project');
1255
+ renderProjectsList();
1256
+ }
1257
+
1258
+ window.goToDashboardHome = goToDashboardHome;
1259
+
1260
+ if (els.sidebarLogo) {
1261
+ els.sidebarLogo.addEventListener('click', () => {
1262
+ goToDashboardHome();
1263
+ });
1264
+ }
1265
+
1266
+ if (els.newProjectBtn) {
1267
+ els.newProjectBtn.addEventListener('click', () => {
1268
+ goToDashboardHome();
1269
+ });
1270
+ }
1271
+
1272
+ function goToWorkspaceHub() {
1273
+ const p = state.activeProject;
1274
+ if (!p) return;
1275
+
1276
+ // Hide sections switcher
1277
+ if (els.btnSectionChat && els.btnSectionChat.parentNode) {
1278
+ els.btnSectionChat.parentNode.classList.add('hidden');
1279
+ }
1280
+
1281
+ // Show areaHub, hide areaChat and areaAgentic
1282
+ if (els.areaHub) els.areaHub.classList.remove('hidden');
1283
+ if (els.areaChat) els.areaChat.classList.add('hidden');
1284
+ if (els.areaAgentic) els.areaAgentic.classList.add('hidden');
1285
+
1286
+ if (els.resultsScreen) els.resultsScreen.classList.remove('ai-chat-mode');
1287
+
1288
+ // Restore sidebar state when exiting chat mode
1289
+ if (preChatSidebarCollapsed === false && els.sidebar.classList.contains('collapsed')) {
1290
+ els.sidebar.classList.remove('collapsed');
1291
+ if (els.sidebarToggle) els.sidebarToggle.textContent = '‹';
1292
+ }
1293
+
1294
+ setBreadcrumb(p.name);
1295
+ }
1296
+ window.goToWorkspaceHub = goToWorkspaceHub;
1297
+
1298
+ function setBreadcrumb(name, activeSection = '') {
1299
+ if (!activeSection) {
1300
+ els.breadcrumb.innerHTML = `
1301
+ <span class="breadcrumb-item" onclick="goToDashboardHome()" style="cursor:pointer">Dashboard</span>
1302
+ <span class="breadcrumb-sep">›</span>
1303
+ <span class="breadcrumb-item active">${escHtml(name)}</span>
1304
+ `;
1305
+ } else {
1306
+ els.breadcrumb.innerHTML = `
1307
+ <span class="breadcrumb-item" onclick="goToDashboardHome()" style="cursor:pointer">Dashboard</span>
1308
+ <span class="breadcrumb-sep">›</span>
1309
+ <span class="breadcrumb-item" onclick="goToWorkspaceHub()" style="cursor:pointer">${escHtml(name)}</span>
1310
+ <span class="breadcrumb-sep">›</span>
1311
+ <span class="breadcrumb-item active">${escHtml(activeSection)}</span>
1312
+ `;
1313
+ }
1314
+ }
1315
+
1316
+ // ────────────────────────────────────────────────────────────────────────────
1317
+ // File Upload
1318
+ // ────────────────────────────────────────────────────────────────────────────
1319
+ ['dragover','dragleave','drop'].forEach(evt => {
1320
+ els.uploadZone.addEventListener(evt, e => {
1321
+ e.preventDefault();
1322
+ if (evt === 'dragover') els.uploadZone.classList.add('drag-over');
1323
+ else {
1324
+ els.uploadZone.classList.remove('drag-over');
1325
+ if (evt === 'drop' && e.dataTransfer.files[0]) handleFileSelected(e.dataTransfer.files[0]);
1326
+ }
1327
+ });
1328
+ });
1329
+ els.uploadZone.addEventListener('click', () => els.fileInput.click());
1330
+ els.fileInput.addEventListener('change', () => {
1331
+ if (els.fileInput.files[0]) handleFileSelected(els.fileInput.files[0]);
1332
+ });
1333
+
1334
+ async function handleFileSelected(file) {
1335
+ if (!file.name.endsWith('.csv')) { toast('Only CSV files are supported.', 'error'); return; }
1336
+ state.uploadedFile = file;
1337
+
1338
+ // Create project immediately via projects endpoint
1339
+ const fd = new FormData();
1340
+ fd.append('name', state.newProjectName || file.name.replace(/\.csv$/i, ''));
1341
+ fd.append('report_title', state.newProjectReportTitle || '');
1342
+ fd.append('goal', state.newProjectGoal || '');
1343
+ fd.append('file', file);
1344
+
1345
+ try {
1346
+ const res = await fetch('/api/projects', { method: 'POST', body: fd });
1347
+ const data = await res.json();
1348
+ state.uploadedSession = data.id;
1349
+
1350
+ els.uploadedFileMeta.textContent = `✓ ${file.name} — ${(file.size / 1024).toFixed(1)} KB uploaded`;
1351
+ els.uploadedFileMeta.classList.remove('hidden');
1352
+ els.uploadedFileActions.classList.remove('hidden');
1353
+ toast(`Project created: ${state.newProjectName || data.name}`, 'success');
1354
+ addMyProject(data.id);
1355
+
1356
+ // Auto-enter project workspace
1357
+ resetWizardState();
1358
+ await loadProjects();
1359
+ const newProj = state.projects.find(p => p.id === data.id);
1360
+ if (newProj) {
1361
+ switchToProject(newProj);
1362
+ }
1363
+ } catch (e) {
1364
+ toast('Project creation failed: ' + e.message, 'error');
1365
+ }
1366
+ }
1367
+
1368
+ // ────────────────────────────────────────────────────────────────────────────
1369
+ // API Warning and Validation
1370
+ // ────────────────────────────────────────────────────────────────────────────
1371
+ function checkApiKeySet() {
1372
+ const provider = els.llmProvider.value;
1373
+ const apiKey = els.apiKey.value.trim();
1374
+ if (provider !== 'ollama' && !apiKey) {
1375
+ const providerNames = {
1376
+ nvidia: 'NVIDIA NIM',
1377
+ groq: 'Groq',
1378
+ openai: 'OpenAI',
1379
+ anthropic: 'Anthropic',
1380
+ gemini: 'Google Gemini',
1381
+ mistral: 'Mistral',
1382
+ huggingface: 'HuggingFace',
1383
+ };
1384
+ els.warningProviderName.textContent = providerNames[provider] || provider.toUpperCase();
1385
+ els.apiWarningModal.classList.remove('hidden');
1386
+ return false;
1387
+ }
1388
+ return true;
1389
+ }
1390
+
1391
+ // API Warning Modal Event Listeners
1392
+ if (els.closeApiWarningBtn) {
1393
+ els.closeApiWarningBtn.addEventListener('click', () => {
1394
+ els.apiWarningModal.classList.add('hidden');
1395
+ });
1396
+ }
1397
+ if (els.apiWarningModal) {
1398
+ els.apiWarningModal.addEventListener('click', e => {
1399
+ if (e.target === els.apiWarningModal) els.apiWarningModal.classList.add('hidden');
1400
+ });
1401
+ }
1402
+ if (els.guideToApiBtn) {
1403
+ els.guideToApiBtn.addEventListener('click', () => {
1404
+ els.apiWarningModal.classList.add('hidden');
1405
+ els.configModal.classList.add('hidden');
1406
+
1407
+ // Open Settings Modal
1408
+ populateSettingsModal();
1409
+ els.settingsModal.classList.remove('hidden');
1410
+
1411
+ // Find the input element for the active provider
1412
+ const provider = els.llmProvider.value;
1413
+ let targetInput = null;
1414
+ if (provider === 'nvidia') targetInput = els.keyNvidia;
1415
+ else if (provider === 'groq') targetInput = els.keyGroq;
1416
+ else if (provider === 'openai') targetInput = els.keyOpenai;
1417
+ else if (provider === 'anthropic') targetInput = els.keyAnthropic;
1418
+ else if (provider === 'gemini') targetInput = els.keyGemini;
1419
+ else if (provider === 'mistral') targetInput = els.keyMistral;
1420
+ else if (provider === 'huggingface') targetInput = els.keyHuggingface;
1421
+ else if (provider === 'ollama') targetInput = els.urlOllama;
1422
+
1423
+ if (targetInput) {
1424
+ targetInput.classList.add('highlight-glowing');
1425
+ targetInput.focus();
1426
+
1427
+ const removeHighlight = () => {
1428
+ targetInput.classList.remove('highlight-glowing');
1429
+ targetInput.removeEventListener('input', removeHighlight);
1430
+ };
1431
+ targetInput.addEventListener('input', removeHighlight);
1432
+ }
1433
+ });
1434
+ }
1435
+
1436
+ els.startAnalysisBtn.addEventListener('click', () => {
1437
+ if (!state.uploadedSession) { toast('No file uploaded yet.', 'warning'); return; }
1438
+ if (!checkApiKeySet()) return;
1439
+ openConfigModal();
1440
+ });
1441
+
1442
+ // ────────────────────────────────────────────────────────────────────────────
1443
+ // Config Modal
1444
+ // ────────────────────────────────────────────────────────────────────────────
1445
+ function openConfigModal() {
1446
+ els.configModal.classList.remove('hidden');
1447
+ }
1448
+ function closeConfigModal() {
1449
+ els.configModal.classList.add('hidden');
1450
+ }
1451
+ els.closeConfigModal.addEventListener('click', closeConfigModal);
1452
+ els.cancelConfigBtn.addEventListener('click', closeConfigModal);
1453
+ els.configModal.addEventListener('click', e => {
1454
+ if (e.target === els.configModal) closeConfigModal();
1455
+ });
1456
+
1457
+ // ── Task card dependency logic ───────────────────────────────────────────────
1458
+ function syncTaskCards() {
1459
+ const cleanChecked = els.taskCleaning.checked;
1460
+ const relationsChecked = els.taskRelations.checked;
1461
+
1462
+ // Relations & Insights require Cleaning
1463
+ if (!cleanChecked) {
1464
+ els.taskRelations.checked = false;
1465
+ els.taskInsights.checked = false;
1466
+ }
1467
+ // Viz requires Relations
1468
+ if (!relationsChecked) {
1469
+ els.taskViz.checked = false;
1470
+ }
1471
+
1472
+ // Update visual state
1473
+ [
1474
+ [els.taskCleaning, els.taskCardCleaning, true],
1475
+ [els.taskRelations, els.taskCardRelations, cleanChecked],
1476
+ [els.taskInsights, els.taskCardInsights, cleanChecked],
1477
+ [els.taskViz, els.taskCardViz, els.taskRelations.checked || relationsChecked],
1478
+ ].forEach(([chk, card, enabled]) => {
1479
+ card.classList.toggle('checked', chk.checked);
1480
+ card.classList.toggle('disabled', !enabled);
1481
+ if (!enabled) chk.checked = false;
1482
+ });
1483
+ }
1484
+
1485
+ ['taskCleaning','taskRelations','taskInsights','taskViz'].forEach(id => {
1486
+ $(id).closest('.task-card').addEventListener('click', () => {
1487
+ const chk = $(id);
1488
+ if (!$(id).closest('.task-card').classList.contains('disabled')) {
1489
+ chk.checked = !chk.checked;
1490
+ syncTaskCards();
1491
+ }
1492
+ });
1493
+ });
1494
+ syncTaskCards();
1495
+
1496
+ // ── Depth selector ───────────────────────────────────────────────────────────
1497
+ $$('.depth-option').forEach(opt => {
1498
+ opt.addEventListener('click', () => {
1499
+ $$('.depth-option').forEach(o => o.classList.remove('active'));
1500
+ opt.classList.add('active');
1501
+ });
1502
+ });
1503
+
1504
+ // ── Run button ───────────────────────────────────────────────────────────────
1505
+ els.runAnalysisBtn.addEventListener('click', async () => {
1506
+ if (!checkApiKeySet()) return;
1507
+ const tasks = [];
1508
+ if (els.taskCleaning.checked) tasks.push('cleaning');
1509
+ if (els.taskRelations.checked) tasks.push('relations');
1510
+ if (els.taskInsights.checked) tasks.push('insights');
1511
+ if (els.taskViz.checked) tasks.push('visualization');
1512
+
1513
+ if (!tasks.length) { toast('Select at least one task.', 'warning'); return; }
1514
+
1515
+ const deep = $$('.depth-option.active')[0]?.querySelector('input')?.value === 'true';
1516
+
1517
+ const provider = els.llmProvider.value;
1518
+ const model = els.llmModel.value === '__custom__' ? '' : els.llmModel.value;
1519
+ const apiKey = els.apiKey.value.trim();
1520
+ const cooldown = parseInt(els.cooldown.value, 10);
1521
+ const title = els.reportTitle.value.trim();
1522
+
1523
+ const fd = new FormData();
1524
+ fd.append('session_id', state.uploadedSession);
1525
+ fd.append('provider', provider);
1526
+ fd.append('model', model);
1527
+ fd.append('api_key', apiKey);
1528
+ fd.append('cooldown', cooldown);
1529
+ fd.append('selected_tasks', tasks.join(','));
1530
+ fd.append('deep_analysis', String(deep));
1531
+ fd.append('report_title', title);
1532
+
1533
+ closeConfigModal();
1534
+
1535
+ try {
1536
+ const res = await fetch('/api/analyze', { method: 'POST', body: fd });
1537
+ if (!res.ok) throw new Error((await res.json()).detail || 'Start failed');
1538
+
1539
+ // Update project metadata locally in-place (or add if new)
1540
+ const projName = title || state.uploadedFile?.name?.replace(/\.csv$/i, '') || 'New Analysis';
1541
+ delete state.resultsCache[state.uploadedSession];
1542
+ const existingIdx = state.projects.findIndex(p => p.id === state.uploadedSession);
1543
+ if (existingIdx !== -1) {
1544
+ state.projects[existingIdx].status = 'running';
1545
+ state.projects[existingIdx].name = projName;
1546
+ state.activeProject = state.projects[existingIdx];
1547
+ } else {
1548
+ const newProj = {
1549
+ id: state.uploadedSession,
1550
+ name: projName,
1551
+ status: 'running',
1552
+ };
1553
+ state.activeProject = newProj;
1554
+ state.projects.unshift(newProj);
1555
+ }
1556
+ renderProjectsList();
1557
+ setBreadcrumb(projName);
1558
+
1559
+ showScreen('running');
1560
+ els.runningTitle.textContent = `Analysing "${projName}"…`;
1561
+ setStatus('● Running', 'running');
1562
+ startSSEStream(state.uploadedSession);
1563
+ toast('Analysis started!', 'success');
1564
+ } catch (e) {
1565
+ toast('Failed to start analysis: ' + e.message, 'error');
1566
+ }
1567
+ });
1568
+
1569
+ // ────────────────────────────────────────────────────────────────────────────
1570
+ // SSE Live Log Stream
1571
+ // ────────────────────────────────────────────────────────────────────────────
1572
+ const STAGE_KEYWORDS = {
1573
+ cleaning: ['cleaning', 'cleaned', 'data clean'],
1574
+ relations: ['relation', 'correlation'],
1575
+ insights: ['insight', 'business'],
1576
+ visualization: ['visualization', 'png', 'chart', 'plot'],
1577
+ plotly: ['plotly', 'interactive chart'],
1578
+ };
1579
+
1580
+ function classifyLine(line) {
1581
+ const ll = line.toLowerCase();
1582
+ if (ll.includes('[thought]') || ll.startsWith('thought:')) return 'thought';
1583
+ if (ll.includes('[calling tool]') || ll.startsWith('action:')) return 'action';
1584
+ if (ll.includes('[tool response]') || ll.includes('observation:')) return 'response';
1585
+ if (ll.includes('[task]') || ll.includes('complete')) return 'done';
1586
+ if (ll.includes('[error]') || ll.includes('error')) return 'error';
1587
+ if (ll.includes('[warning]') || ll.includes('warning')) return 'warning';
1588
+ return '';
1589
+ }
1590
+
1591
+ function appendLog(line) {
1592
+ const div = document.createElement('div');
1593
+ div.className = `log-line ${classifyLine(line)}`;
1594
+ div.textContent = line;
1595
+ els.logOutput.appendChild(div);
1596
+ els.logOutput.scrollTop = els.logOutput.scrollHeight;
1597
+ }
1598
+
1599
+ let povInterval = null;
1600
+
1601
+ function renderStagePov(stage) {
1602
+ if (povInterval) {
1603
+ clearInterval(povInterval);
1604
+ povInterval = null;
1605
+ }
1606
+
1607
+ if (!els.stagePovPanel) return;
1608
+
1609
+ if (stage === 'cleaning') {
1610
+ els.stagePovPanel.innerHTML = `
1611
+ <div class="pov-header">
1612
+ <div class="pov-title"><i data-lucide="brush" style="width:18px; height:18px; color:var(--violet-light); vertical-align:middle; margin-right:8px;"></i> Data Sanitizer Active</div>
1613
+ <div class="pov-desc">Scanning column profiles, auditing data formatting anomalies, and executing Python sanitization code...</div>
1614
+ </div>
1615
+ <div class="pov-content">
1616
+ <div class="pov-scanner-wrap" id="povScannerWrap">
1617
+ <div class="pov-scanner-line"></div>
1618
+ <div class="pov-scanner-row active" data-idx="0"><span>[SCAN] Reading source CSV...</span><span>SCANNING</span></div>
1619
+ <div class="pov-scanner-row" data-idx="1"><span>[AUDIT] Assessing columns for missing cells...</span><span>PENDING</span></div>
1620
+ <div class="pov-scanner-row" data-idx="2"><span>[CLEAN] Executing auto-imputation models...</span><span>PENDING</span></div>
1621
+ <div class="pov-scanner-row" data-idx="3"><span>[EXPORT] Committing sanitized dataset...</span><span>PENDING</span></div>
1622
+ </div>
1623
+ </div>`;
1624
+
1625
+ const rows = els.stagePovPanel.querySelectorAll('.pov-scanner-row');
1626
+ let currentIdx = 0;
1627
+ povInterval = setInterval(() => {
1628
+ if (currentIdx < rows.length) {
1629
+ rows.forEach((r, idx) => {
1630
+ r.classList.toggle('active', idx === currentIdx);
1631
+ const statusCol = r.querySelectorAll('span')[1];
1632
+ if (idx < currentIdx) {
1633
+ statusCol.textContent = 'COMPLETE';
1634
+ statusCol.style.color = '#10b981';
1635
+ } else if (idx === currentIdx) {
1636
+ statusCol.textContent = 'RUNNING';
1637
+ statusCol.style.color = '#ffffff';
1638
+ } else {
1639
+ statusCol.textContent = 'PENDING';
1640
+ }
1641
+ });
1642
+ currentIdx++;
1643
+ } else {
1644
+ rows.forEach(r => {
1645
+ const statusCol = r.querySelectorAll('span')[1];
1646
+ statusCol.textContent = 'COMPLETE';
1647
+ statusCol.style.color = '#10b981';
1648
+ });
1649
+ clearInterval(povInterval);
1650
+ }
1651
+ }, 2500);
1652
+
1653
+ } else if (stage === 'relations') {
1654
+ els.stagePovPanel.innerHTML = `
1655
+ <div class="pov-header">
1656
+ <div class="pov-title"><i data-lucide="link" style="width:18px; height:18px; color:var(--cyan); vertical-align:middle; margin-right:8px;"></i> Correlation Detector Engaged</div>
1657
+ <div class="pov-desc">Computing Pearson/Spearman coefficients, identifying multi-variable dependencies, and mapping relationship strengths...</div>
1658
+ </div>
1659
+ <div class="pov-content">
1660
+ <svg class="pov-chords-svg" viewBox="0 0 160 100">
1661
+ <g class="pov-chords-rotate">
1662
+ <circle cx="80" cy="50" r="30" class="pov-chord-line" stroke-dasharray="2,2"></circle>
1663
+ <circle cx="80" cy="50" r="35" class="pov-chord-line active"></circle>
1664
+
1665
+ <circle cx="50" cy="30" r="4" class="pov-node"></circle>
1666
+ <circle cx="110" cy="30" r="4" class="pov-node"></circle>
1667
+ <circle cx="50" cy="70" r="4" class="pov-node"></circle>
1668
+ <circle cx="110" cy="70" r="4" class="pov-node"></circle>
1669
+ <circle cx="80" cy="15" r="4" class="pov-node"></circle>
1670
+ <circle cx="80" cy="85" r="4" class="pov-node"></circle>
1671
+
1672
+ <path d="M50 30 L110 70" class="pov-chord-line active" stroke-dasharray="none"></path>
1673
+ <path d="M110 30 L50 70" class="pov-chord-line" stroke-dasharray="4,4"></path>
1674
+ <path d="M80 15 L80 85" class="pov-chord-line active" stroke-dasharray="none"></path>
1675
+ </g>
1676
+ </svg>
1677
+ </div>`;
1678
+
1679
+ } else if (stage === 'insights') {
1680
+ els.stagePovPanel.innerHTML = `
1681
+ <div class="pov-header">
1682
+ <div class="pov-title"><i data-lucide="lightbulb" style="width:18px; height:18px; color:var(--amber); vertical-align:middle; margin-right:8px;"></i> Strategic Business Insights</div>
1683
+ <div class="pov-desc">Correlating discovered patterns to business implications and drafting actionable McKinsey-level recommendation strategies...</div>
1684
+ </div>
1685
+ <div class="pov-content">
1686
+ <div class="pov-insight-bulb">
1687
+ <svg class="pov-bulb-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
1688
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
1689
+ </svg>
1690
+ <div class="pov-insight-bullets">
1691
+ <div class="pov-bullet-text">Identifying key driver columns...</div>
1692
+ <div class="pov-bullet-text">Formulating risk matrices...</div>
1693
+ <div class="pov-bullet-text">Generating recommendations...</div>
1694
+ </div>
1695
+ </div>
1696
+ </div>`;
1697
+
1698
+ } else if (stage === 'visualization') {
1699
+ els.stagePovPanel.innerHTML = `
1700
+ <div class="pov-header">
1701
+ <div class="pov-title"><i data-lucide="image" style="width:18px; height:18px; color:var(--rose); vertical-align:middle; margin-right:8px;"></i> Visual Intelligence Compiler</div>
1702
+ <div class="pov-desc">Writing corporate Seaborn/Matplotlib visualization scripts and compiling high-resolution PNG plots...</div>
1703
+ </div>
1704
+ <div class="pov-content">
1705
+ <svg class="pov-compiler-svg" viewBox="0 0 150 90">
1706
+ <line x1="20" y1="10" x2="20" y2="80" class="pov-axis"></line>
1707
+ <line x1="20" y1="80" x2="140" y2="80" class="pov-axis"></line>
1708
+
1709
+ <path d="M20 70 Q 50 20, 80 50 T 140 15" class="pov-compiler-path"></path>
1710
+
1711
+ <circle cx="50" cy="38" r="3" class="pov-dot" style="animation-delay: 0.8s;"></circle>
1712
+ <circle cx="80" cy="50" r="3" class="pov-dot" style="animation-delay: 1.4s;"></circle>
1713
+ <circle cx="110" cy="28" r="3" class="pov-dot" style="animation-delay: 2s;"></circle>
1714
+ <circle cx="130" cy="20" r="3" class="pov-dot" style="animation-delay: 2.6s;"></circle>
1715
+ </svg>
1716
+ </div>`;
1717
+
1718
+ } else if (stage === 'plotly') {
1719
+ els.stagePovPanel.innerHTML = `
1720
+ <div class="pov-header">
1721
+ <div class="pov-title"><i data-lucide="bar-chart-3" style="width:18px; height:18px; color:var(--emerald); vertical-align:middle; margin-right:8px;"></i> Interactive Dashboard Builder</div>
1722
+ <div class="pov-desc">Building zoomable, hoverable Plotly structures and generating the final analytical results suite...</div>
1723
+ </div>
1724
+ <div class="pov-content">
1725
+ <div class="pov-plotly-grid">
1726
+ <div class="pov-skeleton-card">
1727
+ <div class="pov-skeleton-header"></div>
1728
+ <div class="pov-skeleton-body">
1729
+ <div class="pov-skeleton-bar"></div>
1730
+ <div class="pov-skeleton-bar"></div>
1731
+ <div class="pov-skeleton-bar"></div>
1732
+ </div>
1733
+ </div>
1734
+ <div class="pov-skeleton-card">
1735
+ <div class="pov-skeleton-header"></div>
1736
+ <div class="pov-skeleton-body">
1737
+ <div class="pov-skeleton-bar" style="animation-delay:0.2s"></div>
1738
+ <div class="pov-skeleton-bar" style="animation-delay:0.5s"></div>
1739
+ <div class="pov-skeleton-bar" style="animation-delay:0.8s"></div>
1740
+ </div>
1741
+ </div>
1742
+ <div class="pov-skeleton-card">
1743
+ <div class="pov-skeleton-header"></div>
1744
+ <div class="pov-skeleton-body">
1745
+ <div class="pov-skeleton-bar" style="animation-delay:0.4s"></div>
1746
+ <div class="pov-skeleton-bar" style="animation-delay:0.7s"></div>
1747
+ <div class="pov-skeleton-bar" style="animation-delay:1.0s"></div>
1748
+ </div>
1749
+ </div>
1750
+ </div>
1751
+ </div>`;
1752
+ }
1753
+
1754
+ if (window.lucide) {
1755
+ lucide.createIcons();
1756
+ }
1757
+ }
1758
+
1759
+ function markStage(stage, status) {
1760
+ const el = document.querySelector(`.stage-item[data-stage="${stage}"]`);
1761
+ if (el) {
1762
+ el.classList.remove('active','done');
1763
+ el.classList.add(status);
1764
+ }
1765
+ if (status === 'active') {
1766
+ renderStagePov(stage);
1767
+ }
1768
+ }
1769
+
1770
+ let _activeStage = null;
1771
+ const STAGE_ORDER = ['cleaning', 'relations', 'insights', 'visualization', 'plotly'];
1772
+
1773
+ function updateProgressTrack(newStage) {
1774
+ const newIdx = STAGE_ORDER.indexOf(newStage);
1775
+ if (newIdx === -1) return;
1776
+
1777
+ const currentIdx = STAGE_ORDER.indexOf(_activeStage);
1778
+
1779
+ // We only progress forward. If newIdx is less than or equal to currentIdx, ignore.
1780
+ if (newIdx <= currentIdx) return;
1781
+
1782
+ // Mark all stages before newStage as done
1783
+ for (let i = 0; i < newIdx; i++) {
1784
+ markStage(STAGE_ORDER[i], 'done');
1785
+ }
1786
+
1787
+ // Mark newStage as active
1788
+ _activeStage = newStage;
1789
+ markStage(newStage, 'active');
1790
+
1791
+ // Update notification stage text
1792
+ const labels = {
1793
+ cleaning: 'Cleaning dataset...',
1794
+ relations: 'Mapping relationships...',
1795
+ insights: 'Generating BI insights...',
1796
+ visualization: 'Exporting charts...',
1797
+ plotly: 'Building Plotly charts...'
1798
+ };
1799
+ const text = labels[newStage] || 'Analysing...';
1800
+ const el = $('notifActiveJob');
1801
+ if (el) el.textContent = text;
1802
+ }
1803
+
1804
+ function inferStageFromLog(line) {
1805
+ const ll = line.toLowerCase();
1806
+
1807
+ // First, check for explicit stage indicators from stdout!
1808
+ if (ll.includes('[stage 1/4]') || ll.includes('running data cleaner') || ll.includes('data clean')) {
1809
+ return 'cleaning';
1810
+ }
1811
+ if (ll.includes('[stage 2/4]') || ll.includes('running relation analyst') || ll.includes('relation analyst + bi analyst')) {
1812
+ return 'relations';
1813
+ }
1814
+ if (ll.includes('running bi analyst') || ll.includes('business intelligence analyst') || ll.includes('generating recommendations') || ll.includes('insights complete')) {
1815
+ return 'insights';
1816
+ }
1817
+ if (ll.includes('[stage 3/4]') || ll.includes('running data visualizer') || ll.includes('visualization complete')) {
1818
+ return 'visualization';
1819
+ }
1820
+ if (ll.includes('[stage 4/4]') || ll.includes('building interactive plotly charts') || ll.includes('plotly_charts') || ll.includes('interactive chart')) {
1821
+ return 'plotly';
1822
+ }
1823
+
1824
+ // Fallback to keywords but with strict boundary matching / ordering
1825
+ for (const stage of STAGE_ORDER) {
1826
+ const keywords = STAGE_KEYWORDS[stage];
1827
+ if (keywords.some(k => {
1828
+ if (k === 'chart' || k === 'plot') {
1829
+ return ll.includes(k) && !ll.includes('plotly');
1830
+ }
1831
+ return ll.includes(k);
1832
+ })) {
1833
+ return stage;
1834
+ }
1835
+ }
1836
+ return null;
1837
+ }
1838
+
1839
+ function startSSEStream(sessionId) {
1840
+ // Close any existing stream
1841
+ if (state.sseSource) { state.sseSource.close(); state.sseSource = null; }
1842
+
1843
+ // Reset stage indicators
1844
+ $$('.stage-item').forEach(el => el.classList.remove('active','done'));
1845
+ els.logOutput.innerHTML = '';
1846
+ _activeStage = null;
1847
+
1848
+ if (povInterval) { clearInterval(povInterval); povInterval = null; }
1849
+ if (els.stagePovPanel) {
1850
+ els.stagePovPanel.innerHTML = `
1851
+ <div class="pov-initial-state">
1852
+ <div class="pov-pulse-ring"></div>
1853
+ <p>Awaiting analysis stream...</p>
1854
+ </div>`;
1855
+ }
1856
+
1857
+ // Initialize and show the top-right notification toast
1858
+ const notif = $('analysisNotification');
1859
+ if (notif) {
1860
+ notif.classList.remove('hidden', 'complete');
1861
+ const notifTitle = notif.querySelector('.notif-title');
1862
+ if (notifTitle) notifTitle.textContent = 'Crewlyze Analysis Active';
1863
+ const activeJobEl = $('notifActiveJob');
1864
+ if (activeJobEl) activeJobEl.textContent = 'Initializing...';
1865
+ const timerEl = $('notifTimer');
1866
+ if (timerEl) timerEl.textContent = '00:00';
1867
+ const btnMax = $('btnMaximizeNotif');
1868
+ if (btnMax) {
1869
+ btnMax.textContent = 'View';
1870
+ btnMax.onclick = () => {
1871
+ showScreen('running');
1872
+ };
1873
+ }
1874
+ }
1875
+
1876
+ // Setup the elapsed timer
1877
+ if (window.notifTimerInterval) { clearInterval(window.notifTimerInterval); }
1878
+ window.notifElapsedSeconds = 0;
1879
+ window.notifTimerInterval = setInterval(() => {
1880
+ window.notifElapsedSeconds++;
1881
+ const mins = Math.floor(window.notifElapsedSeconds / 60).toString().padStart(2, '0');
1882
+ const secs = (window.notifElapsedSeconds % 60).toString().padStart(2, '0');
1883
+ const timerEl = $('notifTimer');
1884
+ if (timerEl) timerEl.textContent = `${mins}:${secs}`;
1885
+ }, 1000);
1886
+
1887
+ const src = new EventSource(`/api/analyze/stream?session_id=${sessionId}`);
1888
+ state.sseSource = src;
1889
+
1890
+ src.onmessage = e => {
1891
+ const line = e.data;
1892
+
1893
+ if (line === '[EOF]') {
1894
+ src.close();
1895
+ state.sseSource = null;
1896
+ // Mark all stages as done (green) on successful completion
1897
+ STAGE_ORDER.forEach(s => markStage(s, 'done'));
1898
+
1899
+ // Finalize the notification toast
1900
+ if (window.notifTimerInterval) { clearInterval(window.notifTimerInterval); window.notifTimerInterval = null; }
1901
+ if (notif) {
1902
+ notif.classList.add('complete');
1903
+ const notifTitle = notif.querySelector('.notif-title');
1904
+ if (notifTitle) notifTitle.textContent = 'Analysis Completed!';
1905
+ const activeJobEl = $('notifActiveJob');
1906
+ if (activeJobEl) activeJobEl.textContent = 'Results are ready.';
1907
+ const btnMax = $('btnMaximizeNotif');
1908
+ if (btnMax) {
1909
+ btnMax.textContent = 'View Results';
1910
+ btnMax.onclick = () => {
1911
+ notif.classList.add('hidden');
1912
+ loadResults(sessionId);
1913
+ };
1914
+ }
1915
+ }
1916
+
1917
+ appendLog('Analysis complete! Loading results…');
1918
+ setStatus('● Loading…', 'running');
1919
+ setTimeout(() => loadResults(sessionId), 1500);
1920
+ return;
1921
+ }
1922
+
1923
+ appendLog(line);
1924
+
1925
+ // Infer stage transitions and update progress track
1926
+ const stage = inferStageFromLog(line);
1927
+ if (stage) {
1928
+ updateProgressTrack(stage);
1929
+ }
1930
+ };
1931
+
1932
+ src.onerror = () => {
1933
+ src.close();
1934
+ state.sseSource = null;
1935
+
1936
+ // Stop the timer on error
1937
+ if (window.notifTimerInterval) { clearInterval(window.notifTimerInterval); window.notifTimerInterval = null; }
1938
+ if (notif) {
1939
+ notif.classList.add('complete');
1940
+ const notifTitle = notif.querySelector('.notif-title');
1941
+ if (notifTitle) notifTitle.textContent = 'Analysis Finished';
1942
+ const activeJobEl = $('notifActiveJob');
1943
+ if (activeJobEl) activeJobEl.textContent = 'Stream finalized.';
1944
+ const btnMax = $('btnMaximizeNotif');
1945
+ if (btnMax) {
1946
+ btnMax.textContent = 'View Results';
1947
+ btnMax.onclick = () => {
1948
+ notif.classList.add('hidden');
1949
+ loadResults(sessionId);
1950
+ };
1951
+ }
1952
+ }
1953
+
1954
+ // Try to load results anyway
1955
+ setTimeout(() => loadResults(sessionId), 2000);
1956
+ };
1957
+ }
1958
+
1959
+ els.clearLogBtn.addEventListener('click', () => { els.logOutput.innerHTML = ''; });
1960
+
1961
+ // ────────────────────────────────────────────────────────────────────────────
1962
+ // Load & Render Results
1963
+ // ────────────────────────────────────────────────────────────────────────────
1964
+ async function loadResults(sessionId, retryCount = 0) {
1965
+ // Check if we have cached results for this session
1966
+ if (state.resultsCache[sessionId]) {
1967
+ const data = state.resultsCache[sessionId];
1968
+ state.results = data;
1969
+ if (data.preview && data.preview.length) {
1970
+ state.columns = Object.keys(data.preview[0]);
1971
+ }
1972
+ if (state.activeProject) state.activeProject.status = 'completed';
1973
+ setStatus('● Complete', 'complete');
1974
+ if (els.agenticPlaceholder) els.agenticPlaceholder.classList.add('hidden');
1975
+ if (els.agenticTabsBar) els.agenticTabsBar.classList.remove('hidden');
1976
+ if (els.agenticTabPanels) els.agenticTabPanels.classList.remove('hidden');
1977
+ renderDashboard(data, sessionId);
1978
+ showScreen('results');
1979
+ return;
1980
+ }
1981
+
1982
+ try {
1983
+ const res = await fetch(`/api/results?session_id=${sessionId}`);
1984
+ if (!res.ok) throw new Error('Results not ready');
1985
+ const data = await res.json();
1986
+
1987
+ if (data.ready === false) {
1988
+ throw new Error('Results pending');
1989
+ }
1990
+
1991
+ if (data.error) {
1992
+ setStatus('● Error', 'error');
1993
+ toast('Analysis failed: ' + data.error, 'error');
1994
+ if (state.activeProject) {
1995
+ state.activeProject.status = 'failed';
1996
+ loadProjects();
1997
+ }
1998
+ showScreen('landing');
1999
+ return;
2000
+ }
2001
+
2002
+ // Cache the retrieved results
2003
+ state.resultsCache[sessionId] = data;
2004
+
2005
+ const wasRunning = els.runningScreen.classList.contains('active');
2006
+
2007
+ state.results = data;
2008
+
2009
+ // Extract column info from preview
2010
+ if (data.preview && data.preview.length) {
2011
+ state.columns = Object.keys(data.preview[0]);
2012
+ }
2013
+
2014
+ // Update project status
2015
+ if (state.activeProject) state.activeProject.status = 'completed';
2016
+ loadProjects();
2017
+ setStatus('● Complete', 'complete');
2018
+
2019
+ if (els.agenticPlaceholder) els.agenticPlaceholder.classList.add('hidden');
2020
+ if (els.agenticTabsBar) els.agenticTabsBar.classList.remove('hidden');
2021
+ if (els.agenticTabPanels) els.agenticTabPanels.classList.remove('hidden');
2022
+
2023
+ renderDashboard(data, sessionId);
2024
+
2025
+ if (wasRunning) {
2026
+ switchSection('agentic');
2027
+ }
2028
+
2029
+ showScreen('results');
2030
+ } catch (e) {
2031
+ // Results not ready yet, retry up to 15 times (~37 seconds)
2032
+ if (retryCount < 15) {
2033
+ setTimeout(() => loadResults(sessionId, retryCount + 1), 2500);
2034
+ } else {
2035
+ setStatus('● Error', 'error');
2036
+ toast('Analysis failed: Server timed out producing results.', 'error');
2037
+ if (state.activeProject) {
2038
+ state.activeProject.status = 'failed';
2039
+ loadProjects();
2040
+ }
2041
+ showScreen('landing');
2042
+ }
2043
+ }
2044
+ }
2045
+
2046
+ function renderDashboard(data, sessionId) {
2047
+ renderStats(data);
2048
+ renderPreview(data.preview || []);
2049
+ renderCleaning(data.cleaning_steps || '');
2050
+ renderRelations(data.relations || '');
2051
+ renderInsights(data.insights || '');
2052
+ renderCharts(data.plotly_charts || [], data.png_charts || [], sessionId);
2053
+ renderVizCode(data.code || '');
2054
+ setupExport(sessionId);
2055
+ resetChat();
2056
+
2057
+ // Set dimensions text in Chat Preview
2058
+ if (els.chatPreviewDims) {
2059
+ els.chatPreviewDims.textContent = `(${data.rows_count?.toLocaleString() || 0} rows × ${data.cols_count || 0} columns)`;
2060
+ }
2061
+
2062
+ // Default to AI Chat section
2063
+ switchSection('chat');
2064
+
2065
+ // Activate first tab inside agentic area
2066
+ activateTab('cleaning');
2067
+ }
2068
+
2069
+ // ── Stats row ────────────────────────────────────────────────────────────────
2070
+ function renderStats(data) {
2071
+ const stats = [
2072
+ { val: (data.rows_count || 0).toLocaleString(), lbl: 'Total Records', color: 'var(--violet)' },
2073
+ { val: data.cols_count || 0, lbl: 'Total Columns', color: 'var(--cyan)' },
2074
+ { val: data.numeric_count || 0, lbl: 'Numeric Fields', color: 'var(--emerald)' },
2075
+ { val: data.cat_count || 0, lbl: 'Categorical Fields', color: 'var(--amber)' },
2076
+ { val: (data.plotly_charts || []).length, lbl: 'Interactive Charts', color: 'var(--rose)' },
2077
+ ];
2078
+ els.statsRow.innerHTML = stats.map(s => `
2079
+ <div class="stat-card" style="--accent:${s.color}">
2080
+ <div class="stat-val">${s.val}</div>
2081
+ <div class="stat-lbl">${s.lbl}</div>
2082
+ <div class="stat-accent-bar"></div>
2083
+ </div>
2084
+ `).join('');
2085
+ }
2086
+
2087
+ // ── Data Preview ─────────────────────────────────────────────────────────────
2088
+ function renderPreview(rows) {
2089
+ if (!rows.length) {
2090
+ els.previewTable.innerHTML = '<p style="color:var(--text-muted);padding:16px">No preview data.</p>';
2091
+ return;
2092
+ }
2093
+ const cols = Object.keys(rows[0]);
2094
+ state.columns = cols;
2095
+
2096
+ const thead = `<thead><tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr></thead>`;
2097
+ const tbody = `<tbody>${rows.map(row =>
2098
+ `<tr>${cols.map(c => `<td title="${row[c] ?? ''}">${row[c] ?? ''}</td>`).join('')}</tr>`
2099
+ ).join('')}</tbody>`;
2100
+
2101
+ els.previewTable.innerHTML = `<table class="data-table">${thead}${tbody}</table>`;
2102
+ }
2103
+
2104
+ // Toggle preview minimize
2105
+ if (els.togglePreviewBtn) {
2106
+ els.togglePreviewBtn.addEventListener('click', () => {
2107
+ state.previewMinimized = !state.previewMinimized;
2108
+ if (state.previewMinimized) {
2109
+ els.previewTableWrap.innerHTML = `
2110
+ <div class="preview-minimized">
2111
+ Preview minimized — ${state.results?.rows_count?.toLocaleString() ?? '?'} rows × ${state.columns.length} columns.
2112
+ </div>`;
2113
+ els.togglePreviewBtn.textContent = '🔼 Expand Preview';
2114
+ } else {
2115
+ els.previewTableWrap.innerHTML = '<div id="previewTable" class="data-table-container"></div>';
2116
+ renderPreview(state.results?.preview || []);
2117
+ els.togglePreviewBtn.textContent = '🔽 Minimize';
2118
+ }
2119
+ });
2120
+ }
2121
+
2122
+ // ── Cleaning ─────────────────────────────────────────────────────────────────
2123
+ function renderCleaning(text) {
2124
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
2125
+ if (!lines.length) {
2126
+ els.cleaningContent.innerHTML = '<p style="color:var(--text-muted)">No cleaning operations recorded.</p>';
2127
+ return;
2128
+ }
2129
+ els.cleaningContent.innerHTML = lines.map(l =>
2130
+ `<div class="cleaning-item">
2131
+ <div class="cleaning-check" style="display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.2);"><i data-lucide="check" style="width: 12px; height: 12px; color: var(--emerald);"></i></div>
2132
+ <div class="cleaning-text" style="flex: 1;">${escHtml(l.replace(/^[-*•]\s*/, ''))}</div>
2133
+ </div>`
2134
+ ).join('');
2135
+
2136
+ if (window.lucide) {
2137
+ lucide.createIcons();
2138
+ }
2139
+ }
2140
+
2141
+ // ── Relations ─────────────────────────────────────────────────────────────────
2142
+ // ── Relations ─────────────────────────────────────────────────────────────────
2143
+ function renderRelations(text) {
2144
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
2145
+
2146
+ // Parse lines into structured state.relationsList
2147
+ state.relationsList = [];
2148
+ lines.forEach(line => {
2149
+ const xMatch = line.match(/X:\s*([^|]+)/i);
2150
+ const yMatch = line.match(/Y:\s*([^|]+)/i);
2151
+ const typeMatch = line.match(/Type:\s*([^|]+)/i);
2152
+ const detailsMatch = line.match(/Details:\s*(.+)/i);
2153
+
2154
+ if (xMatch && yMatch) {
2155
+ state.relationsList.push({
2156
+ xCol: xMatch[1].trim(),
2157
+ yCol: yMatch[1].trim(),
2158
+ typ: typeMatch ? typeMatch[1].trim() : 'Correlation',
2159
+ details: detailsMatch ? detailsMatch[1].trim() : 'Key relationship identified by analyst.'
2160
+ });
2161
+ }
2162
+ });
2163
+
2164
+ renderRelationsListUI();
2165
+ }
2166
+
2167
+ function renderRelationsListUI() {
2168
+ const list = state.relationsList || [];
2169
+ if (!list.length) {
2170
+ els.relationsContent.innerHTML = `
2171
+ <div style="text-align: center; padding: 24px; color: var(--text-secondary); background: var(--bg-card); border-radius: var(--r-md); border: 1px dashed var(--border-mid); width: 100%;">
2172
+ <p style="margin: 0;">No schema relationships mapped yet.</p>
2173
+ </div>`;
2174
+ return;
2175
+ }
2176
+
2177
+ let html = '<div class="relation-mapper-container">';
2178
+ list.forEach((rel, index) => {
2179
+ const xType = state.colTypes[rel.xCol] || 'Numeric';
2180
+ const yType = state.colTypes[rel.yCol] || 'Numeric';
2181
+
2182
+ html += `
2183
+ <div class="relation-card" style="position: relative; flex: 1 1 calc(50% - 16px); min-width: 380px; box-sizing: border-box;">
2184
+ <div class="relation-node">
2185
+ <span class="relation-node-type">${escHtml(xType)}</span>
2186
+ <strong>${escHtml(rel.xCol)}</strong>
2187
+ </div>
2188
+
2189
+ <div class="relation-connector">
2190
+ <span style="font-size: 0.8rem; font-weight: 600; color: var(--cyan); margin-bottom: 4px;">${escHtml(rel.typ)}</span>
2191
+ <div class="relation-line"></div>
2192
+ <span class="relation-info">${escHtml(rel.details)}</span>
2193
+ </div>
2194
+
2195
+ <div class="relation-node" style="border-color: rgba(34, 211, 238, 0.2);">
2196
+ <span class="relation-node-type" style="color: var(--cyan);">${escHtml(yType)}</span>
2197
+ <strong>${escHtml(rel.yCol)}</strong>
2198
+ </div>
2199
+ </div>
2200
+ `;
2201
+ });
2202
+
2203
+ html += '</div>';
2204
+ els.relationsContent.innerHTML = html;
2205
+ }
2206
+
2207
+ function showRelationModal(index) {
2208
+ const cols = state.columns || [];
2209
+ els.relationModalXSelect.innerHTML = cols.map(c => `<option value="${escHtml(c)}">${escHtml(c)}</option>`).join('');
2210
+ els.relationModalYSelect.innerHTML = cols.map(c => `<option value="${escHtml(c)}">${escHtml(c)}</option>`).join('');
2211
+
2212
+ if (index !== null && state.relationsList[index]) {
2213
+ const rel = state.relationsList[index];
2214
+ els.relationModalTitle.innerHTML = '<i data-lucide="edit" style="width: 16px; height: 16px; vertical-align: middle; margin-right: 6px;"></i> Edit Relationship Schema';
2215
+ els.relationModalXSelect.value = rel.xCol;
2216
+ els.relationModalYSelect.value = rel.yCol;
2217
+ els.relationModalTypeSelect.value = rel.typ;
2218
+ els.relationModalDetails.value = rel.details;
2219
+ } else {
2220
+ els.relationModalTitle.innerHTML = '<i data-lucide="plus" style="width: 16px; height: 16px; vertical-align: middle; margin-right: 6px;"></i> Add Custom Relationship';
2221
+ if (cols.length > 1) {
2222
+ els.relationModalXSelect.selectedIndex = 0;
2223
+ els.relationModalYSelect.selectedIndex = 1;
2224
+ }
2225
+ els.relationModalTypeSelect.value = 'Scatter Plot';
2226
+ els.relationModalDetails.value = '';
2227
+ }
2228
+
2229
+ els.relationModal.classList.remove('hidden');
2230
+
2231
+ els.relationModalConfirmBtn.onclick = () => {
2232
+ const relObj = {
2233
+ xCol: els.relationModalXSelect.value,
2234
+ yCol: els.relationModalYSelect.value,
2235
+ typ: els.relationModalTypeSelect.value,
2236
+ details: els.relationModalDetails.value.trim() || 'Custom correlation defined by data scientist.'
2237
+ };
2238
+
2239
+ if (index !== null) {
2240
+ state.relationsList[index] = relObj;
2241
+ } else {
2242
+ state.relationsList.push(relObj);
2243
+ }
2244
+ els.relationModal.classList.add('hidden');
2245
+ renderRelationsListUI();
2246
+ };
2247
+
2248
+ els.relationModalCancelBtn.onclick = () => els.relationModal.classList.add('hidden');
2249
+ els.closeRelationModalBtn.onclick = () => els.relationModal.classList.add('hidden');
2250
+ }
2251
+
2252
+ function deleteRelationAt(index) {
2253
+ state.relationsList.splice(index, 1);
2254
+ renderRelationsListUI();
2255
+ toast('Relationship removed from schema configuration.', 'info');
2256
+ }
2257
+
2258
+ async function saveTweakedRelations() {
2259
+ const sessionId = state.activeProject?.id || state.uploadedSession;
2260
+ if (!sessionId) { toast('No active project context.', 'error'); return; }
2261
+
2262
+ const textLines = state.relationsList.map(r =>
2263
+ `- X: ${r.xCol} | Y: ${r.yCol} | Type: ${r.typ} | Details: ${r.details}`
2264
+ ).join('\n');
2265
+
2266
+ try {
2267
+ const fd = new FormData();
2268
+ fd.append('relations_text', textLines);
2269
+
2270
+ const res = await fetch(`/api/projects/${sessionId}/tweak-relations`, {
2271
+ method: 'POST',
2272
+ body: fd
2273
+ });
2274
+
2275
+ if (res.ok) {
2276
+ toast('Schema relationships committed successfully!', 'success');
2277
+ if (state.results) {
2278
+ state.results.relations = textLines;
2279
+ }
2280
+ } else {
2281
+ toast('Failed to save schema tweaks.', 'error');
2282
+ }
2283
+ } catch (e) {
2284
+ toast('Error saving schema tweaks: ' + e.message, 'error');
2285
+ }
2286
+ }
2287
+
2288
+ // ── Insights Markdown Formatter ──────────────────────────────────────────────
2289
+ function formatInlineMarkdown(content) {
2290
+ let isBullet = false;
2291
+ let cleanContent = content;
2292
+ if (/^[-*•]\s+/.test(content)) {
2293
+ isBullet = true;
2294
+ cleanContent = content.replace(/^[-*•]\s+/, '');
2295
+ }
2296
+
2297
+ let formatted = escHtml(cleanContent)
2298
+ .replace(/```([\s\S]*?)```/g, '<pre>$1</pre>')
2299
+ .replace(/`([^`]+)`/g, '<code style="background:var(--bg-surface);padding:1px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em">$1</code>')
2300
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
2301
+ .replace(/\*([^*]+)\*/g, '<em>$1</em>');
2302
+
2303
+ if (isBullet) {
2304
+ return `<div style="display: flex; gap: 8px; margin-left: 12px; margin-top: 4px; margin-bottom: 4px;">
2305
+ <span>•</span>
2306
+ <div>${formatted}</div>
2307
+ </div>`;
2308
+ }
2309
+ return formatted;
2310
+ }
2311
+
2312
+ // ── Insights ─────────────────────────────────────────────────────────────────
2313
+ function renderInsights(text) {
2314
+ if (!text || !text.trim() || text.toLowerCase().includes('skipped')) {
2315
+ els.insightsContent.innerHTML = '<p style="color:var(--text-muted)">No business insights generated.</p>';
2316
+ return;
2317
+ }
2318
+
2319
+ // Parse sections
2320
+ let objectivesText = "";
2321
+ let statsText = "";
2322
+ let strategicText = "";
2323
+ let warningsText = "";
2324
+
2325
+ const sections = text.split(/###\s+/);
2326
+ sections.forEach(sec => {
2327
+ const lines = sec.split('\n');
2328
+ if (lines.length === 0) return;
2329
+ const header = lines[0].trim().toLowerCase();
2330
+ const content = lines.slice(1).join('\n').trim();
2331
+
2332
+ if (header.includes('objective') || header.includes('goal')) {
2333
+ objectivesText = content;
2334
+ } else if (header.includes('stat')) {
2335
+ statsText = content;
2336
+ } else if (header.includes('insight')) {
2337
+ strategicText = content;
2338
+ } else if (header.includes('warning') || header.includes('alert')) {
2339
+ warningsText = content;
2340
+ }
2341
+ });
2342
+
2343
+ // If parsing didn't find specific sections, fallback to parsing as a single block
2344
+ if (!strategicText && !objectivesText) {
2345
+ strategicText = text;
2346
+ }
2347
+
2348
+ let html = "";
2349
+
2350
+ // 1. Objectives & Goals Banner
2351
+ if (objectivesText) {
2352
+ html += `
2353
+ <div class="insight-header-card">
2354
+ <div class="insight-header-title"><i data-lucide="target" style="width: 18px; height: 18px; vertical-align: middle; margin-right: 6px; color: var(--violet-light);"></i> Primary Objectives &amp; Goals</div>
2355
+ <div class="insight-header-text">${formatInsightsSubsections(objectivesText)}</div>
2356
+ </div>
2357
+ `;
2358
+ }
2359
+
2360
+ // 2. Dataset Statistics Grid
2361
+ if (statsText) {
2362
+ const lines = statsText.split('\n').map(l => l.trim()).filter(Boolean);
2363
+ let summaryStats = [];
2364
+ let numericCols = [];
2365
+ let categoricalCols = [];
2366
+
2367
+ let currentSection = "";
2368
+
2369
+ lines.forEach(line => {
2370
+ const cleanLine = line.replace(/^[-*•]\s*/, '').trim();
2371
+ const lowerLine = cleanLine.toLowerCase();
2372
+
2373
+ if (lowerLine.startsWith("numeric column")) {
2374
+ currentSection = "numeric";
2375
+ return;
2376
+ } else if (lowerLine.startsWith("categorical column")) {
2377
+ currentSection = "categorical";
2378
+ return;
2379
+ }
2380
+
2381
+ const colIndex = cleanLine.indexOf(':');
2382
+ if (colIndex > -1) {
2383
+ const key = cleanLine.slice(0, colIndex).trim();
2384
+ const val = cleanLine.slice(colIndex + 1).trim();
2385
+
2386
+ if (lowerLine.startsWith("total row") || lowerLine.startsWith("total record") || lowerLine.startsWith("total col")) {
2387
+ summaryStats.push({ key, val });
2388
+ } else {
2389
+ if (currentSection === "numeric") {
2390
+ numericCols.push({ col: key, stats: val });
2391
+ } else if (currentSection === "categorical") {
2392
+ categoricalCols.push({ col: key, stats: val });
2393
+ } else {
2394
+ if (lowerLine.includes("min") || lowerLine.includes("max") || lowerLine.includes("mean")) {
2395
+ numericCols.push({ col: key, stats: val });
2396
+ } else {
2397
+ categoricalCols.push({ col: key, stats: val });
2398
+ }
2399
+ }
2400
+ }
2401
+ } else {
2402
+ if (cleanLine) {
2403
+ if (currentSection === "numeric") {
2404
+ numericCols.push({ col: cleanLine, stats: "" });
2405
+ } else if (currentSection === "categorical") {
2406
+ categoricalCols.push({ col: cleanLine, stats: "" });
2407
+ }
2408
+ }
2409
+ }
2410
+ });
2411
+
2412
+ // A. Summary Cards
2413
+ if (summaryStats.length > 0) {
2414
+ html += `<div class="insight-stats-grid" style="margin-bottom: 20px;">`;
2415
+ summaryStats.forEach(s => {
2416
+ html += `
2417
+ <div class="insight-stat-card">
2418
+ <div class="insight-stat-val">${escHtml(s.val)}</div>
2419
+ <div class="insight-stat-lbl">${escHtml(s.key)}</div>
2420
+ </div>
2421
+ `;
2422
+ });
2423
+ html += `</div>`;
2424
+ }
2425
+
2426
+ // B. Side-by-Side Sections
2427
+ if (numericCols.length > 0 || categoricalCols.length > 0) {
2428
+ html += `
2429
+ <div style="display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 24px;">
2430
+ <!-- Numeric Columns -->
2431
+ <div class="card" style="flex: 1 1 calc(50% - 10px); min-width: 320px; padding: 18px; background: var(--bg-card); border: 1px solid var(--border-mid); box-sizing: border-box;">
2432
+ <h4 style="margin: 0 0 12px 0; color: var(--cyan); font-size: 0.95rem; display: flex; align-items: center; gap: 6px;">
2433
+ <i data-lucide="binary" style="width: 16px; height: 16px;"></i> Numeric Columns &amp; Distributions
2434
+ </h4>
2435
+ <div style="display: flex; flex-direction: column; gap: 10px;">
2436
+ `;
2437
+ if (numericCols.length > 0) {
2438
+ numericCols.forEach(c => {
2439
+ html += `
2440
+ <div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px dashed var(--border-low); padding-bottom: 8px; font-size: 0.85rem;">
2441
+ <strong style="color: #fff;">${escHtml(c.col)}</strong>
2442
+ <span style="color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.8rem; text-align: right;">${escHtml(c.stats)}</span>
2443
+ </div>
2444
+ `;
2445
+ });
2446
+ } else {
2447
+ html += `<p style="color: var(--text-muted); font-size: 0.85rem; margin: 0;">None detected.</p>`;
2448
+ }
2449
+ html += `
2450
+ </div>
2451
+ </div>
2452
+
2453
+ <!-- Categorical Columns -->
2454
+ <div class="card" style="flex: 1 1 calc(50% - 10px); min-width: 320px; padding: 18px; background: var(--bg-card); border: 1px solid var(--border-mid); box-sizing: border-box;">
2455
+ <h4 style="margin: 0 0 12px 0; color: var(--amber); font-size: 0.95rem; display: flex; align-items: center; gap: 6px;">
2456
+ <i data-lucide="tags" style="width: 16px; height: 16px;"></i> Categorical Columns
2457
+ </h4>
2458
+ <div style="display: flex; flex-direction: column; gap: 10px;">
2459
+ `;
2460
+ if (categoricalCols.length > 0) {
2461
+ categoricalCols.forEach(c => {
2462
+ const statsLabel = c.stats ? `: ${c.stats}` : '';
2463
+ html += `
2464
+ <div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px dashed var(--border-low); padding-bottom: 8px; font-size: 0.85rem;">
2465
+ <strong style="color: #fff;">${escHtml(c.col)}</strong>
2466
+ <span style="color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.8rem;">${escHtml(statsLabel)}</span>
2467
+ </div>
2468
+ `;
2469
+ });
2470
+ } else {
2471
+ html += `<p style="color: var(--text-muted); font-size: 0.85rem; margin: 0;">None detected.</p>`;
2472
+ }
2473
+ html += `
2474
+ </div>
2475
+ </div>
2476
+ </div>
2477
+ `;
2478
+ }
2479
+ }
2480
+
2481
+ // 3. Strategic Insights List
2482
+ if (strategicText) {
2483
+ html += `<h4 style="margin-bottom: 12px; color: var(--violet-light); font-size: 1.05rem; display: flex; align-items: center; gap: 6px;"><i data-lucide="lightbulb" style="width: 18px; height: 18px; color: var(--violet-light);"></i> Strategic Business Insights</h4>`;
2484
+ const numberedSplit = strategicText.split(/\n(?=\s*(?:\*{0,2}\d+[.):]\*{0,2}|#{1,3}\s))/);
2485
+ let items = [];
2486
+ if (numberedSplit.length > 1) {
2487
+ items = numberedSplit.map(s => s.replace(/^\s*(?:\*{0,2}\d+[.):]\*{0,2}|#{1,3})\s*/, '').trim()).filter(Boolean);
2488
+ } else {
2489
+ items = strategicText.split(/\n{2,}/).map(s => s.trim()).filter(Boolean);
2490
+ }
2491
+
2492
+ const LABEL_CFG = [
2493
+ { re: /^(?:observation|finding|pattern)[s]?[:]/i, cls: 'obs', icon: 'search', label: 'Observation' },
2494
+ { re: /^(?:business\s+)?implication[s]?[:]/i, cls: 'impl', icon: 'briefcase', label: 'Implication' },
2495
+ { re: /^(?:actionable\s+)?strateg(?:y|ies)[:]/i, cls: 'strat', icon: 'rocket', label: 'Strategy' },
2496
+ { re: /^(?:recommendation|action|next\s+step)[s]?[:]/i, cls: 'strat', icon: 'check-circle', label: 'Recommendation' },
2497
+ { re: /^(?:risk|concern|warning)[s]?[:]/i, cls: 'impl', icon: 'alert-triangle', label: 'Risk' },
2498
+ { re: /^(?:kpi|metric|measure)[s]?[:]/i, cls: 'obs', icon: 'trending-up', label: 'Metric' },
2499
+ ];
2500
+
2501
+ html += items.map((item, i) => {
2502
+ const parts = item.split('\n').map(p => p.trim()).filter(Boolean);
2503
+ const sectionHtml = parts.map(p => {
2504
+ for (const cfg of LABEL_CFG) {
2505
+ if (cfg.re.test(p)) {
2506
+ const body = p.replace(cfg.re, '').trim();
2507
+ return `<div class="insight-section">
2508
+ <div class="insight-section-label ${cfg.cls}"><i data-lucide="${cfg.icon}" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 4px;"></i> ${cfg.label}</div>
2509
+ <div class="insight-section-text">${formatInlineMarkdown(body || p)}</div>
2510
+ </div>`;
2511
+ }
2512
+ }
2513
+ if (/^[-*•]/.test(p)) {
2514
+ return `<div class="insight-bullet">${formatInlineMarkdown(p)}</div>`;
2515
+ }
2516
+ if (/^#{1,3}\s/.test(p)) {
2517
+ return `<div class="insight-sub-heading">${escHtml(p.replace(/^#+\s*/, ''))}</div>`;
2518
+ }
2519
+ return `<div class="insight-section-text">${formatInlineMarkdown(p)}</div>`;
2520
+ }).join('');
2521
+
2522
+ return `
2523
+ <div class="insight-card">
2524
+ <div class="insight-num-pill">${i + 1}</div>
2525
+ <div class="insight-body">${sectionHtml}</div>
2526
+ </div>
2527
+ `;
2528
+ }).join('');
2529
+ }
2530
+
2531
+ // 4. Warnings & Alerts
2532
+ if (warningsText && !warningsText.toLowerCase().includes('no warnings') && !warningsText.toLowerCase().includes('none')) {
2533
+ html += `
2534
+ <div class="insight-warning-card">
2535
+ <div class="insight-warning-card-icon"><i data-lucide="alert-triangle" style="width: 24px; height: 24px; color: var(--amber);"></i></div>
2536
+ <div class="insight-warning-card-body">
2537
+ <div class="insight-warning-card-title">Business Risks &amp; Data Alerts</div>
2538
+ <div class="insight-warning-card-text">${formatInsightsSubsections(warningsText)}</div>
2539
+ </div>
2540
+ </div>
2541
+ `;
2542
+ }
2543
+
2544
+ els.insightsContent.innerHTML = html;
2545
+ }
2546
+
2547
+ function formatInsightsSubsections(text) {
2548
+ return text.split('\n').map(line => {
2549
+ const trimmed = line.trim();
2550
+ if (trimmed.startsWith('-') || trimmed.startsWith('*')) {
2551
+ return `<div style="margin-left: 12px; margin-top: 4px; display: flex; gap: 6px;">
2552
+ <span>•</span>
2553
+ <span>${formatInlineMarkdown(trimmed.replace(/^[-*•]\s*/, ''))}</span>
2554
+ </div>`;
2555
+ }
2556
+ return `<p style="margin-top: 4px; margin-bottom: 4px;">${formatInlineMarkdown(trimmed)}</p>`;
2557
+ }).join('');
2558
+ }
2559
+
2560
+ // ── Charts ────────────────────────────────────────────────────────────────────
2561
+ function renderCharts(plotlyCharts, pngCharts, sessionId) {
2562
+ els.plotlyChartsWrap.innerHTML = '';
2563
+
2564
+ if (plotlyCharts.length) {
2565
+ plotlyCharts.forEach((chart, idx) => {
2566
+ const card = document.createElement('div');
2567
+ card.className = 'chart-card';
2568
+
2569
+ const typeLabel = chart.fig_json?.data?.[0]?.type || 'chart';
2570
+ card.innerHTML = `
2571
+ <div class="chart-card-header">
2572
+ <div class="chart-card-title">${escHtml(chart.title)}</div>
2573
+ <div class="chart-card-type">${typeLabel}</div>
2574
+ </div>
2575
+ <div class="chart-card-body">
2576
+ <div id="plotly_chart_${idx}" style="width:100%;height:380px;"></div>
2577
+ </div>`;
2578
+ els.plotlyChartsWrap.appendChild(card);
2579
+
2580
+ try {
2581
+ const figData = chart.fig_json.data || [];
2582
+ const figLayout = chart.fig_json.layout || {};
2583
+ // Force dark theme
2584
+ figLayout.paper_bgcolor = 'rgba(9,9,11,0)';
2585
+ figLayout.plot_bgcolor = 'rgba(15,23,42,0.4)';
2586
+ figLayout.font = { color: '#e2e8f0', family: 'Inter, sans-serif' };
2587
+ figLayout.margin = figLayout.margin || { l:50, r:20, t:50, b:50 };
2588
+
2589
+ Plotly.newPlot(`plotly_chart_${idx}`, figData, figLayout, {
2590
+ responsive: true,
2591
+ displayModeBar: true,
2592
+ modeBarButtonsToRemove: ['toImage','sendDataToCloud'],
2593
+ displaylogo: false,
2594
+ });
2595
+ } catch (e) {
2596
+ card.querySelector('.chart-card-body').innerHTML =
2597
+ `<div class="chart-card-error">⚠ Could not render chart: ${escHtml(String(e))}</div>`;
2598
+ }
2599
+ });
2600
+ } else {
2601
+ els.plotlyChartsWrap.innerHTML =
2602
+ '<p style="color:var(--text-muted);padding:8px 0">No interactive charts available. Run with Relationship Analysis enabled.</p>';
2603
+ }
2604
+
2605
+ // PNG charts — masonry-style grid with error fallback
2606
+ if (pngCharts.length) {
2607
+ els.pngChartsWrap.classList.remove('hidden');
2608
+ els.pngCharts.innerHTML = pngCharts.map((name, idx) => {
2609
+ const title = name.replace(/\.png$/i, '')
2610
+ .replace(/_/g, ' ')
2611
+ .replace(/\brelation\b/g, '')
2612
+ .replace(/^[a-z]/, c => c.toUpperCase())
2613
+ .trim();
2614
+ return `
2615
+ <div class="chart-card png-chart-card">
2616
+ <div class="chart-card-header">
2617
+ <div class="chart-card-title">${escHtml(title)}</div>
2618
+ <div class="chart-card-type">agent chart</div>
2619
+ </div>
2620
+ <div class="chart-card-body png-chart-body">
2621
+ <img
2622
+ src="/api/charts/${sessionId}/${encodeURIComponent(name)}"
2623
+ alt="${escHtml(title)}"
2624
+ class="png-chart-img"
2625
+ loading="lazy"
2626
+ onerror="this.parentNode.innerHTML='<div class=\'chart-img-error\'>Chart not available yet — rerun analysis to generate.</div>'"
2627
+ />
2628
+ </div>
2629
+ </div>`;
2630
+ }).join('');
2631
+ } else {
2632
+ els.pngChartsWrap.classList.add('hidden');
2633
+ }
2634
+ }
2635
+
2636
+ // ── Viz code ─────────────────────────────────────────────────────────────────
2637
+ function renderVizCode(code) {
2638
+ els.vizCodeDetails.classList.add('hidden');
2639
+ if (code && code.trim()) {
2640
+ els.vizCodeBlock.textContent = code;
2641
+ }
2642
+ }
2643
+
2644
+ // ── Export ────────────────────────────────────────────────────────────────────
2645
+ function setupExport(sessionId) {
2646
+ const exportPdf = () => {
2647
+ window.location = `/api/export-pdf?session_id=${sessionId}`;
2648
+ };
2649
+ const exportZip = () => {
2650
+ window.location = `/api/projects/${sessionId}/export-zip`;
2651
+ };
2652
+ const downloadCsv = () => {
2653
+ window.location = `/api/projects/${sessionId}/download-csv`;
2654
+ };
2655
+ const reRun = () => {
2656
+ state.uploadedSession = sessionId;
2657
+ state.uploadedFile = { name: state.activeProject?.filename || 'dataset.csv' };
2658
+ openConfigModal();
2659
+ };
2660
+
2661
+ els.exportPdfBtn.onclick = exportPdf;
2662
+ if (els.sidebarExportPdfBtn) els.sidebarExportPdfBtn.onclick = exportPdf;
2663
+
2664
+ if (els.exportZipBtn) els.exportZipBtn.onclick = exportZip;
2665
+ if (els.sidebarExportZipBtn) els.sidebarExportZipBtn.onclick = exportZip;
2666
+
2667
+ els.downloadCsvBtn.onclick = downloadCsv;
2668
+ if (els.sidebarDownloadCsvBtn) els.sidebarDownloadCsvBtn.onclick = downloadCsv;
2669
+
2670
+ els.reRunBtn.onclick = reRun;
2671
+ if (els.sidebarReRunBtn) els.sidebarReRunBtn.onclick = reRun;
2672
+ }
2673
+
2674
+ // ────────────────────────────────────────────────────────────────────────────
2675
+ // Tabs
2676
+ // ────────────────────────────────────────────────────────────────────────────
2677
+ function activateTab(name) {
2678
+ els.tabBtns.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
2679
+ els.tabPanels.forEach(panel => panel.classList.toggle('active', panel.id === `panel-${name}`));
2680
+ if (name === 'charts') {
2681
+ setTimeout(() => {
2682
+ const chartElements = document.querySelectorAll('[id^="plotly_chart_"]');
2683
+ chartElements.forEach(el => Plotly.Plots?.resize?.(el));
2684
+ }, 100);
2685
+ }
2686
+ }
2687
+ els.tabBtns.forEach(btn => {
2688
+ btn.addEventListener('click', () => activateTab(btn.dataset.tab));
2689
+ });
2690
+
2691
+ // Debounced Window Resize Plotly Listener
2692
+ let resizeTimeout = null;
2693
+ window.addEventListener('resize', () => {
2694
+ if (resizeTimeout) clearTimeout(resizeTimeout);
2695
+ resizeTimeout = setTimeout(() => {
2696
+ const activeTabBtn = document.querySelector('.tab-btn.active');
2697
+ if (activeTabBtn && activeTabBtn.dataset.tab === 'charts') {
2698
+ const chartElements = document.querySelectorAll('[id^="plotly_chart_"]');
2699
+ chartElements.forEach(el => Plotly.Plots?.resize?.(el));
2700
+ }
2701
+ }, 200);
2702
+ });
2703
+
2704
+ // ────────────────────────────────────────────────────────────────────────────
2705
+ // AI Copilot Chat + /column picker
2706
+ // ────────────────────────────────────────────────────────────────────────────
2707
+ function resetChat() {
2708
+ state.chatHistory = [];
2709
+ els.chatMessages.innerHTML = '';
2710
+ // Seed with a welcome message
2711
+ appendChatMsg('assistant', `Hi! I'm your AI Data Copilot. Ask me anything about your dataset — aggregations, trends, plots, or specific columns.\n\nType **/** to insert a column name directly.`);
2712
+ }
2713
+
2714
+ function appendChatMsg(role, content, plotUrl = null) {
2715
+ const div = document.createElement('div');
2716
+ div.className = `chat-msg ${role}`;
2717
+ const avatar = role === 'user'
2718
+ ? '<i data-lucide="user" style="width: 16px; height: 16px;"></i>'
2719
+ : '<i data-lucide="bot" style="width: 16px; height: 16px;"></i>';
2720
+
2721
+ let formatted = '';
2722
+ // Try to use marked.js, fallback if CDN fails
2723
+ if (typeof marked !== 'undefined') {
2724
+ formatted = marked.parse(content);
2725
+ } else {
2726
+ formatted = escHtml(content)
2727
+ .replace(/```([\s\S]*?)```/g, '<pre>$1</pre>')
2728
+ .replace(/`([^`]+)`/g, '<code style="background:var(--bg-surface);padding:1px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em">$1</code>')
2729
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
2730
+ .replace(/\n/g, '<br>');
2731
+ }
2732
+
2733
+ div.innerHTML = `
2734
+ <div class="chat-avatar">${avatar}</div>
2735
+ <div class="chat-bubble markdown-body">
2736
+ ${formatted}
2737
+ ${plotUrl ? `<img src="${plotUrl}" alt="Generated chart" style="margin-top:1rem; border-radius:12px; border:1px solid var(--border-color); max-width:100%; box-shadow: 0 4px 15px var(--shadow-color);" />` : ''}
2738
+ </div>`;
2739
+ els.chatMessages.appendChild(div);
2740
+ els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
2741
+
2742
+ if (window.lucide) {
2743
+ lucide.createIcons({ attrs: { class: 'icon-svg' } });
2744
+ }
2745
+ }
2746
+
2747
+ function showTypingIndicator() {
2748
+ const div = document.createElement('div');
2749
+ div.className = 'chat-msg assistant';
2750
+ div.id = 'typingIndicator';
2751
+ div.innerHTML = `
2752
+ <div class="chat-avatar"><i data-lucide="bot" style="width: 16px; height: 16px;"></i></div>
2753
+ <div class="chat-bubble" style="color:var(--text-muted)">
2754
+ <span style="animation:pulse 1.2s infinite;display:inline-block">Analysing</span>…
2755
+ </div>`;
2756
+ els.chatMessages.appendChild(div);
2757
+ els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
2758
+
2759
+ if (window.lucide) {
2760
+ lucide.createIcons({ attrs: { class: 'icon-svg' } });
2761
+ }
2762
+ }
2763
+
2764
+ function removeTypingIndicator() {
2765
+ const el = $('typingIndicator');
2766
+ if (el) el.remove();
2767
+ }
2768
+
2769
+ async function sendChat() {
2770
+ const query = els.chatInput.value.trim();
2771
+ if (!query) return;
2772
+
2773
+ const sessionId = state.activeProject?.id || state.uploadedSession;
2774
+ if (!sessionId) { toast('No active session. Run an analysis first.', 'warning'); return; }
2775
+
2776
+ // Read API key directly from localStorage (not the hidden input which may be stale)
2777
+ const provider = els.llmProvider.value;
2778
+ const apiKey = getSavedKey(provider);
2779
+
2780
+ // For non-Ollama providers, warn if no key
2781
+ if (provider !== 'ollama' && !apiKey) {
2782
+ toast('No API key set. Go to settings to add your key.', 'warning');
2783
+ return;
2784
+ }
2785
+
2786
+ els.chatInput.value = '';
2787
+ hideColumnPicker();
2788
+ appendChatMsg('user', query);
2789
+ showTypingIndicator();
2790
+
2791
+ const fd = new FormData();
2792
+ fd.append('session_id', sessionId);
2793
+ fd.append('query', query);
2794
+ fd.append('provider', provider);
2795
+ fd.append('model', els.llmModel.value === '__custom__' ? '' : els.llmModel.value);
2796
+ fd.append('api_key', apiKey);
2797
+
2798
+ try {
2799
+ const res = await fetch('/api/copilot', { method: 'POST', body: fd });
2800
+ removeTypingIndicator();
2801
+
2802
+ if (!res.ok) {
2803
+ // Surface backend HTTP errors (422, 500, etc.)
2804
+ let errMsg = `Server error (${res.status})`;
2805
+ try {
2806
+ const errData = await res.json();
2807
+ errMsg = errData.detail || errData.message || errMsg;
2808
+ } catch (_) {}
2809
+ appendChatMsg('assistant', errMsg);
2810
+ return;
2811
+ }
2812
+
2813
+ const data = await res.json();
2814
+ const text = data.text && data.text.trim() ? data.text : 'No response returned.';
2815
+ appendChatMsg('assistant', text, data.plot_url || null);
2816
+
2817
+ // Reload dynamic preview if this query modified the dataset
2818
+ const qLower = query.toLowerCase();
2819
+ if (qLower.includes('delete') || qLower.includes('rename') || qLower.includes('replace') || qLower.includes('drop') || qLower.includes('fix') || qLower.includes('clean') || qLower.includes('modify') || qLower.includes('update')) {
2820
+ await refreshPreviewData(sessionId);
2821
+ }
2822
+ } catch (e) {
2823
+ removeTypingIndicator();
2824
+ appendChatMsg('assistant', 'Network error: ' + e.message);
2825
+ }
2826
+ }
2827
+
2828
+ els.sendChatBtn.addEventListener('click', sendChat);
2829
+ els.chatInput.addEventListener('keydown', e => {
2830
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
2831
+ });
2832
+
2833
+ // Quick hint chips
2834
+ $$('.chat-hint-chip').forEach(chip => {
2835
+ chip.addEventListener('click', () => {
2836
+ els.chatInput.value = chip.dataset.query;
2837
+ activateTab('chat');
2838
+ sendChat();
2839
+ });
2840
+ });
2841
+
2842
+ els.clearChatBtn.addEventListener('click', resetChat);
2843
+
2844
+ // ── /column slash picker ─────────────────────────────────────────────────────
2845
+ function showColumnPicker(filter = '') {
2846
+ const cols = state.columns.filter(c =>
2847
+ c.toLowerCase().includes(filter.toLowerCase())
2848
+ );
2849
+ const picker = els.colPickerDropdown;
2850
+
2851
+ if (!cols.length) { hideColumnPicker(); return; }
2852
+
2853
+ picker.innerHTML = `<div class="col-picker-header"><i data-lucide="hash" style="width: 12px; height: 12px; vertical-align: middle; margin-right: 4px;"></i> Insert Column — type to filter</div>` +
2854
+ cols.slice(0, 20).map(c => {
2855
+ const dtype = state.colTypes[c] || '';
2856
+ return `<div class="col-picker-item" data-col="${escHtml(c)}">
2857
+ ${escHtml(c)}
2858
+ ${dtype ? `<span class="col-picker-item-type">${escHtml(dtype)}</span>` : ''}
2859
+ </div>`;
2860
+ }).join('');
2861
+
2862
+ picker.querySelectorAll('.col-picker-item').forEach(item => {
2863
+ item.addEventListener('click', () => {
2864
+ const col = item.dataset.col;
2865
+ const textarea = els.chatInput;
2866
+ const val = textarea.value;
2867
+ // Replace the /... part with the column name
2868
+ const slashIdx = val.lastIndexOf('/');
2869
+ textarea.value = (slashIdx >= 0 ? val.slice(0, slashIdx) : val) + `\`${col}\` `;
2870
+ hideColumnPicker();
2871
+ textarea.focus();
2872
+ });
2873
+ });
2874
+
2875
+ picker.classList.remove('hidden');
2876
+ }
2877
+
2878
+ function hideColumnPicker() {
2879
+ els.colPickerDropdown.classList.add('hidden');
2880
+ }
2881
+
2882
+ els.chatInput.addEventListener('input', () => {
2883
+ const val = els.chatInput.value;
2884
+ const slashIdx = val.lastIndexOf('/');
2885
+ if (slashIdx >= 0 && slashIdx === val.length - 1) {
2886
+ // Just typed /
2887
+ showColumnPicker('');
2888
+ } else if (slashIdx >= 0 && slashIdx < val.length) {
2889
+ // Typing after /
2890
+ showColumnPicker(val.slice(slashIdx + 1));
2891
+ } else {
2892
+ hideColumnPicker();
2893
+ }
2894
+ });
2895
+
2896
+ document.addEventListener('click', e => {
2897
+ if (!els.colPickerDropdown.contains(e.target) && e.target !== els.chatInput) {
2898
+ hideColumnPicker();
2899
+ }
2900
+ });
2901
+
2902
+ // ────────────────────────────────────────────────────────────────────────────
2903
+ // Utilities
2904
+ // ────────────────────────────────────────────────────────────────────────────
2905
+ function escHtml(str) {
2906
+ return String(str ?? '')
2907
+ .replace(/&/g,'&amp;')
2908
+ .replace(/</g,'&lt;')
2909
+ .replace(/>/g,'&gt;')
2910
+ .replace(/"/g,'&quot;');
2911
+ }
2912
+
2913
+ // ────────────────────────────────────────────────────────────────────────────
2914
+ // Initialise
2915
+ // ────────────────────────────────────────────────────────────────────────────
2916
+ (async function init() {
2917
+ await loadLlmSettings();
2918
+ loadProjects();
2919
+ syncTaskCards();
2920
+ resetWizardState();
2921
+ showScreen('landing');
2922
+ setStatus('● Idle', 'idle');
2923
+
2924
+ if (window.lucide) {
2925
+ lucide.createIcons();
2926
+ }
2927
+
2928
+ // Wire Selection Hub Cards
2929
+ if (els.btnEnterChat) {
2930
+ els.btnEnterChat.addEventListener('click', () => {
2931
+ const p = state.activeProject;
2932
+ if (!p) return;
2933
+ if (els.btnSectionChat && els.btnSectionChat.parentNode) {
2934
+ els.btnSectionChat.parentNode.classList.remove('hidden');
2935
+ }
2936
+ if (els.areaHub) els.areaHub.classList.add('hidden');
2937
+ switchSection('chat');
2938
+ setBreadcrumb(p.name, 'AI Data Chat');
2939
+ });
2940
+ }
2941
+
2942
+ if (els.btnEnterAgentic) {
2943
+ els.btnEnterAgentic.addEventListener('click', () => {
2944
+ const p = state.activeProject;
2945
+ if (!p) return;
2946
+ if (p.status === 'running') {
2947
+ showScreen('running');
2948
+ return;
2949
+ }
2950
+ if (els.btnSectionChat && els.btnSectionChat.parentNode) {
2951
+ els.btnSectionChat.parentNode.classList.remove('hidden');
2952
+ }
2953
+ if (els.areaHub) els.areaHub.classList.add('hidden');
2954
+ switchSection('agentic');
2955
+ setBreadcrumb(p.name, 'Crew Analysis');
2956
+ });
2957
+ }
2958
+
2959
+ // Wire section switcher
2960
+ if (els.btnSectionChat) {
2961
+ els.btnSectionChat.addEventListener('click', () => {
2962
+ const p = state.activeProject;
2963
+ if (els.areaHub) els.areaHub.classList.add('hidden');
2964
+ switchSection('chat');
2965
+ if (p) setBreadcrumb(p.name, 'AI Data Chat');
2966
+ });
2967
+ }
2968
+ if (els.btnSectionAgentic) {
2969
+ els.btnSectionAgentic.addEventListener('click', () => {
2970
+ const p = state.activeProject;
2971
+ if (els.areaHub) els.areaHub.classList.add('hidden');
2972
+ switchSection('agentic');
2973
+ if (p) setBreadcrumb(p.name, 'Crew Analysis');
2974
+ });
2975
+ }
2976
+ if (els.chatBackBtn) {
2977
+ els.chatBackBtn.addEventListener('click', () => {
2978
+ const p = state.activeProject;
2979
+ if (els.areaHub) els.areaHub.classList.add('hidden');
2980
+ switchSection('agentic');
2981
+ if (p) setBreadcrumb(p.name, 'Crew Analysis');
2982
+ });
2983
+ }
2984
+
2985
+ // Wire quick action buttons
2986
+ if (els.btnRenameColQuick) {
2987
+ els.btnRenameColQuick.addEventListener('click', async () => {
2988
+ const oldName = await customPrompt('Select or enter the column you want to rename:', '', 'e.g. Q3_Sales', 'Rename Column');
2989
+ if (!oldName) return;
2990
+ if (!state.columns.includes(oldName)) {
2991
+ toast(`Column "${oldName}" not found in dataset.`, 'error');
2992
+ return;
2993
+ }
2994
+ const newName = await customPrompt(`Enter the new name for column "${oldName}":`, '', 'e.g. Sales_Q3', 'Rename Column');
2995
+ if (!newName) return;
2996
+
2997
+ // Command the copilot
2998
+ els.chatInput.value = `Rename column \`${oldName}\` to \`${newName}\` in the dataset`;
2999
+ sendChat();
3000
+ });
3001
+ }
3002
+
3003
+ if (els.btnDeleteColQuick) {
3004
+ els.btnDeleteColQuick.addEventListener('click', async () => {
3005
+ const colName = await customPrompt('Enter the name of the column you want to delete:', '', 'e.g. Unwanted_Col', 'Delete Column');
3006
+ if (!colName) return;
3007
+ if (!state.columns.includes(colName)) {
3008
+ toast(`Column "${colName}" not found in dataset.`, 'error');
3009
+ return;
3010
+ }
3011
+ const confirmed = await customConfirm(`Are you sure you want to permanently delete column "${colName}"?`, 'Delete Column');
3012
+ if (!confirmed) return;
3013
+
3014
+ // Command the copilot
3015
+ els.chatInput.value = `Delete column \`${colName}\` from the dataset`;
3016
+ sendChat();
3017
+ });
3018
+ }
3019
+
3020
+ // Wire agentic pipeline launch button
3021
+ if (els.btnRunAgenticPipeline) {
3022
+ els.btnRunAgenticPipeline.addEventListener('click', () => {
3023
+ state.uploadedSession = state.activeProject?.id;
3024
+ state.uploadedFile = { name: state.activeProject?.filename || 'dataset.csv' };
3025
+ if (!checkApiKeySet()) return;
3026
+ openConfigModal();
3027
+ });
3028
+ }
3029
+
3030
+ // Wire run in background and dismiss notification buttons
3031
+ const btnBg = $('btnRunInBackground');
3032
+ if (btnBg) {
3033
+ btnBg.addEventListener('click', () => {
3034
+ const p = state.activeProject;
3035
+ if (p) {
3036
+ goToWorkspaceHub();
3037
+ showScreen('results');
3038
+ toast('Running in background. Check notifications for progress.', 'info');
3039
+ }
3040
+ });
3041
+ }
3042
+
3043
+ const btnDismiss = $('dismissNotif');
3044
+ if (btnDismiss) {
3045
+ btnDismiss.addEventListener('click', (e) => {
3046
+ e.stopPropagation();
3047
+ const notif = $('analysisNotification');
3048
+ if (notif) notif.classList.add('hidden');
3049
+ });
3050
+ }
3051
+
3052
+ // Performance Metrics logic
3053
+ async function loadMetrics() {
3054
+ try {
3055
+ const res = await fetch('/api/metrics');
3056
+ if (!res.ok) throw new Error('Failed to fetch metrics');
3057
+ const data = await res.json();
3058
+
3059
+ const totalRuns = data.length;
3060
+ let totalTime = 0;
3061
+ let totalTokens = 0;
3062
+ let totalCost = 0;
3063
+
3064
+ data.forEach(run => {
3065
+ totalTime += run.total_time || 0;
3066
+ totalTokens += run.token_usage || 0;
3067
+ totalCost += run.estimated_cost || 0;
3068
+ });
3069
+
3070
+ const avgTime = totalRuns > 0 ? (totalTime / totalRuns).toFixed(1) : 0;
3071
+
3072
+ const runsEl = $('metricsTotalRuns');
3073
+ const timeEl = $('metricsAvgTime');
3074
+ const tokensEl = $('metricsTotalTokens');
3075
+ const costEl = $('metricsTotalCost');
3076
+
3077
+ if (runsEl) runsEl.textContent = totalRuns;
3078
+ if (timeEl) timeEl.textContent = `${avgTime}s`;
3079
+ if (tokensEl) tokensEl.textContent = totalTokens.toLocaleString();
3080
+ if (costEl) costEl.textContent = `$${totalCost.toFixed(3)}`;
3081
+
3082
+ const tbody = $('metricsTableBody');
3083
+ if (tbody) {
3084
+ tbody.innerHTML = '';
3085
+
3086
+ if (data.length === 0) {
3087
+ tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 20px; color: var(--text-secondary);">No run history recorded yet.</td></tr>`;
3088
+ return;
3089
+ }
3090
+
3091
+ const sorted = [...data].reverse();
3092
+ sorted.forEach(run => {
3093
+ const tr = document.createElement('tr');
3094
+ tr.style.borderBottom = '1px solid var(--border)';
3095
+
3096
+ const ts = new Date(run.timestamp).toLocaleString();
3097
+ const statusText = run.success ? '✓ Success' : '✗ Failed';
3098
+ const statusStyle = run.success ? 'color: var(--emerald); font-weight: 600;' : 'color: var(--rose); font-weight: 600;';
3099
+
3100
+ tr.innerHTML = `
3101
+ <td style="padding: 12px 20px; font-weight: 500;">${escHtml(run.dataset_name || 'dataset.csv')}</td>
3102
+ <td style="padding: 12px 20px; color: var(--text-secondary);">${run.rows || 0} x ${run.columns || 0}</td>
3103
+ <td style="padding: 12px 20px;">${(run.total_time || 0).toFixed(1)}s</td>
3104
+ <td style="padding: 12px 20px;">${(run.token_usage || 0).toLocaleString()}</td>
3105
+ <td style="padding: 12px 20px; color: var(--amber); font-weight: 500;">$${(run.estimated_cost || 0).toFixed(3)}</td>
3106
+ <td style="padding: 12px 20px; color: var(--text-secondary);">${ts}</td>
3107
+ <td style="padding: 12px 20px; ${statusStyle}">${statusText}</td>
3108
+ `;
3109
+ tbody.appendChild(tr);
3110
+ });
3111
+ }
3112
+ } catch (err) {
3113
+ console.error(err);
3114
+ toast('Failed to load performance metrics', 'error');
3115
+ }
3116
+ }
3117
+
3118
+ if (els.sidebarMetricsBtn) {
3119
+ els.sidebarMetricsBtn.addEventListener('click', () => {
3120
+ showScreen('metrics');
3121
+ loadMetrics();
3122
+ });
3123
+ }
3124
+
3125
+ if (els.backToDashboardBtn) {
3126
+ els.backToDashboardBtn.addEventListener('click', () => {
3127
+ if (state.activeProject) {
3128
+ showScreen('results');
3129
+ } else {
3130
+ showScreen('landing');
3131
+ }
3132
+ });
3133
+ }
3134
+
3135
+ // Check if a running session exists on page load
3136
+ // (handles F5 refresh during analysis)
3137
+ setTimeout(async () => {
3138
+ const projects = state.projects;
3139
+ const running = projects.find(p => p.status === 'running');
3140
+ if (running) switchToProject(running);
3141
+ }, 500);
3142
+ })();