@pheem49/mint 1.8.2 → 1.10.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 (106) hide show
  1. package/Cargo.lock +55 -3
  2. package/Cargo.toml +2 -1
  3. package/Release_Note.md +297 -107
  4. package/bin/mint +0 -0
  5. package/crates/mint-cli/Cargo.toml +1 -0
  6. package/crates/mint-cli/src/actions.rs +233 -0
  7. package/crates/mint-cli/src/agent.rs +382 -127
  8. package/crates/mint-cli/src/gmail.rs +22 -2
  9. package/crates/mint-cli/src/image.rs +8 -8
  10. package/crates/mint-cli/src/interactive.rs +2888 -0
  11. package/crates/mint-cli/src/main.rs +391 -2506
  12. package/crates/mint-cli/src/markdown.rs +294 -0
  13. package/crates/mint-cli/src/onboard.rs +152 -109
  14. package/crates/mint-cli/src/setup.rs +143 -143
  15. package/crates/mint-cli/src/updater.rs +48 -6
  16. package/crates/mint-core/Cargo.toml +1 -0
  17. package/crates/mint-core/src/agent_loop.rs +1 -0
  18. package/crates/mint-core/src/api_server.rs +431 -65
  19. package/crates/mint-core/src/browser.rs +630 -102
  20. package/crates/mint-core/src/channels.rs +105 -6
  21. package/crates/mint-core/src/chat.rs +174 -11
  22. package/crates/mint-core/src/code_tools.rs +47 -31
  23. package/crates/mint-core/src/config.rs +302 -17
  24. package/crates/mint-core/src/image_gen.rs +299 -38
  25. package/crates/mint-core/src/lib.rs +10 -7
  26. package/crates/mint-core/src/mcp.rs +119 -6
  27. package/crates/mint-core/src/memory.rs +6 -1
  28. package/crates/mint-core/src/orchestration.rs +237 -96
  29. package/crates/mint-core/src/pictures.rs +67 -5
  30. package/crates/mint-core/src/semantic.rs +1 -1
  31. package/crates/mint-core/src/shell.rs +1 -1
  32. package/crates/mint-core/src/skills.rs +128 -25
  33. package/crates/mint-core/src/video_gen.rs +235 -0
  34. package/package.json +8 -2
  35. package/shared-modules/linux-audio/fluidsynth2-static.json +9 -3
  36. package/shared-modules/linux-audio/fluidsynth2.json +8 -2
  37. package/shared-modules/linux-audio/lilv.json +26 -19
  38. package/shared-modules/linux-audio/lv2.json +0 -1
  39. package/shared-modules/luajit/luajit.json +2 -2
  40. package/skills-lock.json +41 -0
  41. package/src/renderer/index-web.html +1 -1
  42. package/src/renderer/index.html +1 -1
  43. package/src/renderer/shared/components/AgentActivityTable.tsx +25 -6
  44. package/src/renderer/shared/constants/slashCommands.ts +27 -0
  45. package/src/renderer/shared/platform.ts +3 -0
  46. package/src/renderer/shared/types.ts +4 -0
  47. package/src/renderer/shared/utils/approval.tsx +86 -7
  48. package/src/renderer/shared/utils/fileIcons.ts +135 -0
  49. package/src/renderer/shared/utils/markdown.tsx +217 -88
  50. package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
  51. package/src/renderer/src/components/ChatPanel.tsx +486 -69
  52. package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
  53. package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
  54. package/src/renderer/src/components/MintDashboard.tsx +195 -7
  55. package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
  56. package/src/renderer/src/components/Settings/AgentsTab.tsx +21 -2
  57. package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
  58. package/src/renderer/src/components/Settings/GeneralTab.tsx +795 -413
  59. package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
  60. package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
  61. package/src/renderer/src/components/SettingsWindow.tsx +106 -3
  62. package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
  63. package/src/renderer/src/components/WorkspacePanel.tsx +7 -125
  64. package/src/renderer/src/css/image-studio.css +106 -0
  65. package/src/renderer/src/css/settings.css +158 -0
  66. package/src/renderer/src/css/styles.css +368 -1
  67. package/src/renderer/src/css/veo-studio.css +626 -0
  68. package/src/renderer/src/index.css +1 -0
  69. package/src/renderer/src/tauri.ts +144 -17
  70. package/src/renderer/src-web/components/ChatPanel.tsx +385 -93
  71. package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
  72. package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
  73. package/src/renderer/src-web/components/MintDashboard.tsx +156 -5
  74. package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
  75. package/src/renderer/src-web/components/Settings/AgentsTab.tsx +21 -2
  76. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
  77. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +795 -411
  78. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
  79. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
  80. package/src/renderer/src-web/components/SettingsWindow.tsx +110 -3
  81. package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
  82. package/src/renderer/src-web/css/image-studio.css +106 -0
  83. package/src/renderer/src-web/css/settings.css +158 -0
  84. package/src/renderer/src-web/css/styles.css +369 -1
  85. package/src/renderer/src-web/css/veo-studio.css +650 -0
  86. package/src/renderer/src-web/index.css +1 -0
  87. package/src/renderer/src-web/tauri.ts +135 -16
  88. package/src-tauri/src/desktop.rs +18 -16
  89. package/src-tauri/src/discord_rpc.rs +1 -1
  90. package/src-tauri/src/lib.rs +155 -25
  91. package/src-tauri/src/webhooks.rs +45 -31
  92. package/src-tauri/src/workflows.rs +1 -0
  93. package/src-tauri/tauri.conf.json +1 -1
  94. package/temp_gridgeist/AGENTS.md +8 -0
  95. package/temp_gridgeist/CHANGELOG.md +130 -0
  96. package/temp_gridgeist/LICENSE +21 -0
  97. package/temp_gridgeist/README.md +323 -0
  98. package/temp_gridgeist/README.th.md +325 -0
  99. package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
  100. package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
  101. package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
  102. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
  103. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
  104. package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
  105. package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
  106. package/temp_gridgeist/skills/gridgeist/references/system-contract.md +109 -0
@@ -12,11 +12,12 @@ import {
12
12
  } from '../../shared/constants/models'
13
13
  import { badge, providerLabel, fallbackNotice } from '../../shared/utils/providers'
14
14
  import { activitiesFrom, parseWebSearchSources, type AgentActivity, type AgentActivityView } from '../../shared/utils/agentActivity'
15
+ import { SLASH_COMMANDS } from '../../shared/constants/slashCommands'
15
16
  import { AgentActivityTable } from '../../shared/components/AgentActivityTable'
16
17
  import { ChatCodeBlock } from '../../shared/components/ChatCodeBlock'
17
18
  import { renderApprovalDetails, renderDiff, type ApprovalDetails } from '../../shared/utils/approval'
18
19
  import { ApprovalCard } from '../../shared/components/ApprovalCard'
19
- import { renderFormattedMessage, readableAssistantText, cleanSpeechText, renderSpeakerIcon } from '../../shared/utils/markdown'
20
+ import { renderFormattedMessage, readableAssistantText, cleanSpeechText, renderSpeakerIcon, renderCopyIcon } from '../../shared/utils/markdown'
20
21
  import { ThinkingBlock } from '../../shared/components/ThinkingBlock'
21
22
  import { AgentActivityDrawer } from '../../shared/components/AgentActivityDrawer'
22
23
  import type { DiffHunk, FileChange } from '../../shared/types'
@@ -26,6 +27,8 @@ import { useSpeechToText } from '../../shared/utils/speech'
26
27
 
27
28
 
28
29
  import {
30
+ listLearnedSkills,
31
+ type LearnedSkill,
29
32
  type AgentProgress,
30
33
  type ChatResponse,
31
34
  type RuntimeStatus,
@@ -38,6 +41,7 @@ interface ChatPanelProps {
38
41
  sending: boolean
39
42
  sendingMessage: string
40
43
  sendingImageCount: number
44
+ sendingVideoCount?: number
41
45
  streamedReply: string
42
46
  streamedResponse: ChatResponse | null
43
47
  agentProgress: AgentProgress[]
@@ -46,6 +50,7 @@ interface ChatPanelProps {
46
50
  onThinkingExpandedChange: (key: string, open: boolean) => void
47
51
  message: string
48
52
  imageAttachments: Array<{ dataUri: string; name: string; previewDataUri?: string }>
53
+ videoAttachments: Array<{ dataUri: string; name: string }>
49
54
  documentName: string
50
55
  pendingApproval: any | null
51
56
  smartContext: boolean
@@ -55,11 +60,13 @@ interface ChatPanelProps {
55
60
  welcomeInteraction: any
56
61
  onSubmit: (event: FormEvent<HTMLFormElement>) => void
57
62
  onSelectImage: (event: ChangeEvent<HTMLInputElement>) => void
63
+ onSelectVideo: (event: ChangeEvent<HTMLInputElement>) => void
58
64
  onSelectDocument: (event: ChangeEvent<HTMLInputElement>) => void
59
65
  onPasteImage: (clipboardData: DataTransfer) => boolean
60
66
  onSetMessage: (message: string) => void
61
67
  onSendVoiceMessage: (message: string, audioDataUri?: string | null) => Promise<void>
62
68
  onRemoveImage: (idx: number) => void
69
+ onRemoveVideo: (idx: number) => void
63
70
  onRemoveDocument: () => void
64
71
  onStartWebSearch: () => void
65
72
  onCaptureScreen: () => void
@@ -79,6 +86,7 @@ export default function ChatPanel({
79
86
  sending,
80
87
  sendingMessage,
81
88
  sendingImageCount,
89
+ sendingVideoCount,
82
90
  streamedReply,
83
91
  streamedResponse,
84
92
  agentProgress,
@@ -87,6 +95,7 @@ export default function ChatPanel({
87
95
  onThinkingExpandedChange,
88
96
  message,
89
97
  imageAttachments,
98
+ videoAttachments,
90
99
  documentName,
91
100
  pendingApproval,
92
101
  smartContext,
@@ -96,11 +105,13 @@ export default function ChatPanel({
96
105
  welcomeInteraction,
97
106
  onSubmit,
98
107
  onSelectImage,
108
+ onSelectVideo,
99
109
  onSelectDocument,
100
110
  onPasteImage,
101
111
  onSetMessage,
102
112
  onSendVoiceMessage,
103
113
  onRemoveImage,
114
+ onRemoveVideo,
104
115
  onRemoveDocument,
105
116
  onStartWebSearch,
106
117
  onCaptureScreen,
@@ -150,8 +161,9 @@ export default function ChatPanel({
150
161
  }
151
162
  fetchOllamaModels();
152
163
  }, [status?.activeProvider, settingsConfig?.ollamaHost])
153
- const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName)
164
+ const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || videoAttachments.length > 0 || documentName)
154
165
  const sendingImageMarkers = Array.from({ length: sendingImageCount }, (_, index) => `[Image #${index + 1}]`).join(' ')
166
+ const sendingVideoMarkers = Array.from({ length: sendingVideoCount || 0 }, (_, index) => `[Video #${index + 1}]`).join(' ')
155
167
 
156
168
  const getAvailableModels = (provider: string) => {
157
169
  switch (provider) {
@@ -172,8 +184,13 @@ export default function ChatPanel({
172
184
  case 'ollama':
173
185
  return dynamicOllamaModels
174
186
  default:
187
+ if (provider.startsWith('custom:')) {
188
+ const id = provider.replace(/^custom:/, '')
189
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
190
+ return cp?.models.map(m => m.modelId) ?? []
191
+ }
175
192
  return []
176
- }
193
+ }
177
194
  }
178
195
 
179
196
  const activeProvider = status?.activeProvider ?? ''
@@ -199,6 +216,11 @@ export default function ChatPanel({
199
216
  case 'ollama':
200
217
  return settingsConfig.ollamaModel
201
218
  default:
219
+ if (provider.startsWith('custom:')) {
220
+ const id = provider.replace(/^custom:/, '')
221
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
222
+ return (settingsConfig.customModelSelections ?? {})[id] ?? cp?.models[0]?.modelId ?? ''
223
+ }
202
224
  return ''
203
225
  }
204
226
  }
@@ -224,6 +246,22 @@ export default function ChatPanel({
224
246
  }, [sending])
225
247
 
226
248
  const [speakingText, setSpeakingText] = useState<string | null>(null)
249
+ const [copiedId, setCopiedId] = useState<string | number | null>(null)
250
+
251
+ const handleCopyMessage = async (id: string | number, text: string) => {
252
+ try {
253
+ const cleanText = readableAssistantText(text) || text
254
+ await navigator.clipboard.writeText(cleanText)
255
+ setCopiedId(id)
256
+ setTimeout(() => {
257
+ setCopiedId((current) => (current === id ? null : current))
258
+ }, 2000)
259
+ } catch (err) {
260
+ console.error('Failed to copy message:', err)
261
+ }
262
+ }
263
+
264
+
227
265
  const audioRef = useRef<HTMLAudioElement | null>(null)
228
266
  const speechRunRef = useRef(0)
229
267
  const historyReadyRef = useRef(false)
@@ -370,14 +408,19 @@ export default function ChatPanel({
370
408
  }
371
409
  }
372
410
  if (sending) return
373
- if (!settingsConfig?.enableVoiceReply && !voiceMode) {
374
- lastAutoSpokenIdRef.current = latest?.id ?? null
411
+ if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
412
+
413
+ if (!settingsConfig?.enableVoiceReply) {
414
+ lastAutoSpokenIdRef.current = latest.id
415
+ if (voiceMode) {
416
+ scheduleVoiceListen(350)
417
+ }
375
418
  return
376
419
  }
377
- if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
420
+
378
421
  lastAutoSpokenIdRef.current = latest.id
379
422
  speak(latest.aiText)
380
- }, [interactions, sending, settingsConfig?.enableVoiceReply])
423
+ }, [interactions, sending, settingsConfig?.enableVoiceReply, voiceMode])
381
424
 
382
425
  // Drag and Drop Zone Overlay
383
426
  const [isDragging, setIsDragging] = useState(false)
@@ -422,6 +465,15 @@ export default function ChatPanel({
422
465
  const event = { target: input } as ChangeEvent<HTMLInputElement>
423
466
  onSelectImage(event)
424
467
  }
468
+ } else if (file.type.startsWith('video/')) {
469
+ const input = document.getElementById('video-file-input') as HTMLInputElement | null
470
+ if (input) {
471
+ const dt = new DataTransfer()
472
+ dt.items.add(file)
473
+ input.files = dt.files
474
+ const event = { target: input } as ChangeEvent<HTMLInputElement>
475
+ onSelectVideo(event)
476
+ }
425
477
  } else if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
426
478
  const input = document.getElementById('document-file-input') as HTMLInputElement | null
427
479
  if (input) {
@@ -434,12 +486,154 @@ export default function ChatPanel({
434
486
  }
435
487
  }
436
488
  }
489
+ const [skillsList, setSkillsList] = useState<LearnedSkill[]>([])
490
+ const [slashMenuOpen, setSlashMenuOpen] = useState(true)
491
+ const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
492
+ const slashMenuRef = useRef<HTMLDivElement>(null)
493
+ const slashListRef = useRef<HTMLDivElement>(null)
494
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
495
+
496
+ const isSlashInput = message.trimStart().startsWith('/')
497
+ const isSkillInput = message.trimStart().startsWith('$')
498
+ const atMatch = message.match(/@([\w\-\.\/]*)$/)
499
+ const isAtInput = Boolean(atMatch)
500
+ const atQuery = atMatch ? atMatch[1].toLowerCase() : ''
501
+
502
+ const CONTEXT_SUGGESTIONS = [
503
+ { label: '@workspace', desc: 'Include workspace path & context' },
504
+ { label: '@file', desc: 'Reference workspace file' },
505
+ { label: '@docs', desc: 'Include documentation context' },
506
+ { label: '@memory', desc: 'Include long-term memory store' },
507
+ ]
508
+
509
+ const filteredContexts = isAtInput
510
+ ? CONTEXT_SUGGESTIONS.filter((item) => item.label.toLowerCase().includes(atQuery))
511
+ : []
512
+
513
+ useEffect(() => {
514
+ let isMounted = true
515
+ listLearnedSkills()
516
+ .then((res) => {
517
+ if (isMounted && Array.isArray(res)) setSkillsList(res)
518
+ })
519
+ .catch(() => {})
520
+ return () => { isMounted = false }
521
+ }, [isSkillInput])
522
+
523
+ const slashQuery = isSlashInput ? message.trimStart().toLowerCase() : ''
524
+ const filteredSlashCommands = isSlashInput
525
+ ? SLASH_COMMANDS.filter((c) => c.command.toLowerCase().startsWith(slashQuery))
526
+ : []
527
+
528
+ const skillQuery = isSkillInput ? message.trimStart().slice(1).toLowerCase() : ''
529
+ const filteredSkills = isSkillInput
530
+ ? skillsList.filter((s) => s.name.toLowerCase().startsWith(skillQuery))
531
+ : []
532
+
533
+ const showSuggestionMenu = slashMenuOpen && (
534
+ (isSlashInput && filteredSlashCommands.length > 0) ||
535
+ (isSkillInput && filteredSkills.length > 0) ||
536
+ (isAtInput && filteredContexts.length > 0)
537
+ )
538
+
539
+ const suggestionCount = isSlashInput
540
+ ? filteredSlashCommands.length
541
+ : isSkillInput
542
+ ? filteredSkills.length
543
+ : filteredContexts.length
544
+
545
+ useEffect(() => {
546
+ if (isSlashInput || isSkillInput || isAtInput) {
547
+ setSlashMenuOpen(true)
548
+ setSlashSelectedIndex(0)
549
+ }
550
+ }, [message])
551
+
552
+ useEffect(() => {
553
+ if (showSuggestionMenu && slashListRef.current) {
554
+ const activeItem = slashListRef.current.children[slashSelectedIndex] as HTMLElement
555
+ if (activeItem) {
556
+ activeItem.scrollIntoView({ block: 'nearest' })
557
+ }
558
+ }
559
+ }, [slashSelectedIndex, showSuggestionMenu])
560
+
561
+ useEffect(() => {
562
+ const handleClickOutside = (event: MouseEvent) => {
563
+ if (
564
+ slashMenuRef.current &&
565
+ !slashMenuRef.current.contains(event.target as Node) &&
566
+ textareaRef.current &&
567
+ !textareaRef.current.contains(event.target as Node)
568
+ ) {
569
+ setSlashMenuOpen(false)
570
+ }
571
+ }
572
+ document.addEventListener('mousedown', handleClickOutside)
573
+ return () => document.removeEventListener('mousedown', handleClickOutside)
574
+ }, [])
575
+
576
+ const selectSlashCommand = (cmd: string) => {
577
+ onSetMessage(cmd + ' ')
578
+ setSlashMenuOpen(false)
579
+ setSlashSelectedIndex(0)
580
+ setTimeout(() => textareaRef.current?.focus(), 0)
581
+ }
582
+
583
+ const selectSkillCommand = (name: string) => {
584
+ onSetMessage('$' + name + ' ')
585
+ setSlashMenuOpen(false)
586
+ setSlashSelectedIndex(0)
587
+ setTimeout(() => textareaRef.current?.focus(), 0)
588
+ }
589
+
590
+ const selectAtCommand = (label: string) => {
591
+ const nextMsg = message.replace(/@[\w\-\.\/]*$/, label + ' ')
592
+ onSetMessage(nextMsg)
593
+ setSlashMenuOpen(false)
594
+ setSlashSelectedIndex(0)
595
+ setTimeout(() => textareaRef.current?.focus(), 0)
596
+ }
597
+
437
598
  const submitOnEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
438
599
  if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
439
600
  event.preventDefault()
440
601
  event.currentTarget.form?.requestSubmit()
441
602
  }
442
603
  const handleInputKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
604
+ if (showSuggestionMenu) {
605
+ if (event.key === 'ArrowDown') {
606
+ event.preventDefault()
607
+ setSlashSelectedIndex((prev) => (prev + 1) % suggestionCount)
608
+ return
609
+ }
610
+ if (event.key === 'ArrowUp') {
611
+ event.preventDefault()
612
+ setSlashSelectedIndex((prev) => (prev - 1 + suggestionCount) % suggestionCount)
613
+ return
614
+ }
615
+ if (event.key === 'Enter' || event.key === 'Tab') {
616
+ if (!event.shiftKey && !event.nativeEvent.isComposing) {
617
+ event.preventDefault()
618
+ if (isSlashInput) {
619
+ const selected = filteredSlashCommands[slashSelectedIndex] || filteredSlashCommands[0]
620
+ if (selected) selectSlashCommand(selected.command)
621
+ } else if (isSkillInput) {
622
+ const selected = filteredSkills[slashSelectedIndex] || filteredSkills[0]
623
+ if (selected) selectSkillCommand(selected.name)
624
+ } else if (isAtInput) {
625
+ const selected = filteredContexts[slashSelectedIndex] || filteredContexts[0]
626
+ if (selected) selectAtCommand(selected.label)
627
+ }
628
+ return
629
+ }
630
+ }
631
+ if (event.key === 'Escape') {
632
+ event.preventDefault()
633
+ setSlashMenuOpen(false)
634
+ return
635
+ }
636
+ }
443
637
  submitOnEnter(event)
444
638
  }
445
639
  const resizeInput = (element: HTMLTextAreaElement) => {
@@ -485,6 +679,10 @@ export default function ChatPanel({
485
679
  setToolMenuOpen(false)
486
680
  document.getElementById('vision-file-input')?.click()
487
681
  }
682
+ const openVideoPicker = () => {
683
+ setToolMenuOpen(false)
684
+ document.getElementById('video-file-input')?.click()
685
+ }
488
686
  const openDocumentPicker = () => {
489
687
  setToolMenuOpen(false)
490
688
  document.getElementById('document-file-input')?.click()
@@ -810,7 +1008,7 @@ export default function ChatPanel({
810
1008
  >
811
1009
  <div style={{ fontSize: '3.5rem', marginBottom: '16px' }}>🖼️</div>
812
1010
  <div style={{ fontSize: '1.25rem', fontWeight: 'bold', letterSpacing: '0.5px' }}>Drag files to attach data</div>
813
- <div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>Supports images (PNG, JPEG, WebP, GIF) and PDF files</div>
1011
+ <div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>Supports images (PNG, JPEG, WebP, GIF), videos (MP4, WebM, MOV, MKV), and PDF files</div>
814
1012
  </div>
815
1013
  )}
816
1014
 
@@ -818,6 +1016,8 @@ export default function ChatPanel({
818
1016
  {interactions.map((interaction) => {
819
1017
  const isSystemEvent = interaction.provider === 'system' && interaction.model === 'provider_change';
820
1018
  if (isSystemEvent) {
1019
+ const rawText = interaction.userText || ''
1020
+ const cleanText = rawText.replace(/^Changed model to\s*/i, '').trim()
821
1021
  return (
822
1022
  <div key={interaction.id} className="system-event-divider">
823
1023
  <div className="system-event-line" />
@@ -825,7 +1025,7 @@ export default function ChatPanel({
825
1025
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
826
1026
  <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" />
827
1027
  </svg>
828
- <span>{interaction.userText}</span>
1028
+ <span>{cleanText}</span>
829
1029
  </div>
830
1030
  <div className="system-event-line" />
831
1031
  </div>
@@ -837,7 +1037,17 @@ export default function ChatPanel({
837
1037
  <div className="message user-message">
838
1038
  <div className="bubble-wrapper">
839
1039
  <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
840
- <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1040
+ <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1041
+ <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
1042
+ <button
1043
+ type="button"
1044
+ className={`msg-action-btn copy-btn ${copiedId === `user-${interaction.id}` ? 'is-copied' : ''}`}
1045
+ onClick={() => handleCopyMessage(`user-${interaction.id}`, interaction.userText)}
1046
+ title={copiedId === `user-${interaction.id}` ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy)'}
1047
+ >
1048
+ {renderCopyIcon(copiedId === `user-${interaction.id}`)}
1049
+ </button>
1050
+ </div>
841
1051
  </div>
842
1052
  </div>
843
1053
  )}
@@ -858,32 +1068,30 @@ export default function ChatPanel({
858
1068
  />
859
1069
  )
860
1070
  })()}
861
- <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
1071
+ <div className="message-bubble">{renderFormattedMessage(interaction.aiText)}</div>
862
1072
  {renderWebSearchSources(interaction)}
863
1073
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
864
1074
  <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
865
1075
  {fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
866
1076
  <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
867
- <button
868
- type="button"
869
- className={`tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
870
- onClick={() => speak(interaction.aiText)}
871
- style={{
872
- background: 'transparent',
873
- border: 'none',
874
- color: speakingText === interaction.aiText ? 'var(--accent)' : 'var(--text-soft)',
875
- cursor: 'pointer',
876
- fontSize: '0.85rem',
877
- padding: '2px',
878
- display: 'inline-flex',
879
- alignItems: 'center',
880
- opacity: 0.7,
881
- transition: 'all 0.2s',
882
- }}
883
- title={speakingText === interaction.aiText ? "Stop reading" : "Read aloud"}
884
- >
885
- {renderSpeakerIcon(speakingText === interaction.aiText)}
886
- </button>
1077
+ <div className="message-action-buttons" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto' }}>
1078
+ <button
1079
+ type="button"
1080
+ className={`msg-action-btn copy-btn ${copiedId === interaction.id ? 'is-copied' : ''}`}
1081
+ onClick={() => handleCopyMessage(interaction.id, interaction.aiText)}
1082
+ title={copiedId === interaction.id ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy message)'}
1083
+ >
1084
+ {renderCopyIcon(copiedId === interaction.id)}
1085
+ </button>
1086
+ <button
1087
+ type="button"
1088
+ className={`msg-action-btn tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
1089
+ onClick={() => speak(interaction.aiText)}
1090
+ title={speakingText === interaction.aiText ? 'Stop reading' : 'Read aloud'}
1091
+ >
1092
+ {renderSpeakerIcon(speakingText === interaction.aiText)}
1093
+ </button>
1094
+ </div>
887
1095
  </div>
888
1096
  </div>
889
1097
  </div>
@@ -893,7 +1101,7 @@ export default function ChatPanel({
893
1101
 
894
1102
  {sending && (
895
1103
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
896
- <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{sendingImageMarkers ? renderFormattedMessage(`${sendingMessage} ${sendingImageMarkers}`) : renderFormattedMessage(sendingMessage)}</div></div></div>
1104
+ <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{renderFormattedMessage([sendingMessage, sendingImageMarkers, sendingVideoMarkers].filter(Boolean).join(' '))}</div></div></div>
897
1105
  {agentMode && (
898
1106
  <AgentActivityDrawer
899
1107
  activityView={agentActivities}
@@ -952,26 +1160,24 @@ export default function ChatPanel({
952
1160
  <button className="provider-badge">{badge(streamedResponse.provider, streamedResponse.model)}</button>
953
1161
  {activeFallbackNotice && <span className="provider-fallback-notice">{activeFallbackNotice}</span>}
954
1162
  {streamedReply && (
955
- <button
956
- type="button"
957
- className={`tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
958
- onClick={() => speak(streamedReply)}
959
- style={{
960
- background: 'transparent',
961
- border: 'none',
962
- color: speakingText === streamedReply ? 'var(--accent)' : 'var(--text-soft)',
963
- cursor: 'pointer',
964
- fontSize: '0.85rem',
965
- padding: '2px',
966
- display: 'inline-flex',
967
- alignItems: 'center',
968
- opacity: 0.7,
969
- transition: 'all 0.2s',
970
- }}
971
- title={speakingText === streamedReply ? "Stop reading" : "Read aloud"}
972
- >
973
- {renderSpeakerIcon(speakingText === streamedReply)}
974
- </button>
1163
+ <div className="message-action-buttons" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto' }}>
1164
+ <button
1165
+ type="button"
1166
+ className={`msg-action-btn copy-btn ${copiedId === 'live' ? 'is-copied' : ''}`}
1167
+ onClick={() => handleCopyMessage('live', streamedReply)}
1168
+ title={copiedId === 'live' ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy message)'}
1169
+ >
1170
+ {renderCopyIcon(copiedId === 'live')}
1171
+ </button>
1172
+ <button
1173
+ type="button"
1174
+ className={`msg-action-btn tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
1175
+ onClick={() => speak(streamedReply)}
1176
+ title={speakingText === streamedReply ? 'Stop reading' : 'Read aloud'}
1177
+ >
1178
+ {renderSpeakerIcon(speakingText === streamedReply)}
1179
+ </button>
1180
+ </div>
975
1181
  )}
976
1182
  </div>
977
1183
  )}
@@ -1022,6 +1228,65 @@ export default function ChatPanel({
1022
1228
  </div>
1023
1229
  )}
1024
1230
 
1231
+ {showSuggestionMenu && (
1232
+ <div className="slash-suggestions-popup" ref={slashMenuRef}>
1233
+ <div className="slash-suggestions-header">
1234
+ {isSlashInput ? 'Slash Commands' : isSkillInput ? 'Learned Skills' : 'Context Mentions'} ({slashSelectedIndex + 1}/{suggestionCount})
1235
+ </div>
1236
+ <div className="slash-suggestions-list" ref={slashListRef}>
1237
+ {isSlashInput
1238
+ ? filteredSlashCommands.map((item, idx) => (
1239
+ <button
1240
+ key={item.command}
1241
+ type="button"
1242
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1243
+ onMouseDown={(e) => {
1244
+ e.preventDefault()
1245
+ selectSlashCommand(item.command)
1246
+ }}
1247
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1248
+ >
1249
+ <span className="slash-cmd-name">{item.command}</span>
1250
+ <span className="slash-cmd-desc">{item.description}</span>
1251
+ </button>
1252
+ ))
1253
+ : isSkillInput
1254
+ ? filteredSkills.map((item, idx) => (
1255
+ <button
1256
+ key={item.id || item.name}
1257
+ type="button"
1258
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1259
+ onMouseDown={(e) => {
1260
+ e.preventDefault()
1261
+ selectSkillCommand(item.name)
1262
+ }}
1263
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1264
+ >
1265
+ <span className="slash-cmd-name">${item.name}</span>
1266
+ <span className="skill-badge">[Skill]</span>
1267
+ <span className="slash-cmd-desc">{item.description || item.content?.slice(0, 60)}</span>
1268
+ </button>
1269
+ ))
1270
+ : filteredContexts.map((item, idx) => (
1271
+ <button
1272
+ key={item.label}
1273
+ type="button"
1274
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1275
+ onMouseDown={(e) => {
1276
+ e.preventDefault()
1277
+ selectAtCommand(item.label)
1278
+ }}
1279
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1280
+ >
1281
+ <span className="slash-cmd-name">{item.label}</span>
1282
+ <span className="skill-badge">[Context]</span>
1283
+ <span className="slash-cmd-desc">{item.desc}</span>
1284
+ </button>
1285
+ ))}
1286
+ </div>
1287
+ </div>
1288
+ )}
1289
+
1025
1290
  <form
1026
1291
  id="chat-form"
1027
1292
  onSubmit={onSubmit}
@@ -1029,7 +1294,7 @@ export default function ChatPanel({
1029
1294
  if (onPasteImage(event.clipboardData)) event.preventDefault()
1030
1295
  }}
1031
1296
  >
1032
- {(imageAttachments.length > 0 || documentName) && (
1297
+ {(imageAttachments.length > 0 || videoAttachments.length > 0 || documentName) && (
1033
1298
  <div className="mint-attachment">
1034
1299
  {imageAttachments.map((attachment, idx) => (
1035
1300
  <div className="mint-image-attachment" key={idx}>
@@ -1037,6 +1302,17 @@ export default function ChatPanel({
1037
1302
  <button className="mint-attachment-remove" type="button" onClick={() => onRemoveImage(idx)} aria-label="Remove image">×</button>
1038
1303
  </div>
1039
1304
  ))}
1305
+ {videoAttachments.map((attachment, idx) => (
1306
+ <div className="mint-image-attachment" key={idx}>
1307
+ <video className="mint-image-preview" src={attachment.dataUri} muted playsInline preload="metadata" />
1308
+ <div className="mint-video-play-indicator" style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', pointerEvents: 'none', background: 'rgba(0,0,0,0.6)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '22px', height: '22px', border: '1.5px solid white' }}>
1309
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="white" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1310
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
1311
+ </svg>
1312
+ </div>
1313
+ <button className="mint-attachment-remove" type="button" onClick={() => onRemoveVideo(idx)} aria-label="Remove video">×</button>
1314
+ </div>
1315
+ ))}
1040
1316
  {documentName && (
1041
1317
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
1042
1318
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--accent)' }}>
@@ -1055,6 +1331,7 @@ export default function ChatPanel({
1055
1331
  )}
1056
1332
  <textarea
1057
1333
  id="chat-input"
1334
+ ref={textareaRef}
1058
1335
  value={message}
1059
1336
  onChange={(event) => {
1060
1337
  resizeInput(event.currentTarget)
@@ -1065,47 +1342,57 @@ export default function ChatPanel({
1065
1342
  rows={1}
1066
1343
  />
1067
1344
  <div className="chat-tool-menu-wrap" ref={toolMenuRef}>
1068
- <button id="chat-tool-btn" type="button" aria-haspopup="menu" aria-expanded={toolMenuOpen} onClick={() => setToolMenuOpen((open) => !open)} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
1069
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
1070
- <line x1="12" y1="5" x2="12" y2="19"></line>
1071
- <line x1="5" y1="12" x2="19" y2="12"></line>
1072
- </svg>
1073
- </button>
1074
- {toolMenuOpen && (
1075
- <div className="chat-tool-menu" role="menu">
1076
- <button type="button" role="menuitem" onClick={openImagePicker}>
1077
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1078
- <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1079
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
1080
- <circle cx="8.5" cy="8.5" r="1.5"></circle>
1081
- <polyline points="21 15 16 10 5 21"></polyline>
1082
- </svg>
1083
- </span>
1084
- <span>Add image</span>
1085
- </button>
1086
- <button type="button" role="menuitem" onClick={openDocumentPicker}>
1087
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1088
- <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1089
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1090
- <polyline points="14 2 14 8 20 8"></polyline>
1091
- </svg>
1092
- </span>
1093
- <span>Add file</span>
1094
- </button>
1095
- <button type="button" role="menuitem" onClick={startWebSearch}>
1096
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1097
- <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1098
- <circle cx="12" cy="12" r="10"></circle>
1099
- <line x1="2" y1="12" x2="22" y2="12"></line>
1100
- <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
1101
- </svg>
1102
- </span>
1103
- <span>Search web</span>
1104
- </button>
1105
- </div>
1106
- )}
1107
- </div>
1345
+ <button id="chat-tool-btn" type="button" aria-haspopup="menu" aria-expanded={toolMenuOpen} onClick={() => setToolMenuOpen((open) => !open)} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
1346
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
1347
+ <line x1="12" y1="5" x2="12" y2="19"></line>
1348
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1349
+ </svg>
1350
+ </button>
1351
+ {toolMenuOpen && (
1352
+ <div className="chat-tool-menu" role="menu">
1353
+ <button type="button" role="menuitem" onClick={openImagePicker}>
1354
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1355
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1356
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
1357
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
1358
+ <polyline points="21 15 16 10 5 21"></polyline>
1359
+ </svg>
1360
+ </span>
1361
+ <span>Add image</span>
1362
+ </button>
1363
+ <button type="button" role="menuitem" onClick={openVideoPicker}>
1364
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1365
+ <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">
1366
+ <polygon points="23 7 16 12 23 17 23 7"></polygon>
1367
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
1368
+ </svg>
1369
+ </span>
1370
+ <span>Add video</span>
1371
+ </button>
1372
+ <button type="button" role="menuitem" onClick={openDocumentPicker}>
1373
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1374
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1375
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1376
+ <polyline points="14 2 14 8 20 8"></polyline>
1377
+ </svg>
1378
+ </span>
1379
+ <span>Add file</span>
1380
+ </button>
1381
+ <button type="button" role="menuitem" onClick={startWebSearch}>
1382
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1383
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1384
+ <circle cx="12" cy="12" r="10"></circle>
1385
+ <line x1="2" y1="12" x2="22" y2="12"></line>
1386
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
1387
+ </svg>
1388
+ </span>
1389
+ <span>Search web</span>
1390
+ </button>
1391
+ </div>
1392
+ )}
1393
+ </div>
1108
1394
  <input id="vision-file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={onSelectImage} style={{ display: 'none' }} />
1395
+ <input id="video-file-input" type="file" accept="video/mp4,video/webm,video/quicktime,video/x-matroska" onChange={onSelectVideo} style={{ display: 'none' }} />
1109
1396
  <input id="document-file-input" type="file" accept="application/pdf,.pdf" onChange={onSelectDocument} style={{ display: 'none' }} />
1110
1397
  <div className="chat-provider-select" style={{ display: 'flex', gap: '4px', padding: 0, background: 'transparent', border: 0, width: '100%', height: '32px' }}>
1111
1398
  <select
@@ -1137,6 +1424,11 @@ export default function ChatPanel({
1137
1424
  else if (provider === 'huggingface') displayName = 'HF'
1138
1425
  else if (provider === 'local_openai') displayName = 'Local'
1139
1426
  else if (provider === 'ollama') displayName = 'Ollama'
1427
+ else if (provider.startsWith('custom:')) {
1428
+ const id = provider.replace(/^custom:/, '')
1429
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
1430
+ displayName = cp?.displayName || id
1431
+ }
1140
1432
  return <option key={provider} value={provider}>{displayName}</option>
1141
1433
  })}
1142
1434
  </select>