llms-py 2.0.18__py3-none-any.whl → 2.0.33__py3-none-any.whl

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 (41) hide show
  1. llms/index.html +17 -1
  2. llms/llms.json +1132 -1075
  3. llms/main.py +561 -103
  4. llms/ui/Analytics.mjs +115 -104
  5. llms/ui/App.mjs +81 -4
  6. llms/ui/Avatar.mjs +61 -4
  7. llms/ui/Brand.mjs +29 -11
  8. llms/ui/ChatPrompt.mjs +163 -16
  9. llms/ui/Main.mjs +177 -94
  10. llms/ui/ModelSelector.mjs +28 -10
  11. llms/ui/OAuthSignIn.mjs +92 -0
  12. llms/ui/ProviderStatus.mjs +12 -12
  13. llms/ui/Recents.mjs +13 -13
  14. llms/ui/SettingsDialog.mjs +65 -65
  15. llms/ui/Sidebar.mjs +24 -19
  16. llms/ui/SystemPromptEditor.mjs +5 -5
  17. llms/ui/SystemPromptSelector.mjs +26 -6
  18. llms/ui/Welcome.mjs +2 -2
  19. llms/ui/ai.mjs +69 -5
  20. llms/ui/app.css +548 -34
  21. llms/ui/lib/servicestack-vue.mjs +9 -9
  22. llms/ui/markdown.mjs +8 -8
  23. llms/ui/tailwind.input.css +2 -0
  24. llms/ui/threadStore.mjs +39 -0
  25. llms/ui/typography.css +54 -36
  26. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/METADATA +403 -47
  27. llms_py-2.0.33.dist-info/RECORD +48 -0
  28. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/licenses/LICENSE +1 -2
  29. llms/__pycache__/__init__.cpython-312.pyc +0 -0
  30. llms/__pycache__/__init__.cpython-313.pyc +0 -0
  31. llms/__pycache__/__init__.cpython-314.pyc +0 -0
  32. llms/__pycache__/__main__.cpython-312.pyc +0 -0
  33. llms/__pycache__/__main__.cpython-314.pyc +0 -0
  34. llms/__pycache__/llms.cpython-312.pyc +0 -0
  35. llms/__pycache__/main.cpython-312.pyc +0 -0
  36. llms/__pycache__/main.cpython-313.pyc +0 -0
  37. llms/__pycache__/main.cpython-314.pyc +0 -0
  38. llms_py-2.0.18.dist-info/RECORD +0 -56
  39. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/WHEEL +0 -0
  40. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/entry_points.txt +0 -0
  41. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/top_level.txt +0 -0
llms/ui/Avatar.mjs CHANGED
@@ -1,13 +1,40 @@
1
- import { computed, inject } from "vue"
1
+ import { computed, inject, ref, onMounted, onUnmounted } from "vue"
2
2
 
3
3
  export default {
4
4
  template:`
5
- <div v-if="$ai.auth?.profileUrl" :title="authTitle">
6
- <img :src="$ai.auth.profileUrl" class="size-8 rounded-full" />
5
+ <div v-if="$ai.auth?.profileUrl" class="relative" ref="avatarContainer">
6
+ <img
7
+ @click.stop="toggleMenu"
8
+ :src="$ai.auth.profileUrl"
9
+ :title="authTitle"
10
+ class="size-8 rounded-full cursor-pointer hover:ring-2 hover:ring-gray-300"
11
+ />
12
+ <div
13
+ v-if="showMenu"
14
+ @click.stop
15
+ class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-50 border border-gray-200 dark:border-gray-700"
16
+ >
17
+ <div class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
18
+ <div class="font-medium whitespace-nowrap overflow-hidden text-ellipsis">{{ $ai.auth.displayName || $ai.auth.userName }}</div>
19
+ <div class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap overflow-hidden text-ellipsis">{{ $ai.auth.email }}</div>
20
+ </div>
21
+ <button type="button"
22
+ @click="handleLogout"
23
+ class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center whitespace-nowrap"
24
+ >
25
+ <svg class="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
26
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
27
+ </svg>
28
+ Sign Out
29
+ </button>
30
+ </div>
7
31
  </div>
8
32
  `,
9
33
  setup() {
10
34
  const ai = inject('ai')
35
+ const showMenu = ref(false)
36
+ const avatarContainer = ref(null)
37
+
11
38
  const authTitle = computed(() => {
12
39
  if (!ai.auth) return ''
13
40
  const { userId, userName, displayName, bearerToken, roles } = ai.auth
@@ -20,9 +47,39 @@ export default {
20
47
  ]
21
48
  return sb.filter(x => x).join('\n')
22
49
  })
23
-
50
+
51
+ function toggleMenu() {
52
+ showMenu.value = !showMenu.value
53
+ }
54
+
55
+ async function handleLogout() {
56
+ showMenu.value = false
57
+ await ai.signOut()
58
+ // Reload the page to show sign-in screen
59
+ window.location.reload()
60
+ }
61
+
62
+ // Close menu when clicking outside
63
+ const handleClickOutside = (event) => {
64
+ if (showMenu.value && avatarContainer.value && !avatarContainer.value.contains(event.target)) {
65
+ showMenu.value = false
66
+ }
67
+ }
68
+
69
+ onMounted(() => {
70
+ document.addEventListener('click', handleClickOutside)
71
+ })
72
+
73
+ onUnmounted(() => {
74
+ document.removeEventListener('click', handleClickOutside)
75
+ })
76
+
24
77
  return {
25
78
  authTitle,
79
+ handleLogout,
80
+ showMenu,
81
+ toggleMenu,
82
+ avatarContainer,
26
83
  }
27
84
  }
28
85
  }
llms/ui/Brand.mjs CHANGED
@@ -1,19 +1,37 @@
1
1
  export default {
2
2
  template:`
3
- <div class="flex-shrink-0 px-4 py-4 border-b border-gray-200 bg-white min-h-16 select-none">
3
+ <div class="flex-shrink-0 pl-2 pr-4 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 min-h-16 select-none">
4
4
  <div class="flex items-center justify-between">
5
- <button type="button"
6
- @click="$emit('home')"
7
- class="text-lg font-semibold text-gray-900 hover:text-blue-600 focus:outline-none transition-colors"
8
- title="Go back to initial state"
9
- >
10
- History
11
- </button>
5
+ <div class="flex items-center space-x-2">
6
+ <button type="button"
7
+ @click="$emit('toggle-sidebar')"
8
+ class="group relative text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
9
+ title="Collapse sidebar"
10
+ >
11
+ <div class="relative size-5">
12
+ <!-- Default sidebar icon -->
13
+ <svg class="absolute inset-0 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
14
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
15
+ <line x1="9" y1="3" x2="9" y2="21"></line>
16
+ </svg>
17
+ <!-- Hover state: |← icon -->
18
+ <svg class="absolute inset-0 hidden group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m10.071 4.929l1.414 1.414L6.828 11H16v2H6.828l4.657 4.657l-1.414 1.414L3 12zM18.001 19V5h2v14z"/></svg>
19
+ </div>
20
+ </button>
21
+
22
+ <button type="button"
23
+ @click="$emit('home')"
24
+ class="text-lg font-semibold text-gray-900 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
25
+ title="Go back to initial state"
26
+ >
27
+ History
28
+ </button>
29
+ </div>
12
30
 
13
31
  <div class="flex items-center space-x-2">
14
32
  <button type="button"
15
33
  @click="$emit('analytics')"
16
- class="text-gray-900 hover:text-blue-600 focus:outline-none transition-colors"
34
+ class="text-gray-900 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
17
35
  title="Analytics"
18
36
  >
19
37
  <svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M5 22a1 1 0 0 1-1-1v-8a1 1 0 0 1 2 0v8a1 1 0 0 1-1 1m5 0a1 1 0 0 1-1-1V3a1 1 0 0 1 2 0v18a1 1 0 0 1-1 1m5 0a1 1 0 0 1-1-1V9a1 1 0 0 1 2 0v12a1 1 0 0 1-1 1m5 0a1 1 0 0 1-1-1v-4a1 1 0 0 1 2 0v4a1 1 0 0 1-1 1"/></svg>
@@ -21,7 +39,7 @@ export default {
21
39
 
22
40
  <button type="button"
23
41
  @click="$emit('new')"
24
- class="text-gray-900 hover:text-blue-600 focus:outline-none transition-colors"
42
+ class="text-gray-900 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
25
43
  title="New Chat"
26
44
  >
27
45
  <svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></g></svg>
@@ -30,5 +48,5 @@ export default {
30
48
  </div>
31
49
  </div>
32
50
  `,
33
- emits:['home','new','analytics'],
51
+ emits:['home','new','analytics','toggle-sidebar'],
34
52
  }
llms/ui/ChatPrompt.mjs CHANGED
@@ -11,6 +11,7 @@ export function useChatPrompt() {
11
11
  const attachedFiles = ref([])
12
12
  const isGenerating = ref(false)
13
13
  const errorStatus = ref(null)
14
+ const abortController = ref(null)
14
15
  const hasImage = () => attachedFiles.value.some(f => imageExts.includes(lastRightPart(f.name, '.')))
15
16
  const hasAudio = () => attachedFiles.value.some(f => audioExts.includes(lastRightPart(f.name, '.')))
16
17
  const hasFile = () => attachedFiles.value.length > 0
@@ -21,6 +22,17 @@ export function useChatPrompt() {
21
22
  isGenerating.value = false
22
23
  attachedFiles.value = []
23
24
  messageText.value = ''
25
+ abortController.value = null
26
+ }
27
+
28
+ function cancel() {
29
+ // Cancel the pending request
30
+ if (abortController.value) {
31
+ abortController.value.abort()
32
+ }
33
+ // Reset UI state
34
+ isGenerating.value = false
35
+ abortController.value = null
24
36
  }
25
37
 
26
38
  return {
@@ -28,6 +40,7 @@ export function useChatPrompt() {
28
40
  attachedFiles,
29
41
  errorStatus,
30
42
  isGenerating,
43
+ abortController,
31
44
  get generating() {
32
45
  return isGenerating.value
33
46
  },
@@ -36,6 +49,7 @@ export function useChatPrompt() {
36
49
  hasFile,
37
50
  // hasText,
38
51
  reset,
52
+ cancel,
39
53
  }
40
54
  }
41
55
 
@@ -50,7 +64,7 @@ export default {
50
64
  <button type="button"
51
65
  @click="triggerFilePicker"
52
66
  :disabled="isGenerating || !model"
53
- class="size-8 flex items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
67
+ class="size-8 flex items-center justify-center rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed"
54
68
  title="Attach image or audio">
55
69
  <svg class="size-5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256">
56
70
  <path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"></path>
@@ -64,8 +78,8 @@ export default {
64
78
  <div>
65
79
  <button type="button" title="Settings" @click="showSettings = true"
66
80
  :disabled="isGenerating || !model"
67
- class="size-8 flex items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed">
68
- <svg class="size-4 text-gray-600 disabled:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H199a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h97a32,32,0,0,0,62,0h17a8,8,0,0,0,0-16Zm-48,24a16,16,0,1,1,16-16A16,16,0,0,1,168,192Z"></path></svg>
81
+ class="size-8 flex items-center justify-center rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed">
82
+ <svg class="size-4 text-gray-600 dark:text-gray-400 disabled:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H199a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h97a32,32,0,0,0,62,0h17a8,8,0,0,0,0-16Zm-48,24a16,16,0,1,1,16-16A16,16,0,0,1,168,192Z"></path></svg>
69
83
  </button>
70
84
  </div>
71
85
  </div>
@@ -73,38 +87,50 @@ export default {
73
87
  <div class="flex-1">
74
88
  <div class="relative">
75
89
  <textarea
76
- ref="messageInput"
90
+ ref="refMessage"
77
91
  v-model="messageText"
78
92
  @keydown.enter.exact.prevent="sendMessage"
79
93
  @keydown.enter.shift.exact="addNewLine"
80
- placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
94
+ @paste="onPaste"
95
+ @dragover="onDragOver"
96
+ @dragleave="onDragLeave"
97
+ @drop="onDrop"
98
+ placeholder="Type message... (Enter to send, Shift+Enter for new line, drag & drop or paste files)"
81
99
  rows="3"
82
- class="block w-full rounded-md border border-gray-300 px-3 py-2 pr-12 text-sm placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
100
+ :class="[
101
+ 'block w-full rounded-md border px-3 py-2 pr-12 text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-900 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-1',
102
+ isDragging
103
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-500'
104
+ : 'border-gray-300 dark:border-gray-600 focus:border-blue-500 focus:ring-blue-500'
105
+ ]"
83
106
  :disabled="isGenerating || !model"
84
107
  ></textarea>
85
- <button title="Send (Enter)" type="button"
108
+ <button v-if="!isGenerating" title="Send (Enter)" type="button"
86
109
  @click="sendMessage"
87
110
  :disabled="!messageText.trim() || isGenerating || !model"
88
- class="absolute bottom-2 right-2 size-8 flex items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed disabled:border-gray-200 transition-colors">
89
- <svg v-if="isGenerating" class="size-5 animate-spin" fill="none" viewBox="0 0 24 24">
90
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
91
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
111
+ class="absolute bottom-2 right-2 size-8 flex items-center justify-center rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed disabled:border-gray-200 dark:disabled:border-gray-700 transition-colors">
112
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="20" stroke-dashoffset="20" d="M12 21l0 -17.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="20;0"/></path><path stroke-dasharray="12" stroke-dashoffset="12" d="M12 3l7 7M12 3l-7 7"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="12;0"/></path></g></svg>
113
+ </button>
114
+ <button v-else title="Cancel request" type="button"
115
+ @click="cancelRequest"
116
+ class="absolute bottom-2 right-2 size-8 flex items-center justify-center rounded-md border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors">
117
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
118
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
92
119
  </svg>
93
- <svg v-else class="size-5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="20" stroke-dashoffset="20" d="M12 21l0 -17.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="20;0"/></path><path stroke-dasharray="12" stroke-dashoffset="12" d="M12 3l7 7M12 3l-7 7"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="12;0"/></path></g></svg>
94
120
  </button>
95
121
  </div>
96
122
 
97
123
  <!-- Attached files preview -->
98
124
  <div v-if="attachedFiles.length" class="mt-2 flex flex-wrap gap-2">
99
- <div v-for="(f,i) in attachedFiles" :key="i" class="flex items-center gap-2 px-2 py-1 rounded-md border border-gray-300 text-xs text-gray-700 bg-gray-50">
125
+ <div v-for="(f,i) in attachedFiles" :key="i" class="flex items-center gap-2 px-2 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-xs text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800">
100
126
  <span class="truncate max-w-48" :title="f.name">{{ f.name }}</span>
101
- <button type="button" class="text-gray-500 hover:text-gray-700" @click="removeAttachment(i)" title="Remove Attachment">
127
+ <button type="button" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" @click="removeAttachment(i)" title="Remove Attachment">
102
128
  <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
103
129
  </button>
104
130
  </div>
105
131
  </div>
106
132
 
107
- <div v-if="!model" class="mt-2 text-sm text-red-600">
133
+ <div v-if="!model" class="mt-2 text-sm text-red-600 dark:text-red-400">
108
134
  Please select a model
109
135
  </div>
110
136
  </div>
@@ -142,6 +168,7 @@ export default {
142
168
  } = threads
143
169
 
144
170
  const fileInput = ref(null)
171
+ const refMessage = ref(null)
145
172
  const showSettings = ref(false)
146
173
  const { applySettings } = chatSettings
147
174
 
@@ -169,6 +196,93 @@ export default {
169
196
  attachedFiles.value.splice(i, 1)
170
197
  }
171
198
 
199
+ // Helper function to add files and set default message
200
+ const addFilesAndSetMessage = (files) => {
201
+ if (files.length === 0) return
202
+
203
+ attachedFiles.value.push(...files)
204
+
205
+ // Set default message text if empty
206
+ if (!messageText.value.trim()) {
207
+ if (hasImage()) {
208
+ messageText.value = getTextContent(config.defaults.image)
209
+ } else if (hasAudio()) {
210
+ messageText.value = getTextContent(config.defaults.audio)
211
+ } else {
212
+ messageText.value = getTextContent(config.defaults.file)
213
+ }
214
+ }
215
+ }
216
+
217
+ // Handle paste events for clipboard images, audio, and files
218
+ const onPaste = async (e) => {
219
+ // Use the paste event's clipboardData directly (works best for paste events)
220
+ const items = e.clipboardData?.items
221
+ if (!items) return
222
+
223
+ const files = []
224
+
225
+ // Check all clipboard items
226
+ for (let i = 0; i < items.length; i++) {
227
+ const item = items[i]
228
+
229
+ // Handle files (images, audio, etc.)
230
+ if (item.kind === 'file') {
231
+ const file = item.getAsFile()
232
+ if (file) {
233
+ // Generate a better filename based on type
234
+ let filename = file.name
235
+ if (!filename || filename === 'image.png' || filename === 'blob') {
236
+ const ext = file.type.split('/')[1] || 'png'
237
+ const timestamp = new Date().getTime()
238
+ if (file.type.startsWith('image/')) {
239
+ filename = `pasted-image-${timestamp}.${ext}`
240
+ } else if (file.type.startsWith('audio/')) {
241
+ filename = `pasted-audio-${timestamp}.${ext}`
242
+ } else {
243
+ filename = `pasted-file-${timestamp}.${ext}`
244
+ }
245
+ // Create a new File object with the better name
246
+ files.push(new File([file], filename, { type: file.type }))
247
+ } else {
248
+ files.push(file)
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ if (files.length > 0) {
255
+ e.preventDefault()
256
+ addFilesAndSetMessage(files)
257
+ }
258
+ }
259
+
260
+ // Handle drag and drop events
261
+ const isDragging = ref(false)
262
+
263
+ const onDragOver = (e) => {
264
+ e.preventDefault()
265
+ e.stopPropagation()
266
+ isDragging.value = true
267
+ }
268
+
269
+ const onDragLeave = (e) => {
270
+ e.preventDefault()
271
+ e.stopPropagation()
272
+ isDragging.value = false
273
+ }
274
+
275
+ const onDrop = (e) => {
276
+ e.preventDefault()
277
+ e.stopPropagation()
278
+ isDragging.value = false
279
+
280
+ const files = Array.from(e.dataTransfer?.files || [])
281
+ if (files.length > 0) {
282
+ addFilesAndSetMessage(files)
283
+ }
284
+ }
285
+
172
286
  function createChatRequest() {
173
287
  if (hasImage()) {
174
288
  return deepClone(config.defaults.image)
@@ -208,6 +322,10 @@ export default {
208
322
  }
209
323
  messageText.value = ''
210
324
 
325
+ // Create AbortController for this request
326
+ const controller = new AbortController()
327
+ chatPrompt.abortController.value = controller
328
+
211
329
  try {
212
330
  let threadId
213
331
 
@@ -338,11 +456,15 @@ export default {
338
456
  }))
339
457
  }
340
458
 
459
+ chatRequest.metadata ??= {}
460
+ chatRequest.metadata.threadId = threadId
461
+
341
462
  // Send to API
342
463
  console.debug('chatRequest', chatRequest)
343
464
  const startTime = Date.now()
344
465
  const response = await ai.post('/v1/chat/completions', {
345
- body: JSON.stringify(chatRequest)
466
+ body: JSON.stringify(chatRequest),
467
+ signal: controller.signal
346
468
  })
347
469
 
348
470
  let result = null
@@ -417,11 +539,29 @@ export default {
417
539
  attachedFiles.value = []
418
540
  // Error will be cleared when user sends next message (no auto-timeout)
419
541
  }
542
+ } catch (error) {
543
+ // Check if the error is due to abort
544
+ if (error.name === 'AbortError') {
545
+ console.log('Request was cancelled by user')
546
+ // Don't show error for cancelled requests
547
+ } else {
548
+ // Re-throw other errors to be handled by outer catch
549
+ throw error
550
+ }
420
551
  } finally {
421
552
  isGenerating.value = false
553
+ chatPrompt.abortController.value = null
554
+ // Restore focus to the textarea
555
+ nextTick(() => {
556
+ refMessage.value?.focus()
557
+ })
422
558
  }
423
559
  }
424
560
 
561
+ const cancelRequest = () => {
562
+ chatPrompt.cancel()
563
+ }
564
+
425
565
  const addNewLine = () => {
426
566
  // Enter key already adds new line
427
567
  //messageText.value += '\n'
@@ -432,11 +572,18 @@ export default {
432
572
  attachedFiles,
433
573
  messageText,
434
574
  fileInput,
575
+ refMessage,
435
576
  showSettings,
577
+ isDragging,
436
578
  triggerFilePicker,
437
579
  onFilesSelected,
580
+ onPaste,
581
+ onDragOver,
582
+ onDragLeave,
583
+ onDrop,
438
584
  removeAttachment,
439
585
  sendMessage,
586
+ cancelRequest,
440
587
  addNewLine,
441
588
  }
442
589
  }