@pheem49/mint 1.7.1 → 1.8.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 (51) hide show
  1. package/Cargo.lock +3 -3
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/crates/mint-core/src/api_server.rs +62 -4
  5. package/crates/mint-core/src/chat.rs +79 -0
  6. package/crates/mint-core/src/lib.rs +1 -1
  7. package/crates/mint-core/src/workflows.rs +8 -0
  8. package/package.json +2 -1
  9. package/src/renderer/src/App.tsx +1 -1
  10. package/src/renderer/src/components/ChatPanel.tsx +27 -14
  11. package/src/renderer/src/components/DashboardSidebar.tsx +71 -12
  12. package/src/renderer/src/components/ImageStudioPanel.tsx +7 -3
  13. package/src/renderer/src/components/MintDashboard.tsx +199 -5
  14. package/src/renderer/src/components/PicturesLibrary.tsx +1 -3
  15. package/src/renderer/src/components/Settings/AudioTab.tsx +97 -0
  16. package/src/renderer/src/components/Settings/AutomationTab.tsx +195 -0
  17. package/src/renderer/src/components/Settings/GeneralTab.tsx +950 -0
  18. package/src/renderer/src/components/Settings/MemoryTab.tsx +60 -0
  19. package/src/renderer/src/components/Settings/PluginsTab.tsx +452 -0
  20. package/src/renderer/src/components/Settings/ShortcutsTab.tsx +30 -0
  21. package/src/renderer/src/components/Settings/ThemeTab.tsx +129 -0
  22. package/src/renderer/src/components/SettingsWindow.tsx +175 -962
  23. package/src/renderer/src/components/WorkflowBuilderPanel.tsx +536 -0
  24. package/src/renderer/src/components/WorkflowsWindow.tsx +46 -0
  25. package/src/renderer/src/css/image-studio.css +9 -4
  26. package/src/renderer/src/css/settings.css +75 -6
  27. package/src/renderer/src/css/styles.css +355 -38
  28. package/src/renderer/src/css/workflows.css +542 -0
  29. package/src/renderer/src/env.d.ts +2 -0
  30. package/src/renderer/src/index.css +1 -0
  31. package/src/renderer/src/tauri.ts +97 -0
  32. package/src/renderer/src-web/components/ChatPanel.tsx +27 -14
  33. package/src/renderer/src-web/components/DashboardSidebar.tsx +17 -1
  34. package/src/renderer/src-web/components/ImageStudioPanel.tsx +7 -3
  35. package/src/renderer/src-web/components/MintDashboard.tsx +189 -0
  36. package/src/renderer/src-web/components/PicturesLibrary.tsx +1 -3
  37. package/src/renderer/src-web/components/Settings/AudioTab.tsx +97 -0
  38. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +196 -0
  39. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +948 -0
  40. package/src/renderer/src-web/components/Settings/MemoryTab.tsx +60 -0
  41. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +452 -0
  42. package/src/renderer/src-web/components/Settings/ShortcutsTab.tsx +30 -0
  43. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +129 -0
  44. package/src/renderer/src-web/components/SettingsWindow.tsx +187 -968
  45. package/src/renderer/src-web/css/image-studio.css +9 -4
  46. package/src/renderer/src-web/css/settings.css +75 -6
  47. package/src/renderer/src-web/css/styles.css +294 -38
  48. package/src/renderer/src-web/env.d.ts +2 -0
  49. package/src/renderer/src-web/tauri.ts +36 -0
  50. package/src-tauri/src/lib.rs +83 -90
  51. package/src-tauri/tauri.conf.json +1 -1
@@ -1798,21 +1798,33 @@ export default function ChatPanel({
1798
1798
  )}
1799
1799
 
1800
1800
  <div className="chat-container">
1801
- {interactions.map((interaction) => (
1802
- <div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1803
- {interaction.isSystemEvent ? (
1804
- <div className="system-event" style={{ background: 'rgba(16, 185, 129, 0.06)', border: '1px solid rgba(16, 185, 129, 0.2)', borderRadius: '8px', padding: '10px 14px', color: '#a7f3d0', fontSize: '0.82rem', lineHeight: '1.45', alignSelf: 'stretch' }}>
1805
- {interaction.userText}
1806
- </div>
1807
- ) : interaction.userText && (
1808
- <div className="message user-message">
1809
- <div className="bubble-wrapper">
1810
- <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
1811
- <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1801
+ {interactions.map((interaction) => {
1802
+ const isSystemEvent = interaction.provider === 'system' && interaction.model === 'provider_change';
1803
+ if (isSystemEvent) {
1804
+ return (
1805
+ <div key={interaction.id} className="system-event-divider">
1806
+ <div className="system-event-line" />
1807
+ <div className="system-event-pill">
1808
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
1809
+ <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
1810
+ </svg>
1811
+ <span>{interaction.userText}</span>
1812
1812
  </div>
1813
+ <div className="system-event-line" />
1813
1814
  </div>
1814
- )}
1815
- <div className="message ai-message">
1815
+ );
1816
+ }
1817
+ return (
1818
+ <div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1819
+ {interaction.userText && (
1820
+ <div className="message user-message">
1821
+ <div className="bubble-wrapper">
1822
+ <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
1823
+ <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1824
+ </div>
1825
+ </div>
1826
+ )}
1827
+ <div className="message ai-message">
1816
1828
  <div className="bubble-wrapper">
1817
1829
  {renderCompletedActivity(interaction)}
1818
1830
  {renderFileChanges(interaction)}
@@ -1858,7 +1870,8 @@ export default function ChatPanel({
1858
1870
  </div>
1859
1871
  </div>
1860
1872
  </div>
1861
- ))}
1873
+ );
1874
+ })}
1862
1875
 
1863
1876
  {sending && (
1864
1877
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
@@ -1,11 +1,13 @@
1
1
  import { useState } from 'react'
2
2
 
3
- export type DashboardView = 'chat' | 'pictures' | 'model' | 'imagine'
3
+ export type DashboardView = 'chat' | 'pictures' | 'model' | 'imagine' | 'workflows'
4
4
 
5
5
  interface ChatSessionItem {
6
6
  id: string
7
7
  title: string
8
8
  kind: string
9
+ createdAt?: string
10
+ updatedAt?: string
9
11
  }
10
12
 
11
13
  interface DashboardSidebarProps {
@@ -20,6 +22,8 @@ interface DashboardSidebarProps {
20
22
  onDeleteConversation: (id: string) => void
21
23
  onRenameConversation?: (id: string, newTitle: string) => void
22
24
  onSetView: (view: DashboardView) => void
25
+ isSearchOpen: boolean
26
+ onSetSearchOpen: (open: boolean) => void
23
27
  }
24
28
 
25
29
  export default function DashboardSidebar({
@@ -34,6 +38,8 @@ export default function DashboardSidebar({
34
38
  onDeleteConversation,
35
39
  onRenameConversation,
36
40
  onSetView,
41
+ isSearchOpen,
42
+ onSetSearchOpen,
37
43
  }: DashboardSidebarProps) {
38
44
  const [editingSessionId, setEditingSessionId] = useState<string | null>(null)
39
45
  const [editTitleValue, setEditTitleValue] = useState('')
@@ -77,6 +83,16 @@ export default function DashboardSidebar({
77
83
  <span>New Chat</span>
78
84
  </button>
79
85
 
86
+ <button className="sidebar-top-action sidebar-search-btn" onClick={() => onSetSearchOpen(true)}>
87
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
88
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
89
+ <circle cx="11" cy="11" r="8"></circle>
90
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
91
+ </svg>
92
+ </span>
93
+ <span>Search Chats</span>
94
+ </button>
95
+
80
96
  <button className={`sidebar-top-action ${view === 'chat' ? 'is-active' : ''}`} onClick={() => onSetView('chat')}>
81
97
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
82
98
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
@@ -193,12 +193,16 @@ export default function ImageStudioPanel({ view, onRefreshPictures, onSendToChat
193
193
  document.body.removeChild(a)
194
194
  }
195
195
 
196
- if (view !== 'imagine') return null
197
-
198
196
  const modelOptions = PROVIDER_MODELS[selectedProvider] ?? []
199
197
 
200
198
  return (
201
- <div className="img-studio" id="image-studio-panel" role="main" aria-label="Image Studio">
199
+ <div
200
+ className={`img-studio ${view === 'imagine' ? 'is-visible' : ''}`}
201
+ id="image-studio-panel"
202
+ role="main"
203
+ aria-label="Image Studio"
204
+ aria-hidden={view !== 'imagine'}
205
+ >
202
206
  {/* Header */}
203
207
  <header className="img-studio-header">
204
208
  <div className="img-studio-header-title">
@@ -5,6 +5,7 @@ import {
5
5
  deleteChatSession,
6
6
  renameChatSession,
7
7
  getRecentInteractions,
8
+ saveSystemInteraction,
8
9
  getRuntimeStatus,
9
10
  listChatSessions,
10
11
  listSavedPictures,
@@ -235,6 +236,79 @@ export default function MintDashboard() {
235
236
  const chatEnd = useRef<HTMLDivElement | null>(null)
236
237
  const startupReady = dashboardDataReady || startupTimedOut
237
238
 
239
+ const [isSearchOpen, setIsSearchOpen] = useState(false)
240
+ const [searchQuery, setSearchQuery] = useState('')
241
+
242
+ useEffect(() => {
243
+ function handleKeyDown(event: KeyboardEvent) {
244
+ if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
245
+ event.preventDefault()
246
+ setIsSearchOpen((prev) => !prev)
247
+ } else if (event.key === 'Escape') {
248
+ setIsSearchOpen(false)
249
+ }
250
+ }
251
+ window.addEventListener('keydown', handleKeyDown)
252
+ return () => window.removeEventListener('keydown', handleKeyDown)
253
+ }, [])
254
+
255
+ useEffect(() => {
256
+ if (!isSearchOpen) {
257
+ setSearchQuery('')
258
+ }
259
+ }, [isSearchOpen])
260
+
261
+ const filteredSessions = chatSessions.filter((session) => {
262
+ if (session.kind === 'cli' || session.id === 'conversation-default') return false
263
+ return session.title.toLowerCase().includes(searchQuery.toLowerCase())
264
+ })
265
+
266
+ const groupSessionsByDate = (sessions: ChatSession[]) => {
267
+ const groups: { [key: string]: ChatSession[] } = {}
268
+
269
+ sessions.forEach((session) => {
270
+ const dateStr = session.updatedAt || session.createdAt
271
+ if (!dateStr) {
272
+ const groupName = 'Other'
273
+ if (!groups[groupName]) groups[groupName] = []
274
+ groups[groupName].push(session)
275
+ return
276
+ }
277
+
278
+ const date = new Date(dateStr)
279
+ const today = new Date()
280
+ const yesterday = new Date()
281
+ yesterday.setDate(today.getDate() - 1)
282
+
283
+ let groupName = ''
284
+ if (date.toDateString() === today.toDateString()) {
285
+ groupName = 'Today'
286
+ } else if (date.toDateString() === yesterday.toDateString()) {
287
+ groupName = 'Yesterday'
288
+ } else {
289
+ const diffTime = Math.abs(today.getTime() - date.getTime())
290
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
291
+ if (diffDays <= 7) {
292
+ groupName = 'Previous 7 days'
293
+ } else if (diffDays <= 30) {
294
+ groupName = 'Previous 30 days'
295
+ } else {
296
+ groupName = date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
297
+ }
298
+ }
299
+
300
+ if (!groups[groupName]) {
301
+ groups[groupName] = []
302
+ }
303
+ groups[groupName].push(session)
304
+ })
305
+
306
+ return groups
307
+ }
308
+
309
+ const groupedSearchSessions = groupSessionsByDate(filteredSessions)
310
+
311
+
238
312
  async function refreshHistory() {
239
313
  const history = await getRecentInteractions(50, conversationId)
240
314
  const reversed = history.reverse()
@@ -664,6 +738,37 @@ export default function MintDashboard() {
664
738
  }
665
739
  }
666
740
 
741
+ function formatProviderChangeText(provider: string, model: string) {
742
+ let providerName = provider
743
+ if (provider === 'gemini') providerName = 'Gemini'
744
+ else if (provider === 'openai') providerName = 'OpenAI'
745
+ else if (provider === 'openrouter') providerName = 'OpenRouter'
746
+ else if (provider === 'deepseek') providerName = 'DeepSeek'
747
+ else if (provider === 'anthropic') providerName = 'Claude'
748
+ else if (provider === 'huggingface') providerName = 'HF'
749
+ else if (provider === 'local_openai') providerName = 'Local'
750
+ else if (provider === 'ollama') providerName = 'Ollama'
751
+
752
+ if (providerName && providerName === provider) {
753
+ providerName = providerName.charAt(0).toUpperCase() + providerName.slice(1)
754
+ }
755
+ return [providerName, model].filter(Boolean).join(' • ')
756
+ }
757
+
758
+ function getActiveModelName(config: any, provider: string) {
759
+ switch (provider) {
760
+ case 'gemini': return config.geminiModel || 'gemini-1.5-flash'
761
+ case 'openai': return config.openaiModel || 'gpt-4o'
762
+ case 'openrouter': return config.openrouterModel || 'anthropic/claude-3.5-sonnet'
763
+ case 'deepseek': return config.deepseekModel || 'deepseek-chat'
764
+ case 'anthropic': return config.anthropicModel || 'claude-3-5-sonnet-20240620'
765
+ case 'huggingface': return config.hfModel || 'meta-llama/Meta-Llama-3-8B-Instruct'
766
+ case 'local_openai': return config.localModelName || 'llama3'
767
+ case 'ollama': return config.ollamaModel || 'llama3:latest'
768
+ default: return ''
769
+ }
770
+ }
771
+
667
772
  async function changeProvider(provider: string) {
668
773
  try {
669
774
  const config = await window.settingsApi.getSettings()
@@ -671,6 +776,12 @@ export default function MintDashboard() {
671
776
  await window.settingsApi.saveSettings(config)
672
777
  setSettingsConfig(config)
673
778
  setStatus(await getRuntimeStatus())
779
+
780
+ // Record system event in chat history
781
+ const activeModel = getActiveModelName(config, provider)
782
+ const displayName = formatProviderChangeText(provider, activeModel)
783
+ await saveSystemInteraction(conversationId, displayName, 'system', 'provider_change')
784
+ await refreshHistory()
674
785
  } catch (reason) {
675
786
  setError(errorMessage(reason))
676
787
  }
@@ -700,6 +811,11 @@ export default function MintDashboard() {
700
811
  await window.settingsApi.saveSettings(config)
701
812
  setSettingsConfig(config)
702
813
  setStatus(await getRuntimeStatus())
814
+
815
+ // Record system event in chat history
816
+ const displayName = formatProviderChangeText(provider, modelName)
817
+ await saveSystemInteraction(conversationId, displayName, 'system', 'provider_change')
818
+ await refreshHistory()
703
819
  } catch (reason) {
704
820
  setError(errorMessage(reason))
705
821
  }
@@ -738,6 +854,8 @@ export default function MintDashboard() {
738
854
  onDeleteConversation={deleteConversation}
739
855
  onRenameConversation={renameConversation}
740
856
  onSetView={changeView}
857
+ isSearchOpen={isSearchOpen}
858
+ onSetSearchOpen={setIsSearchOpen}
741
859
  />
742
860
  <main className="assistant-workspace model-hidden">
743
861
  <ChatPanel
@@ -804,6 +922,77 @@ export default function MintDashboard() {
804
922
  <button onClick={() => setError('')} style={{ marginLeft: '12px', background: 'transparent', border: 0, color: 'white', cursor: 'pointer' }}>✕</button>
805
923
  </div>
806
924
  )}
925
+
926
+ {isSearchOpen && (
927
+ <div className="sidebar-search-modal-backdrop" onClick={() => setIsSearchOpen(false)}>
928
+ <div className="sidebar-search-modal" onClick={(e) => e.stopPropagation()}>
929
+ <div className="search-modal-header">
930
+ <span className="search-icon-wrapper">
931
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
932
+ <circle cx="11" cy="11" r="8"></circle>
933
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
934
+ </svg>
935
+ </span>
936
+ <input
937
+ type="text"
938
+ placeholder="Search chats..."
939
+ value={searchQuery}
940
+ onChange={(e) => setSearchQuery(e.target.value)}
941
+ autoFocus
942
+ />
943
+ <button className="search-modal-close" onClick={() => setIsSearchOpen(false)}>
944
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
945
+ <line x1="18" y1="6" x2="6" y2="18"></line>
946
+ <line x1="6" y1="6" x2="18" y2="18"></line>
947
+ </svg>
948
+ </button>
949
+ </div>
950
+
951
+ <div className="search-modal-body">
952
+ <button
953
+ className="search-new-chat-btn"
954
+ onClick={() => {
955
+ clearHistory('New chat')
956
+ setIsSearchOpen(false)
957
+ }}
958
+ >
959
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
960
+ <line x1="12" y1="5" x2="12" y2="19"></line>
961
+ <line x1="5" y1="12" x2="19" y2="12"></line>
962
+ </svg>
963
+ <span>New Chat</span>
964
+ </button>
965
+
966
+ <div className="search-modal-results">
967
+ {Object.keys(groupedSearchSessions).length > 0 ? (
968
+ Object.entries(groupedSearchSessions).map(([groupName, sessions]) => (
969
+ <div key={groupName} className="search-results-group">
970
+ <div className="search-group-title">{groupName}</div>
971
+ {sessions.map((session) => (
972
+ <button
973
+ key={session.id}
974
+ className={`search-result-item ${session.id === conversationId ? 'active' : ''}`}
975
+ onClick={() => {
976
+ selectConversation(session.id)
977
+ setIsSearchOpen(false)
978
+ }}
979
+ >
980
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
981
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
982
+ </svg>
983
+ <span className="search-result-title">{session.title || 'New chat'}</span>
984
+ </button>
985
+ ))}
986
+ </div>
987
+ ))
988
+ ) : (
989
+ <div className="search-no-results">No matching chats found</div>
990
+ )}
991
+ </div>
992
+ </div>
993
+ </div>
994
+ </div>
995
+ )}
807
996
  </div>
808
997
  )
809
998
  }
@@ -39,10 +39,8 @@ export default function PicturesLibrary({ view, pictures, onSetView, onRefreshPi
39
39
  return () => window.clearTimeout(timer)
40
40
  }, [view, pictures.length, visibleCount])
41
41
 
42
- if (view !== 'pictures') return null
43
-
44
42
  return (
45
- <section className="pictures-library">
43
+ <section className={`pictures-library ${view === 'pictures' ? 'is-visible' : ''}`} aria-hidden={view !== 'pictures'}>
46
44
  <header className="pictures-header">
47
45
  <div><span className="pictures-kicker">Gallery</span><h2>Saved Pictures</h2></div>
48
46
  <div className="pictures-header-actions">
@@ -0,0 +1,97 @@
1
+ import React from 'react'
2
+ import { DEFAULT_CONFIG } from '../SettingsWindow'
3
+
4
+ interface AudioTabProps {
5
+ config: typeof DEFAULT_CONFIG
6
+ updateField: (field: keyof typeof DEFAULT_CONFIG, value: any) => void
7
+ }
8
+
9
+ export default function AudioTab({ config, updateField }: AudioTabProps) {
10
+ return (
11
+ <div className="tab-pane active">
12
+ <section className="setting-section">
13
+ <div className="section-heading">
14
+ <div>
15
+ <p className="section-kicker">Speech</p>
16
+ <h2 className="section-title">Voice Reply</h2>
17
+ </div>
18
+ <p className="section-description">Control spoken responses and TTS behavior.</p>
19
+ </div>
20
+
21
+ <div className="toggle-row">
22
+ <div>
23
+ <label>Enable Voice Reply</label>
24
+ <p className="hint">Mint will speak responses out loud when this is enabled.</p>
25
+ </div>
26
+ <label className="settings-toggle-switch">
27
+ <input
28
+ type="checkbox"
29
+ checked={config.enableVoiceReply}
30
+ onChange={(e) => updateField('enableVoiceReply', e.target.checked)}
31
+ />
32
+ <span className="settings-toggle-slider"></span>
33
+ </label>
34
+ </div>
35
+
36
+ <div className="form-grid single">
37
+ <div className="setting-row">
38
+ <label>Voice Engine</label>
39
+ <select value={config.ttsProvider} onChange={(e) => updateField('ttsProvider', e.target.value)}>
40
+ <option value="google">Google Cloud (Natural, Auto Lang)</option>
41
+ <option value="native">OS Native (Supports Pitch)</option>
42
+ </select>
43
+ </div>
44
+ </div>
45
+
46
+ <div className="slider-stack">
47
+ <div className="setting-row">
48
+ <label>Volume</label>
49
+ <div className="slider-group">
50
+ <input
51
+ type="range"
52
+ min="0"
53
+ max="1"
54
+ step="0.1"
55
+ value={config.ttsVolume}
56
+ onChange={(e) => updateField('ttsVolume', parseFloat(e.target.value))}
57
+ className="range-slider"
58
+ />
59
+ <span className="range-value">{Math.round(config.ttsVolume * 100)}%</span>
60
+ </div>
61
+ </div>
62
+ <div className="setting-row">
63
+ <label>Speed</label>
64
+ <div className="slider-group">
65
+ <input
66
+ type="range"
67
+ min="0.5"
68
+ max="2"
69
+ step="0.1"
70
+ value={config.ttsSpeed}
71
+ onChange={(e) => updateField('ttsSpeed', parseFloat(e.target.value))}
72
+ className="range-slider"
73
+ />
74
+ <span className="range-value">{parseFloat(String(config.ttsSpeed)).toFixed(1)}x</span>
75
+ </div>
76
+ </div>
77
+ <div className="setting-row">
78
+ <label>Pitch</label>
79
+ <div className="slider-group">
80
+ <input
81
+ type="range"
82
+ min="0"
83
+ max="2"
84
+ step="0.1"
85
+ value={config.ttsPitch}
86
+ onChange={(e) => updateField('ttsPitch', parseFloat(e.target.value))}
87
+ className="range-slider"
88
+ />
89
+ <span className="range-value">{parseFloat(String(config.ttsPitch)).toFixed(1)}</span>
90
+ </div>
91
+ <p className="hint">Pitch applies to OS native voice only.</p>
92
+ </div>
93
+ </div>
94
+ </section>
95
+ </div>
96
+ )
97
+ }