@pheem49/mint 1.5.2 → 1.5.3

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.
@@ -6,14 +6,35 @@ const maximizeBtn = document.getElementById('maximize-btn');
6
6
  const minimizeBtn = document.getElementById('minimize-btn');
7
7
  const clearBtn = document.getElementById('clear-btn');
8
8
  const settingsBtn = document.getElementById('settings-btn');
9
+ const sidebarNewChatBtn = document.getElementById('sidebar-new-chat');
10
+ const sidebarSettingsBtn = document.getElementById('sidebar-settings');
11
+ const sidebarToggleBtn = document.getElementById('sidebar-toggle');
12
+ const appBody = document.querySelector('.app-body');
13
+ const sidebarChatBtn = document.getElementById('sidebar-chat-btn');
14
+ const sidebarPicturesBtn = document.getElementById('sidebar-pictures-btn');
15
+ const picturesLibrary = document.getElementById('pictures-library');
16
+ const picturesGrid = document.getElementById('pictures-grid');
17
+ const picturesEmpty = document.getElementById('pictures-empty');
18
+ const picturesCloseBtn = document.getElementById('pictures-close-btn');
9
19
  const micBtn = document.getElementById('mic-btn');
10
20
  const visionBtn = document.getElementById('vision-btn');
21
+ const chatProviderSelect = document.getElementById('chat-provider-select');
11
22
  const imagePreviewContainer = document.getElementById('image-preview-container');
12
23
  const imagePreview = document.getElementById('image-preview');
13
24
  const removeImageBtn = document.getElementById('remove-image-btn');
14
25
  const modelMount = document.getElementById('model-mount');
15
26
  const modelShell = document.getElementById('model-shell');
16
27
  const modelStatus = document.getElementById('model-status');
28
+ const mintStatus = document.getElementById('mint-status');
29
+ const mintStatusLabel = document.getElementById('mint-status-label');
30
+ const modelActivityBadge = document.getElementById('model-activity-badge');
31
+ const startupLoading = document.getElementById('startup-loading');
32
+ const appContainer = document.querySelector('.app-container');
33
+
34
+ if (startupLoading) {
35
+ startupLoading.style.background = 'var(--bg-gradient)';
36
+ startupLoading.style.color = 'var(--text-muted)';
37
+ }
17
38
 
18
39
  // Proactive Assistant elements
19
40
  const proactiveBar = document.getElementById('proactive-bar');
@@ -28,6 +49,86 @@ let ttsVolume = 1.0;
28
49
  let ttsSpeed = 1.0;
29
50
  let ttsPitch = 1.0;
30
51
  let lastConversationLanguage = 'auto';
52
+ let mintActivityResetTimer = null;
53
+ let currentSettings = {};
54
+
55
+ const PROVIDER_PICKER_OPTIONS = [
56
+ ['gemini', 'Gemini'],
57
+ ['anthropic', 'Claude'],
58
+ ['openai', 'OpenAI'],
59
+ ['ollama', 'Ollama'],
60
+ ['huggingface', 'Hugging Face'],
61
+ ['local_openai', 'Local']
62
+ ];
63
+
64
+ function buildProviderPicker(settings = currentSettings) {
65
+ if (!chatProviderSelect) return;
66
+ chatProviderSelect.textContent = '';
67
+ PROVIDER_PICKER_OPTIONS.forEach(([value, label]) => {
68
+ const option = document.createElement('option');
69
+ option.value = value;
70
+ option.textContent = label;
71
+ chatProviderSelect.appendChild(option);
72
+ });
73
+ chatProviderSelect.value = settings.aiProvider || 'gemini';
74
+ }
75
+
76
+ async function changeChatProvider(provider) {
77
+ if (!PROVIDER_PICKER_OPTIONS.some(([value]) => value === provider)) return;
78
+ const nextSettings = { ...currentSettings, aiProvider: provider };
79
+ chatProviderSelect.disabled = true;
80
+ try {
81
+ const result = await window.api.saveSettings(nextSettings);
82
+ if (!result || result.success !== false) {
83
+ currentSettings = nextSettings;
84
+ buildProviderPicker(currentSettings);
85
+ } else {
86
+ throw new Error(result.message || 'Unable to save provider setting');
87
+ }
88
+ } catch (error) {
89
+ console.error('Failed to change provider:', error);
90
+ buildProviderPicker(currentSettings);
91
+ setMintActivity('error');
92
+ } finally {
93
+ chatProviderSelect.disabled = false;
94
+ }
95
+ }
96
+
97
+ const MINT_ACTIVITY_STATES = {
98
+ idle: { label: 'Idle', title: 'Mint is idle' },
99
+ listening: { label: 'Listening', title: 'Mint is listening' },
100
+ thinking: { label: 'Thinking', title: 'Mint is thinking' },
101
+ speaking: { label: 'Speaking', title: 'Mint is speaking' },
102
+ error: { label: 'Error', title: 'Mint needs attention' }
103
+ };
104
+
105
+ function setMintActivity(state, options = {}) {
106
+ const normalizedState = MINT_ACTIVITY_STATES[state] ? state : 'idle';
107
+ const meta = MINT_ACTIVITY_STATES[normalizedState];
108
+ if (mintActivityResetTimer) {
109
+ clearTimeout(mintActivityResetTimer);
110
+ mintActivityResetTimer = null;
111
+ }
112
+
113
+ [mintStatus, modelActivityBadge].forEach((element) => {
114
+ if (!element) return;
115
+ element.dataset.state = normalizedState;
116
+ element.title = meta.title;
117
+ const label = element.querySelector('.mint-status-label');
118
+ if (label) label.textContent = meta.label;
119
+ });
120
+ if (mintStatusLabel) mintStatusLabel.textContent = meta.label;
121
+
122
+ if (window.api && window.api.setAiState) {
123
+ window.api.setAiState(normalizedState);
124
+ }
125
+
126
+ if (normalizedState === 'error' || options.resetAfter) {
127
+ mintActivityResetTimer = setTimeout(() => {
128
+ setMintActivity('idle');
129
+ }, options.resetAfter || 3500);
130
+ }
131
+ }
31
132
 
32
133
  function detectConversationLanguage(text) {
33
134
  const value = String(text || '');
@@ -56,8 +157,11 @@ function buildInteractionLanguageInstruction() {
56
157
  // --- Theme Loading ---
57
158
  function applyTheme(theme, accentColor, systemTextColor, config = {}) {
58
159
  document.documentElement.setAttribute('data-theme', theme || 'dark');
59
- const accent = accentColor || '#8b5cf6';
60
- const textColor = systemTextColor || '#f8fafc';
160
+ const accent = accentColor || '#8f6cf5';
161
+ const defaultTextColor = theme === 'light' ? '#0f172a' : '#e8e8ea';
162
+ const textColor = (!systemTextColor || (theme === 'light' && systemTextColor === '#f8fafc'))
163
+ ? defaultTextColor
164
+ : systemTextColor;
61
165
  document.documentElement.style.setProperty('--accent', accent);
62
166
  document.documentElement.style.setProperty('--accent-hover', lightenColor(accent, 20));
63
167
  document.documentElement.style.setProperty('--text-main', textColor);
@@ -65,19 +169,36 @@ function applyTheme(theme, accentColor, systemTextColor, config = {}) {
65
169
  // Dynamic UI Customizations
66
170
  document.documentElement.style.setProperty('--glass-blur', config.glassBlur || 'blur(16px)');
67
171
  document.body.style.fontFamily = config.fontFamily || "'Outfit', sans-serif";
172
+ document.documentElement.style.fontSize = config.fontSize || '15px';
68
173
 
69
174
  if (theme === 'custom') {
70
175
  if (config.customBgStart && config.customBgEnd) {
71
176
  const gradient = `linear-gradient(135deg, ${config.customBgStart} 0%, ${config.customBgEnd} 100%)`;
177
+ document.documentElement.style.setProperty('--bg-color', config.customBgStart);
72
178
  document.documentElement.style.setProperty('--bg-gradient', gradient);
73
179
  }
74
180
  if (config.customPanelBg) {
75
181
  const rgb = hexToRgb(config.customPanelBg);
76
182
  document.documentElement.style.setProperty('--panel-bg', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.75)`);
183
+ document.documentElement.style.setProperty('--panel-raised', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.82)`);
184
+ document.documentElement.style.setProperty('--panel-soft', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.46)`);
185
+ document.documentElement.style.setProperty('--chrome-bg', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.88)`);
186
+ document.documentElement.style.setProperty('--surface-bg', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.62)`);
187
+ document.documentElement.style.setProperty('--surface-strong', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.86)`);
188
+ document.documentElement.style.setProperty('--input-bg', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.72)`);
77
189
  }
78
190
  } else {
79
- document.documentElement.style.removeProperty('--bg-gradient');
80
- document.documentElement.style.removeProperty('--panel-bg');
191
+ [
192
+ '--bg-color',
193
+ '--bg-gradient',
194
+ '--panel-bg',
195
+ '--panel-raised',
196
+ '--panel-soft',
197
+ '--chrome-bg',
198
+ '--surface-bg',
199
+ '--surface-strong',
200
+ '--input-bg'
201
+ ].forEach(name => document.documentElement.style.removeProperty(name));
81
202
  }
82
203
  }
83
204
 
@@ -93,14 +214,17 @@ function hexToRgb(hex) {
93
214
  async function loadTheme() {
94
215
  try {
95
216
  const config = await window.api.getSettings();
217
+ currentSettings = config || {};
96
218
  applyTheme(config.theme, config.accentColor, config.systemTextColor, config);
97
219
  enableVoiceReply = config.enableVoiceReply !== false;
98
220
  ttsProvider = config.ttsProvider || 'google';
99
221
  ttsVolume = config.ttsVolume !== undefined ? config.ttsVolume : 1.0;
100
222
  ttsSpeed = config.ttsSpeed !== undefined ? config.ttsSpeed : 1.0;
101
223
  ttsPitch = config.ttsPitch !== undefined ? config.ttsPitch : 1.0;
224
+ buildProviderPicker(currentSettings);
102
225
  } catch (e) {
103
226
  applyTheme('dark', '#8b5cf6', '#f8fafc');
227
+ buildProviderPicker(currentSettings);
104
228
  }
105
229
  }
106
230
 
@@ -116,12 +240,18 @@ function lightenColor(hex, amount) {
116
240
 
117
241
  // 🔔 Real-time theme sync from Settings window
118
242
  window.api.onSettingsChanged((config) => {
243
+ currentSettings = config || currentSettings;
119
244
  applyTheme(config.theme, config.accentColor, config.systemTextColor, config);
120
245
  enableVoiceReply = config.enableVoiceReply !== false;
121
246
  ttsProvider = config.ttsProvider || 'google';
122
247
  ttsVolume = config.ttsVolume !== undefined ? config.ttsVolume : 1.0;
123
248
  ttsSpeed = config.ttsSpeed !== undefined ? config.ttsSpeed : 1.0;
124
249
  ttsPitch = config.ttsPitch !== undefined ? config.ttsPitch : 1.0;
250
+ buildProviderPicker(currentSettings);
251
+ });
252
+
253
+ chatProviderSelect?.addEventListener('change', (event) => {
254
+ changeChatProvider(event.target.value);
125
255
  });
126
256
 
127
257
  // --- Voice Input Setup ---
@@ -190,6 +320,7 @@ function setupSpeechRecognition() {
190
320
  speechRecognition.onstart = () => {
191
321
  micBtn.classList.add('listening');
192
322
  chatInput.placeholder = "Listening... (Click to stop)";
323
+ setMintActivity('listening');
193
324
  speechHadResult = false;
194
325
  if (speechFallbackTimer) clearTimeout(speechFallbackTimer);
195
326
  speechFallbackTimer = setTimeout(() => {
@@ -228,6 +359,7 @@ function setupSpeechRecognition() {
228
359
 
229
360
  speechRecognition.onerror = (err) => {
230
361
  console.error("Speech recognition error:", err);
362
+ setMintActivity('error');
231
363
  fallbackToMediaRecorder();
232
364
  isSpeechStreaming = false;
233
365
  resetMicUI();
@@ -300,10 +432,12 @@ async function setupMediaRecorder() {
300
432
  mediaRecorder.onstart = () => {
301
433
  micBtn.classList.add('listening');
302
434
  chatInput.placeholder = "Listening... (Click to stop)";
435
+ setMintActivity('listening');
303
436
  };
304
437
 
305
438
  } catch (err) {
306
439
  console.error("Microphone access error:", err);
440
+ setMintActivity('error');
307
441
  micBtn.style.display = 'none';
308
442
  appendMessage("❌ ไม่สามารถเข้าถึงไมโครโฟนได้ค่ะ กรุณาตรวจสอบการตั้งค่าระดับระบบ", 'ai');
309
443
  }
@@ -312,11 +446,15 @@ async function setupMediaRecorder() {
312
446
  function resetMicUI() {
313
447
  micBtn.classList.remove('listening');
314
448
  chatInput.placeholder = DEFAULT_PLACEHOLDER;
449
+ if (voiceMode !== 'speech' && (!mediaRecorder || mediaRecorder.state === 'inactive')) {
450
+ setMintActivity('idle');
451
+ }
315
452
  }
316
453
 
317
454
  async function sendVoiceMessage(base64Audio) {
318
455
  showTyping();
319
456
  chatInput.placeholder = "Processing voice...";
457
+ setMintActivity('thinking');
320
458
  try {
321
459
  // Send empty text, but include the audio
322
460
  const response = await window.api.sendMessage("", null, base64Audio);
@@ -335,6 +473,7 @@ async function sendVoiceMessage(base64Audio) {
335
473
  }
336
474
  } catch (error) {
337
475
  removeTyping();
476
+ setMintActivity('error');
338
477
  appendMessage("ขออภัยค่ะ เกิดข้อผิดพลาดในการประมวลผลเสียง", 'ai');
339
478
  console.error(error);
340
479
  resumeSpeechIfNeeded();
@@ -373,10 +512,10 @@ micBtn.addEventListener('click', (e) => {
373
512
  if (mediaRecorder.state === 'inactive') {
374
513
  audioChunks = [];
375
514
  mediaRecorder.start();
376
- if (window.api && window.api.setAiState) window.api.setAiState('listening');
515
+ setMintActivity('listening');
377
516
  } else {
378
517
  mediaRecorder.stop();
379
- if (window.api && window.api.setAiState) window.api.setAiState('thinking');
518
+ setMintActivity('thinking');
380
519
  voiceMode = null;
381
520
  }
382
521
  return;
@@ -408,10 +547,10 @@ micBtn.addEventListener('click', (e) => {
408
547
  if (mediaRecorder.state === 'inactive') {
409
548
  audioChunks = [];
410
549
  mediaRecorder.start();
411
- if (window.api && window.api.setAiState) window.api.setAiState('listening');
550
+ setMintActivity('listening');
412
551
  } else {
413
552
  mediaRecorder.stop();
414
- if (window.api && window.api.setAiState) window.api.setAiState('thinking');
553
+ setMintActivity('thinking');
415
554
  }
416
555
  });
417
556
 
@@ -419,7 +558,7 @@ micBtn.addEventListener('click', (e) => {
419
558
  let currentAudioPlayer = null;
420
559
 
421
560
  function speakText(text, options = {}) {
422
- if (window.api && window.api.setAiState) window.api.setAiState('speaking');
561
+ setMintActivity('speaking');
423
562
  const onEnd = typeof options.onEnd === 'function' ? options.onEnd : () => {};
424
563
 
425
564
  const wrappedOnEnd = () => {
@@ -429,7 +568,7 @@ function speakText(text, options = {}) {
429
568
 
430
569
  return new Promise(async (resolve) => {
431
570
  if (!enableVoiceReply) {
432
- if (window.api && window.api.setAiState) window.api.setAiState('idle');
571
+ setMintActivity('idle');
433
572
  wrappedOnEnd();
434
573
  return resolve();
435
574
  }
@@ -447,7 +586,7 @@ function speakText(text, options = {}) {
447
586
  }
448
587
 
449
588
  if (!text || !text.trim()) {
450
- if (window.api && window.api.setAiState) window.api.setAiState('idle');
589
+ setMintActivity('idle');
451
590
  wrappedOnEnd();
452
591
  return resolve();
453
592
  }
@@ -461,7 +600,7 @@ function speakText(text, options = {}) {
461
600
  let i = 0;
462
601
  const playNext = () => {
463
602
  if (i >= urls.length) {
464
- if (window.api && window.api.setAiState) window.api.setAiState('idle');
603
+ setMintActivity('idle');
465
604
  wrappedOnEnd();
466
605
  return resolve();
467
606
  }
@@ -499,6 +638,7 @@ function speakText(text, options = {}) {
499
638
 
500
639
  function fallbackSpeak(text, onEnd, resolve) {
501
640
  if (!('speechSynthesis' in window)) {
641
+ setMintActivity('idle');
502
642
  if (onEnd) onEnd();
503
643
  resolve();
504
644
  return;
@@ -515,7 +655,7 @@ function fallbackSpeak(text, onEnd, resolve) {
515
655
  const done = () => {
516
656
  if (finished) return;
517
657
  finished = true;
518
- if (window.api && window.api.setAiState) window.api.setAiState('idle');
658
+ setMintActivity('idle');
519
659
  if (onEnd) onEnd();
520
660
  resolve();
521
661
  };
@@ -540,9 +680,82 @@ maximizeBtn.addEventListener('click', () => {
540
680
  });
541
681
 
542
682
  // Settings button
543
- settingsBtn.addEventListener('click', () => {
683
+ function openSettings() {
544
684
  window.api.openSettings();
545
- });
685
+ }
686
+
687
+ settingsBtn.addEventListener('click', openSettings);
688
+ sidebarSettingsBtn?.addEventListener('click', openSettings);
689
+
690
+ async function renderPicturesLibrary() {
691
+ if (!picturesGrid || !picturesEmpty) return;
692
+ picturesGrid.innerHTML = '';
693
+
694
+ const pictures = await window.api.listSavedPictures();
695
+ picturesEmpty.classList.toggle('is-hidden', pictures.length > 0);
696
+
697
+ for (const picture of pictures) {
698
+ const card = document.createElement('article');
699
+ card.className = 'picture-card';
700
+
701
+ const img = document.createElement('img');
702
+ img.src = picture.url;
703
+ img.alt = picture.filename || 'Saved picture';
704
+ img.loading = 'lazy';
705
+
706
+ const meta = document.createElement('div');
707
+ meta.className = 'picture-card-meta';
708
+ const date = picture.createdAt ? new Date(picture.createdAt).toLocaleString() : '';
709
+ meta.textContent = picture.message || date || picture.filename || 'Saved picture';
710
+ meta.title = [picture.filename, picture.message, date].filter(Boolean).join('\n');
711
+
712
+ card.appendChild(img);
713
+ card.appendChild(meta);
714
+ picturesGrid.appendChild(card);
715
+ }
716
+ }
717
+
718
+ async function openPicturesLibrary() {
719
+ if (!appBody || !picturesLibrary) return;
720
+ picturesLibrary.hidden = false;
721
+ requestAnimationFrame(() => {
722
+ appBody.classList.add('pictures-open');
723
+ });
724
+ sidebarChatBtn?.classList.remove('is-active');
725
+ sidebarPicturesBtn?.classList.add('is-active');
726
+ await renderPicturesLibrary();
727
+ }
728
+
729
+ function closePicturesLibrary() {
730
+ if (!appBody || !picturesLibrary) return;
731
+ appBody.classList.remove('pictures-open');
732
+ setTimeout(() => {
733
+ if (!appBody.classList.contains('pictures-open')) {
734
+ picturesLibrary.hidden = true;
735
+ }
736
+ }, 240);
737
+ sidebarChatBtn?.classList.add('is-active');
738
+ sidebarPicturesBtn?.classList.remove('is-active');
739
+ }
740
+
741
+ sidebarChatBtn?.addEventListener('click', closePicturesLibrary);
742
+ sidebarPicturesBtn?.addEventListener('click', openPicturesLibrary);
743
+ picturesCloseBtn?.addEventListener('click', closePicturesLibrary);
744
+
745
+ function setSidebarCollapsed(isCollapsed) {
746
+ if (!appBody || !sidebarToggleBtn) return;
747
+ appBody.classList.toggle('sidebar-collapsed', isCollapsed);
748
+ sidebarToggleBtn.setAttribute('aria-expanded', String(!isCollapsed));
749
+ sidebarToggleBtn.setAttribute('aria-label', isCollapsed ? 'Expand sidebar' : 'Collapse sidebar');
750
+ sidebarToggleBtn.setAttribute('title', isCollapsed ? 'Expand sidebar' : 'Collapse sidebar');
751
+ }
752
+
753
+ if (appBody && sidebarToggleBtn) {
754
+ setSidebarCollapsed(true);
755
+ sidebarToggleBtn.addEventListener('click', () => {
756
+ setSidebarCollapsed(!appBody.classList.contains('sidebar-collapsed'));
757
+ });
758
+ }
546
759
 
547
760
  // Throttle utility to prevent UI spam
548
761
  function throttle(func, limit) {
@@ -587,14 +800,21 @@ function formatTime(isoString) {
587
800
  }
588
801
 
589
802
  // Clear chat history
590
- clearBtn.addEventListener('click', async () => {
803
+ async function clearChatHistory(confirmMessage = 'Clear current chat history?') {
804
+ const shouldClear = window.confirm(confirmMessage);
805
+ if (!shouldClear) return;
806
+
807
+ closePicturesLibrary();
591
808
  await window.api.resetChat();
592
809
  // Remove all messages except the initial greeting
593
810
  const messages = chatContainer.querySelectorAll('.message:not(.initial)');
594
811
  messages.forEach(m => m.remove());
595
812
  // Append a clear confirmation
596
813
  appendMessage('Chat history cleared. Starting fresh! 🌿', 'ai', null, new Date().toISOString());
597
- });
814
+ }
815
+
816
+ clearBtn.addEventListener('click', () => clearChatHistory('Clear current chat history?'));
817
+ sidebarNewChatBtn?.addEventListener('click', () => clearChatHistory('Start a new chat and clear current history?'));
598
818
 
599
819
  function formatProviderInfo(providerInfo) {
600
820
  if (!providerInfo || typeof providerInfo !== 'object') return '';
@@ -604,6 +824,85 @@ function formatProviderInfo(providerInfo) {
604
824
  return model ? `${provider || 'AI'} • ${model}` : provider;
605
825
  }
606
826
 
827
+ function formatNumber(value) {
828
+ const number = Number(value) || 0;
829
+ return number.toLocaleString('en-US');
830
+ }
831
+
832
+ function summarizeProviderUsage(providerInfo) {
833
+ const usage = Array.isArray(providerInfo?.usage) ? providerInfo.usage : [];
834
+ const selectedProvider = String(providerInfo?.provider || '').trim();
835
+ const selectedModel = String(providerInfo?.model || '').trim();
836
+ const row = usage.find(item =>
837
+ String(item.provider || '') === selectedProvider &&
838
+ String(item.model || '') === selectedModel
839
+ ) || usage[0] || {};
840
+
841
+ return {
842
+ requests: Number(row.requests) || 0,
843
+ inputTokens: Number(row.inputTokens) || 0,
844
+ outputTokens: Number(row.outputTokens) || 0,
845
+ reasoningTokens: Number(row.reasoningTokens) || 0,
846
+ cacheReads: Number(row.cacheReads) || 0,
847
+ totalTokens: Number(row.totalTokens) || 0
848
+ };
849
+ }
850
+
851
+ function closeProviderPopover() {
852
+ document.querySelectorAll('.provider-popover').forEach(popover => popover.remove());
853
+ document.querySelectorAll('.provider-badge.is-open').forEach(badge => badge.classList.remove('is-open'));
854
+ }
855
+
856
+ function createProviderRow(label, value) {
857
+ const row = document.createElement('div');
858
+ row.className = 'provider-popover-row';
859
+ const labelEl = document.createElement('span');
860
+ labelEl.textContent = label;
861
+ const valueEl = document.createElement('strong');
862
+ valueEl.textContent = value;
863
+ row.appendChild(labelEl);
864
+ row.appendChild(valueEl);
865
+ return row;
866
+ }
867
+
868
+ function showProviderPopover(anchor, providerInfo) {
869
+ closeProviderPopover();
870
+ anchor.classList.add('is-open');
871
+
872
+ const provider = String(providerInfo?.provider || 'AI').trim();
873
+ const model = String(providerInfo?.model || 'Unknown model').trim();
874
+ const usage = summarizeProviderUsage(providerInfo);
875
+ const popover = document.createElement('div');
876
+ popover.className = 'provider-popover';
877
+
878
+ const title = document.createElement('div');
879
+ title.className = 'provider-popover-title';
880
+ title.textContent = 'Model details';
881
+ popover.appendChild(title);
882
+
883
+ popover.appendChild(createProviderRow('Provider', provider));
884
+ popover.appendChild(createProviderRow('Model', model));
885
+ popover.appendChild(createProviderRow('Context tokens', formatNumber(usage.inputTokens)));
886
+ popover.appendChild(createProviderRow('Output tokens', formatNumber(usage.outputTokens)));
887
+ if (usage.reasoningTokens) {
888
+ popover.appendChild(createProviderRow('Reasoning tokens', formatNumber(usage.reasoningTokens)));
889
+ }
890
+ popover.appendChild(createProviderRow('Total tokens', formatNumber(usage.totalTokens)));
891
+
892
+ const action = document.createElement('button');
893
+ action.type = 'button';
894
+ action.className = 'provider-popover-action';
895
+ action.textContent = 'Change model in Settings';
896
+ action.addEventListener('click', (event) => {
897
+ event.stopPropagation();
898
+ closeProviderPopover();
899
+ if (window.api?.openSettings) window.api.openSettings();
900
+ });
901
+ popover.appendChild(action);
902
+
903
+ anchor.after(popover);
904
+ }
905
+
607
906
  function splitListOutro(text) {
608
907
  const value = String(text || '').trim();
609
908
  const markers = [
@@ -726,10 +1025,20 @@ function appendMessage(text, sender, base64Image = null, timestamp = null, optio
726
1025
  const timeDiv = document.createElement('div');
727
1026
  timeDiv.classList.add('message-time');
728
1027
  if (providerLabel) {
729
- const providerSpan = document.createElement('span');
730
- providerSpan.classList.add('provider-badge');
731
- providerSpan.textContent = providerLabel;
732
- timeDiv.appendChild(providerSpan);
1028
+ const providerButton = document.createElement('button');
1029
+ providerButton.type = 'button';
1030
+ providerButton.classList.add('provider-badge');
1031
+ providerButton.textContent = providerLabel;
1032
+ providerButton.title = 'View model details';
1033
+ providerButton.addEventListener('click', (event) => {
1034
+ event.stopPropagation();
1035
+ if (providerButton.classList.contains('is-open')) {
1036
+ closeProviderPopover();
1037
+ return;
1038
+ }
1039
+ showProviderPopover(providerButton, options.providerInfo);
1040
+ });
1041
+ timeDiv.appendChild(providerButton);
733
1042
  }
734
1043
  if (timestamp) {
735
1044
  const timeSpan = document.createElement('span');
@@ -898,8 +1207,18 @@ function loadScript(src) {
898
1207
  });
899
1208
  }
900
1209
 
1210
+ function hideStartupLoading() {
1211
+ appContainer?.classList.remove('is-loading');
1212
+ if (!startupLoading) return;
1213
+ startupLoading.classList.add('is-hidden');
1214
+ setTimeout(() => startupLoading.remove(), 400);
1215
+ }
1216
+
901
1217
  async function loadLive2DWhenIdle() {
902
- if (!modelMount || window.Live2DManager) return;
1218
+ if (!modelMount || window.Live2DManager) {
1219
+ hideStartupLoading();
1220
+ return;
1221
+ }
903
1222
  try {
904
1223
  await loadScript('../../node_modules/@hazart-pkg/live2d-core/live2dcubismcore.min.js');
905
1224
  await loadScript('../../node_modules/pixi.js/dist/browser/pixi.min.js');
@@ -907,6 +1226,7 @@ async function loadLive2DWhenIdle() {
907
1226
  await loadScript('live2d_manager.js');
908
1227
  if (window.Live2DManager) {
909
1228
  await Live2DManager.loadModel(modelMount, modelStatus, modelShell);
1229
+ applyModelPanelControlState();
910
1230
  }
911
1231
  } catch (err) {
912
1232
  console.error('[Live2D] Deferred load failed:', err);
@@ -914,6 +1234,8 @@ async function loadLive2DWhenIdle() {
914
1234
  modelStatus.classList.add('is-error');
915
1235
  modelStatus.textContent = 'Live2D model unavailable.';
916
1236
  }
1237
+ } finally {
1238
+ hideStartupLoading();
917
1239
  }
918
1240
  }
919
1241
 
@@ -980,6 +1302,7 @@ async function sendTextMessage(text, options = {}) {
980
1302
 
981
1303
  // Show typing early so user knows we are processing
982
1304
  showTyping();
1305
+ setMintActivity('thinking');
983
1306
 
984
1307
  // Check Smart Context Toggle
985
1308
  const smartToggle = document.getElementById('smart-context-toggle');
@@ -1044,6 +1367,7 @@ async function sendTextMessage(text, options = {}) {
1044
1367
  }
1045
1368
  } catch (error) {
1046
1369
  removeTyping();
1370
+ setMintActivity('error');
1047
1371
  appendMessage("Sorry, I encountered an error communicating with the main process.", 'ai');
1048
1372
  console.error(error);
1049
1373
  resumeSpeechIfNeeded();
@@ -1052,7 +1376,6 @@ async function sendTextMessage(text, options = {}) {
1052
1376
 
1053
1377
  chatForm.addEventListener('submit', throttle(async (e) => {
1054
1378
  e.preventDefault();
1055
- if (window.api && window.api.setAiState) window.api.setAiState('thinking');
1056
1379
  const text = chatInput.value.trim();
1057
1380
  await sendTextMessage(text);
1058
1381
  }, 500));
@@ -1060,7 +1383,7 @@ chatForm.addEventListener('submit', throttle(async (e) => {
1060
1383
  window.addEventListener('live2d-model-interaction', async (event) => {
1061
1384
  const prompt = event?.detail?.prompt;
1062
1385
  if (!prompt) return;
1063
- if (window.api && window.api.setAiState) window.api.setAiState('thinking');
1386
+ setMintActivity('thinking');
1064
1387
  const interactionPrompt = `${prompt}\n\n${buildInteractionLanguageInstruction()}`;
1065
1388
  const displayPrefix = lastConversationLanguage === 'thai' ? 'แตะโมเดล' : 'Model interaction';
1066
1389
  await sendTextMessage(interactionPrompt, {
@@ -1126,9 +1449,9 @@ inputArea.addEventListener('drop', (e) => {
1126
1449
  window.addEventListener('DOMContentLoaded', async () => {
1127
1450
  chatInput.focus();
1128
1451
  await loadTheme();
1452
+ setMintActivity('idle');
1129
1453
  await loadChatHistory();
1130
- const scheduleLive2DLoad = window.requestIdleCallback || ((callback) => setTimeout(callback, 750));
1131
- scheduleLive2DLoad(() => loadLive2DWhenIdle());
1454
+ loadLive2DWhenIdle();
1132
1455
  });
1133
1456
 
1134
1457
  // Proactive OS Notifications (Battery, Network, etc.)
@@ -1143,6 +1466,11 @@ window.addEventListener('focus', () => {
1143
1466
  if (window.api.clearAiNotifications) window.api.clearAiNotifications();
1144
1467
  });
1145
1468
 
1469
+ document.addEventListener('click', closeProviderPopover);
1470
+ document.addEventListener('keydown', (event) => {
1471
+ if (event.key === 'Escape') closeProviderPopover();
1472
+ });
1473
+
1146
1474
  // =====================
1147
1475
  // Proactive Smart Suggestion Engine
1148
1476
  // =====================
@@ -1224,34 +1552,121 @@ if (smartContextToggle) {
1224
1552
  // Toggle Live2D Model visibility
1225
1553
  const toggleModelBtn = document.getElementById('toggle-model-btn');
1226
1554
  const assistantWorkspace = document.querySelector('.assistant-workspace');
1555
+ const modelLockBtn = document.getElementById('model-lock-btn');
1556
+ const modelScaleSlider = document.getElementById('model-scale-slider');
1557
+ const modelScaleValue = document.getElementById('model-scale-value');
1558
+ const modelScaleResetBtn = document.getElementById('model-scale-reset-btn');
1559
+ const modelBgBtn = document.getElementById('model-bg-btn');
1560
+ const layoutPresetBtns = document.querySelectorAll('.layout-preset-btn');
1561
+
1562
+ const modelBgStorageKey = 'mint-model-background';
1563
+ const modelScaleStorageKey = 'mint-model-scale';
1564
+ const modelPositionLockStorageKey = 'mint-model-position-locked';
1565
+ const workspaceLayoutStorageKey = 'mint-workspace-layout';
1566
+ const modelBgClasses = ['model-bg-default', 'model-bg-clear', 'model-bg-grid', 'model-bg-stage'];
1567
+ const modelBgLabels = ['Default background', 'Clear background', 'Grid background', 'Stage background'];
1568
+ const workspaceLayoutClasses = ['layout-chat'];
1569
+ const workspaceLayoutPresets = ['companion', 'chat'];
1570
+
1571
+ function setModelHidden(isHidden) {
1572
+ if (!assistantWorkspace || !toggleModelBtn) return;
1573
+ assistantWorkspace.classList.toggle('model-hidden', Boolean(isHidden));
1574
+ toggleModelBtn.classList.toggle('active', Boolean(isHidden));
1575
+ toggleModelBtn.setAttribute('aria-pressed', String(Boolean(isHidden)));
1576
+ localStorage.setItem('mint-model-hidden', String(Boolean(isHidden)));
1577
+
1578
+ if (!isHidden && window.Live2DManager && Live2DManager.model) {
1579
+ setTimeout(() => {
1580
+ window.dispatchEvent(new Event('resize'));
1581
+ if (typeof Live2DManager.fitModelToMount === 'function') {
1582
+ Live2DManager.fitModelToMount();
1583
+ }
1584
+ }, 450);
1585
+ }
1586
+ }
1587
+
1588
+ function setModelPositionLocked(isLocked) {
1589
+ const locked = Boolean(isLocked);
1590
+ localStorage.setItem(modelPositionLockStorageKey, String(locked));
1591
+ modelLockBtn?.classList.toggle('is-active', locked);
1592
+ modelLockBtn?.setAttribute('aria-pressed', String(locked));
1593
+ modelLockBtn?.setAttribute('title', locked ? 'Unlock model position' : 'Lock model position');
1594
+ if (window.Live2DManager) {
1595
+ Live2DManager.setPointerTrackingEnabled(!locked);
1596
+ }
1597
+ }
1598
+
1599
+ function setModelBackground(index) {
1600
+ if (!modelShell) return;
1601
+ const normalized = ((Number(index) || 0) + modelBgClasses.length) % modelBgClasses.length;
1602
+ modelBgClasses.forEach(className => modelShell.classList.remove(className));
1603
+ if (normalized > 0) {
1604
+ modelShell.classList.add(modelBgClasses[normalized]);
1605
+ }
1606
+ localStorage.setItem(modelBgStorageKey, String(normalized));
1607
+ modelBgBtn?.setAttribute('title', modelBgLabels[normalized]);
1608
+ }
1609
+
1610
+ function setModelScale(value) {
1611
+ const next = Math.max(78, Math.min(128, Number(value) || 100));
1612
+ localStorage.setItem(modelScaleStorageKey, String(next));
1613
+ if (modelScaleSlider) modelScaleSlider.value = String(next);
1614
+ if (modelScaleValue) modelScaleValue.textContent = `${(next / 100).toFixed(2)}x`;
1615
+ if (window.Live2DManager) {
1616
+ Live2DManager.setZoomMultiplier(next / 100);
1617
+ }
1618
+ }
1619
+
1620
+ function applyModelPanelControlState() {
1621
+ setModelPositionLocked(localStorage.getItem(modelPositionLockStorageKey) === 'true');
1622
+ setModelBackground(Number(localStorage.getItem(modelBgStorageKey) || 0));
1623
+ setModelScale(Number(localStorage.getItem(modelScaleStorageKey) || 100));
1624
+ setWorkspaceLayout(localStorage.getItem(workspaceLayoutStorageKey) || 'companion');
1625
+ }
1626
+
1627
+ function setWorkspaceLayout(layout) {
1628
+ if (!assistantWorkspace) return;
1629
+ const normalized = workspaceLayoutPresets.includes(layout) ? layout : 'companion';
1630
+ workspaceLayoutClasses.forEach(className => assistantWorkspace.classList.remove(className));
1631
+ if (normalized !== 'companion') {
1632
+ assistantWorkspace.classList.add(`layout-${normalized}`);
1633
+ }
1634
+ localStorage.setItem(workspaceLayoutStorageKey, normalized);
1635
+ layoutPresetBtns.forEach((button) => {
1636
+ const isActive = button.dataset.layoutPreset === normalized;
1637
+ button.classList.toggle('is-active', isActive);
1638
+ button.setAttribute('aria-pressed', String(isActive));
1639
+ });
1640
+ }
1227
1641
 
1228
1642
  if (toggleModelBtn && assistantWorkspace) {
1229
1643
  toggleModelBtn.addEventListener('click', () => {
1230
- const isHidden = assistantWorkspace.classList.toggle('model-hidden');
1231
- toggleModelBtn.classList.toggle('active', isHidden);
1232
-
1233
- // Save preference to local storage
1234
- localStorage.setItem('mint-model-hidden', String(isHidden));
1235
-
1236
- // Refit model if shown
1237
- if (!isHidden && window.Live2DManager && Live2DManager.model) {
1238
- // Wait for transition
1239
- setTimeout(() => {
1240
- // Trigger a resize event to refit
1241
- window.dispatchEvent(new Event('resize'));
1242
- }, 450);
1243
- }
1644
+ setModelHidden(!assistantWorkspace.classList.contains('model-hidden'));
1244
1645
  });
1245
1646
 
1246
1647
  // Restore preference on load
1247
1648
  const savedModelHidden = localStorage.getItem('mint-model-hidden');
1248
1649
  const savedHidden = savedModelHidden === null || savedModelHidden === 'true';
1249
1650
  if (savedHidden) {
1250
- assistantWorkspace.classList.add('model-hidden');
1251
- toggleModelBtn.classList.add('active');
1651
+ setModelHidden(true);
1252
1652
  }
1253
1653
  }
1254
1654
 
1655
+ modelLockBtn?.addEventListener('click', () => {
1656
+ setModelPositionLocked(localStorage.getItem(modelPositionLockStorageKey) !== 'true');
1657
+ });
1658
+ modelScaleSlider?.addEventListener('input', (event) => setModelScale(event.target.value));
1659
+ modelScaleResetBtn?.addEventListener('click', () => setModelScale(100));
1660
+ modelBgBtn?.addEventListener('click', () => {
1661
+ const current = Number(localStorage.getItem(modelBgStorageKey) || 0);
1662
+ setModelBackground(current + 1);
1663
+ });
1664
+ layoutPresetBtns.forEach((button) => {
1665
+ button.addEventListener('click', () => setWorkspaceLayout(button.dataset.layoutPreset));
1666
+ });
1667
+
1668
+ applyModelPanelControlState();
1669
+
1255
1670
  // Cycle Shiroko's Expression
1256
1671
  const changeExpressionBtn = document.getElementById('change-expression-btn');
1257
1672
  if (changeExpressionBtn) {