llms-py 3.0.0__py3-none-any.whl → 3.0.0b1__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 (206) hide show
  1. llms/index.html +77 -35
  2. llms/llms.json +23 -72
  3. llms/main.py +732 -1786
  4. llms/providers.json +1 -1
  5. llms/{extensions/analytics/ui/index.mjs → ui/Analytics.mjs} +238 -154
  6. llms/ui/App.mjs +60 -151
  7. llms/ui/Avatar.mjs +85 -0
  8. llms/ui/Brand.mjs +52 -0
  9. llms/ui/ChatPrompt.mjs +606 -0
  10. llms/ui/Main.mjs +873 -0
  11. llms/ui/ModelSelector.mjs +693 -0
  12. llms/ui/OAuthSignIn.mjs +92 -0
  13. llms/ui/ProviderIcon.mjs +36 -0
  14. llms/ui/ProviderStatus.mjs +105 -0
  15. llms/{extensions/app/ui → ui}/Recents.mjs +65 -91
  16. llms/ui/{modules/chat/SettingsDialog.mjs → SettingsDialog.mjs} +9 -9
  17. llms/{extensions/app/ui/index.mjs → ui/Sidebar.mjs} +58 -124
  18. llms/ui/SignIn.mjs +64 -0
  19. llms/ui/SystemPromptEditor.mjs +31 -0
  20. llms/ui/SystemPromptSelector.mjs +56 -0
  21. llms/ui/Welcome.mjs +8 -0
  22. llms/ui/ai.mjs +53 -125
  23. llms/ui/app.css +111 -1837
  24. llms/ui/lib/charts.mjs +13 -9
  25. llms/ui/lib/servicestack-vue.mjs +3 -3
  26. llms/ui/lib/vue.min.mjs +9 -10
  27. llms/ui/lib/vue.mjs +1602 -1763
  28. llms/ui/markdown.mjs +2 -10
  29. llms/ui/tailwind.input.css +80 -496
  30. llms/ui/threadStore.mjs +572 -0
  31. llms/ui/utils.mjs +117 -113
  32. llms/ui.json +1069 -0
  33. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/METADATA +1 -1
  34. llms_py-3.0.0b1.dist-info/RECORD +49 -0
  35. llms/__pycache__/__init__.cpython-312.pyc +0 -0
  36. llms/__pycache__/__init__.cpython-313.pyc +0 -0
  37. llms/__pycache__/__init__.cpython-314.pyc +0 -0
  38. llms/__pycache__/__main__.cpython-312.pyc +0 -0
  39. llms/__pycache__/__main__.cpython-314.pyc +0 -0
  40. llms/__pycache__/llms.cpython-312.pyc +0 -0
  41. llms/__pycache__/main.cpython-312.pyc +0 -0
  42. llms/__pycache__/main.cpython-313.pyc +0 -0
  43. llms/__pycache__/main.cpython-314.pyc +0 -0
  44. llms/__pycache__/plugins.cpython-314.pyc +0 -0
  45. llms/extensions/app/README.md +0 -20
  46. llms/extensions/app/__init__.py +0 -530
  47. llms/extensions/app/__pycache__/__init__.cpython-314.pyc +0 -0
  48. llms/extensions/app/__pycache__/db.cpython-314.pyc +0 -0
  49. llms/extensions/app/__pycache__/db_manager.cpython-314.pyc +0 -0
  50. llms/extensions/app/db.py +0 -644
  51. llms/extensions/app/db_manager.py +0 -195
  52. llms/extensions/app/requests.json +0 -9073
  53. llms/extensions/app/threads.json +0 -15290
  54. llms/extensions/app/ui/threadStore.mjs +0 -411
  55. llms/extensions/core_tools/CALCULATOR.md +0 -32
  56. llms/extensions/core_tools/__init__.py +0 -598
  57. llms/extensions/core_tools/__pycache__/__init__.cpython-314.pyc +0 -0
  58. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +0 -201
  59. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +0 -185
  60. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +0 -101
  61. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +0 -160
  62. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +0 -66
  63. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +0 -27
  64. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +0 -72
  65. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +0 -119
  66. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +0 -98
  67. llms/extensions/core_tools/ui/codemirror/doc/docs.css +0 -225
  68. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  69. llms/extensions/core_tools/ui/codemirror/lib/codemirror.css +0 -344
  70. llms/extensions/core_tools/ui/codemirror/lib/codemirror.js +0 -9884
  71. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +0 -942
  72. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +0 -118
  73. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +0 -962
  74. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +0 -62
  75. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +0 -402
  76. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +0 -40
  77. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +0 -135
  78. llms/extensions/core_tools/ui/index.mjs +0 -650
  79. llms/extensions/gallery/README.md +0 -61
  80. llms/extensions/gallery/__init__.py +0 -61
  81. llms/extensions/gallery/__pycache__/__init__.cpython-314.pyc +0 -0
  82. llms/extensions/gallery/__pycache__/db.cpython-314.pyc +0 -0
  83. llms/extensions/gallery/db.py +0 -298
  84. llms/extensions/gallery/ui/index.mjs +0 -482
  85. llms/extensions/katex/README.md +0 -39
  86. llms/extensions/katex/__init__.py +0 -6
  87. llms/extensions/katex/__pycache__/__init__.cpython-314.pyc +0 -0
  88. llms/extensions/katex/ui/README.md +0 -125
  89. llms/extensions/katex/ui/contrib/auto-render.js +0 -338
  90. llms/extensions/katex/ui/contrib/auto-render.min.js +0 -1
  91. llms/extensions/katex/ui/contrib/auto-render.mjs +0 -244
  92. llms/extensions/katex/ui/contrib/copy-tex.js +0 -127
  93. llms/extensions/katex/ui/contrib/copy-tex.min.js +0 -1
  94. llms/extensions/katex/ui/contrib/copy-tex.mjs +0 -105
  95. llms/extensions/katex/ui/contrib/mathtex-script-type.js +0 -109
  96. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +0 -1
  97. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +0 -24
  98. llms/extensions/katex/ui/contrib/mhchem.js +0 -3213
  99. llms/extensions/katex/ui/contrib/mhchem.min.js +0 -1
  100. llms/extensions/katex/ui/contrib/mhchem.mjs +0 -3109
  101. llms/extensions/katex/ui/contrib/render-a11y-string.js +0 -887
  102. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +0 -1
  103. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +0 -800
  104. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  116. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  117. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  118. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  119. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  120. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  121. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  122. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  123. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  124. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  125. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  126. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  127. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  128. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  129. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  130. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  131. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  132. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  133. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  134. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  135. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  136. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  137. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  138. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  139. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  140. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  141. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  142. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  143. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  144. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  145. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  146. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  147. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  148. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  149. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  150. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  151. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  152. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  153. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  154. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  155. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  156. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  157. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  158. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  159. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  160. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  161. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  162. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  163. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  164. llms/extensions/katex/ui/index.mjs +0 -92
  165. llms/extensions/katex/ui/katex-swap.css +0 -1230
  166. llms/extensions/katex/ui/katex-swap.min.css +0 -1
  167. llms/extensions/katex/ui/katex.css +0 -1230
  168. llms/extensions/katex/ui/katex.js +0 -19080
  169. llms/extensions/katex/ui/katex.min.css +0 -1
  170. llms/extensions/katex/ui/katex.min.js +0 -1
  171. llms/extensions/katex/ui/katex.min.mjs +0 -1
  172. llms/extensions/katex/ui/katex.mjs +0 -18547
  173. llms/extensions/providers/__init__.py +0 -18
  174. llms/extensions/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  175. llms/extensions/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  176. llms/extensions/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  177. llms/extensions/providers/__pycache__/google.cpython-314.pyc +0 -0
  178. llms/extensions/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
  179. llms/extensions/providers/__pycache__/openai.cpython-314.pyc +0 -0
  180. llms/extensions/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  181. llms/extensions/providers/anthropic.py +0 -229
  182. llms/extensions/providers/chutes.py +0 -155
  183. llms/extensions/providers/google.py +0 -378
  184. llms/extensions/providers/nvidia.py +0 -105
  185. llms/extensions/providers/openai.py +0 -156
  186. llms/extensions/providers/openrouter.py +0 -72
  187. llms/extensions/system_prompts/README.md +0 -22
  188. llms/extensions/system_prompts/__init__.py +0 -45
  189. llms/extensions/system_prompts/__pycache__/__init__.cpython-314.pyc +0 -0
  190. llms/extensions/system_prompts/ui/index.mjs +0 -280
  191. llms/extensions/system_prompts/ui/prompts.json +0 -1067
  192. llms/extensions/tools/__init__.py +0 -5
  193. llms/extensions/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  194. llms/extensions/tools/ui/index.mjs +0 -204
  195. llms/providers-extra.json +0 -356
  196. llms/ui/ctx.mjs +0 -365
  197. llms/ui/index.mjs +0 -129
  198. llms/ui/modules/chat/ChatBody.mjs +0 -691
  199. llms/ui/modules/chat/index.mjs +0 -828
  200. llms/ui/modules/layout.mjs +0 -243
  201. llms/ui/modules/model-selector.mjs +0 -851
  202. llms_py-3.0.0.dist-info/RECORD +0 -202
  203. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/WHEEL +0 -0
  204. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/entry_points.txt +0 -0
  205. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
  206. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/top_level.txt +0 -0
llms/ui/ChatPrompt.mjs ADDED
@@ -0,0 +1,606 @@
1
+ import { ref, nextTick, inject, unref } from 'vue'
2
+ import { useRouter } from 'vue-router'
3
+ import { lastRightPart } from '@servicestack/client'
4
+ import { deepClone, fileToDataUri, fileToBase64, addCopyButtons, toModelInfo, tokenCost, uploadFile } from './utils.mjs'
5
+ import { toRaw } from 'vue'
6
+
7
+ const imageExts = 'png,webp,jpg,jpeg,gif,bmp,svg,tiff,ico'.split(',')
8
+ const audioExts = 'mp3,wav,ogg,flac,m4a,opus,webm'.split(',')
9
+
10
+ export function useChatPrompt() {
11
+ const messageText = ref('')
12
+ const attachedFiles = ref([])
13
+ const isGenerating = ref(false)
14
+ const errorStatus = ref(null)
15
+ const abortController = ref(null)
16
+ const hasImage = () => attachedFiles.value.some(f => imageExts.includes(lastRightPart(f.name, '.')))
17
+ const hasAudio = () => attachedFiles.value.some(f => audioExts.includes(lastRightPart(f.name, '.')))
18
+ const hasFile = () => attachedFiles.value.length > 0
19
+ // const hasText = () => !hasImage() && !hasAudio() && !hasFile()
20
+
21
+ const editingMessageId = ref(null)
22
+
23
+ function reset() {
24
+ // Ensure initial state is ready to accept input
25
+ isGenerating.value = false
26
+ attachedFiles.value = []
27
+ messageText.value = ''
28
+ abortController.value = null
29
+ editingMessageId.value = null
30
+ }
31
+
32
+ function cancel() {
33
+ // Cancel the pending request
34
+ if (abortController.value) {
35
+ abortController.value.abort()
36
+ }
37
+ // Reset UI state
38
+ isGenerating.value = false
39
+ abortController.value = null
40
+ }
41
+
42
+ return {
43
+ messageText,
44
+ attachedFiles,
45
+ errorStatus,
46
+ isGenerating,
47
+ abortController,
48
+ editingMessageId,
49
+ get generating() {
50
+ return isGenerating.value
51
+ },
52
+ hasImage,
53
+ hasAudio,
54
+ hasFile,
55
+ // hasText,
56
+ reset,
57
+ cancel,
58
+ }
59
+ }
60
+
61
+ export default {
62
+ template: `
63
+ <div class="mx-auto max-w-3xl">
64
+ <SettingsDialog :isOpen="showSettings" @close="showSettings = false" />
65
+ <div class="flex space-x-2">
66
+ <!-- Attach (+) button and Settings button -->
67
+ <div class="mt-1.5 flex flex-col space-y-1 items-center">
68
+ <div>
69
+ <button type="button"
70
+ @click="triggerFilePicker"
71
+ :disabled="isGenerating || !model"
72
+ 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"
73
+ title="Attach image or audio">
74
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256">
75
+ <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>
76
+ </svg>
77
+ </button>
78
+ <!-- Hidden file input -->
79
+ <input ref="fileInput" type="file" multiple @change="onFilesSelected"
80
+ class="hidden" accept="image/*,audio/*,.pdf,.doc,.docx,.xml,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
81
+ />
82
+ </div>
83
+ <div>
84
+ <button type="button" title="Settings" @click="showSettings = true"
85
+ :disabled="isGenerating || !model"
86
+ 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">
87
+ <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>
88
+ </button>
89
+ </div>
90
+ </div>
91
+
92
+ <div class="flex-1">
93
+ <div class="relative">
94
+ <textarea
95
+ ref="refMessage"
96
+ v-model="messageText"
97
+ @keydown.enter.exact.prevent="sendMessage"
98
+ @keydown.enter.shift.exact="addNewLine"
99
+ @paste="onPaste"
100
+ @dragover="onDragOver"
101
+ @dragleave="onDragLeave"
102
+ @drop="onDrop"
103
+ placeholder="Type message... (Enter to send, Shift+Enter for new line, drag & drop or paste files)"
104
+ rows="3"
105
+ :class="[
106
+ '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',
107
+ isDragging
108
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-500'
109
+ : 'border-gray-300 dark:border-gray-600 focus:border-blue-500 focus:ring-blue-500'
110
+ ]"
111
+ :disabled="isGenerating || !model"
112
+ ></textarea>
113
+ <button v-if="!isGenerating" title="Send (Enter)" type="button"
114
+ @click="sendMessage"
115
+ :disabled="!messageText.trim() || isGenerating || !model"
116
+ 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">
117
+ <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>
118
+ </button>
119
+ <button v-else title="Cancel request" type="button"
120
+ @click="cancelRequest"
121
+ 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">
122
+ <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">
123
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
124
+ </svg>
125
+ </button>
126
+ </div>
127
+
128
+ <!-- Attached files preview -->
129
+ <div v-if="attachedFiles.length" class="mt-2 flex flex-wrap gap-2">
130
+ <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">
131
+ <span class="truncate max-w-48" :title="f.name">{{ f.name }}</span>
132
+ <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">
133
+ <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>
134
+ </button>
135
+ </div>
136
+ </div>
137
+
138
+ <div v-if="!model" class="mt-2 text-sm text-red-600 dark:text-red-400">
139
+ Please select a model
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ `,
145
+ props: {
146
+ model: {
147
+ type: Object,
148
+ default: null
149
+ },
150
+ systemPrompt: {
151
+ type: String,
152
+ default: ''
153
+ }
154
+ },
155
+ setup(props) {
156
+ const ai = inject('ai')
157
+ const chatSettings = inject('chatSettings')
158
+ const router = useRouter()
159
+ const config = inject('config')
160
+ const chatPrompt = inject('chatPrompt')
161
+ const {
162
+ messageText,
163
+ attachedFiles,
164
+ isGenerating,
165
+ errorStatus,
166
+ hasImage,
167
+ hasAudio,
168
+ hasFile,
169
+ editingMessageId
170
+ } = chatPrompt
171
+ const threads = inject('threads')
172
+ const {
173
+ currentThread,
174
+ } = threads
175
+
176
+ const fileInput = ref(null)
177
+ const refMessage = ref(null)
178
+ const showSettings = ref(false)
179
+ const { applySettings } = chatSettings
180
+
181
+ // File attachments (+) handlers
182
+ const triggerFilePicker = () => {
183
+ if (fileInput.value) fileInput.value.click()
184
+ }
185
+ const onFilesSelected = async (e) => {
186
+ const files = Array.from(e.target?.files || [])
187
+ if (files.length) {
188
+ // Upload files immediately
189
+ const uploadedFiles = await Promise.all(files.map(async f => {
190
+ try {
191
+ const response = await uploadFile(f)
192
+ const metadata = {
193
+ url: response.url,
194
+ name: f.name,
195
+ size: response.size,
196
+ type: f.type,
197
+ width: response.width,
198
+ height: response.height,
199
+ threadId: currentThread.value?.id,
200
+ created: Date.now()
201
+ }
202
+
203
+ return {
204
+ ...metadata,
205
+ file: f // Keep original file for preview/fallback if needed
206
+ }
207
+ } catch (error) {
208
+ console.error('File upload failed:', error)
209
+ errorStatus.value = {
210
+ errorCode: 'Upload Failed',
211
+ message: `Failed to upload ${f.name}: ${error.message}`
212
+ }
213
+ return null
214
+ }
215
+ }))
216
+
217
+ attachedFiles.value.push(...uploadedFiles.filter(f => f))
218
+ }
219
+
220
+ // allow re-selecting the same file
221
+ if (fileInput.value) fileInput.value.value = ''
222
+
223
+ if (!messageText.value.trim()) {
224
+ if (hasImage()) {
225
+ messageText.value = getTextContent(config.defaults.image)
226
+ } else if (hasAudio()) {
227
+ messageText.value = getTextContent(config.defaults.audio)
228
+ } else {
229
+ messageText.value = getTextContent(config.defaults.file)
230
+ }
231
+ }
232
+ }
233
+ const removeAttachment = (i) => {
234
+ attachedFiles.value.splice(i, 1)
235
+ }
236
+
237
+ // Helper function to add files and set default message
238
+ const addFilesAndSetMessage = (files) => {
239
+ if (files.length === 0) return
240
+
241
+ attachedFiles.value.push(...files)
242
+
243
+ // Set default message text if empty
244
+ if (!messageText.value.trim()) {
245
+ if (hasImage()) {
246
+ messageText.value = getTextContent(config.defaults.image)
247
+ } else if (hasAudio()) {
248
+ messageText.value = getTextContent(config.defaults.audio)
249
+ } else {
250
+ messageText.value = getTextContent(config.defaults.file)
251
+ }
252
+ }
253
+ }
254
+
255
+ // Handle paste events for clipboard images, audio, and files
256
+ const onPaste = async (e) => {
257
+ // Use the paste event's clipboardData directly (works best for paste events)
258
+ const items = e.clipboardData?.items
259
+ if (!items) return
260
+
261
+ const files = []
262
+
263
+ // Check all clipboard items
264
+ for (let i = 0; i < items.length; i++) {
265
+ const item = items[i]
266
+
267
+ // Handle files (images, audio, etc.)
268
+ if (item.kind === 'file') {
269
+ const file = item.getAsFile()
270
+ if (file) {
271
+ // Generate a better filename based on type
272
+ let filename = file.name
273
+ if (!filename || filename === 'image.png' || filename === 'blob') {
274
+ const ext = file.type.split('/')[1] || 'png'
275
+ const timestamp = new Date().getTime()
276
+ if (file.type.startsWith('image/')) {
277
+ filename = `pasted-image-${timestamp}.${ext}`
278
+ } else if (file.type.startsWith('audio/')) {
279
+ filename = `pasted-audio-${timestamp}.${ext}`
280
+ } else {
281
+ filename = `pasted-file-${timestamp}.${ext}`
282
+ }
283
+ // Create a new File object with the better name
284
+ files.push(new File([file], filename, { type: file.type }))
285
+ } else {
286
+ files.push(file)
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ if (files.length > 0) {
293
+ e.preventDefault()
294
+ // Reuse the same logic as onFilesSelected for consistency
295
+ const event = { target: { files: files } }
296
+ await onFilesSelected(event)
297
+ }
298
+ }
299
+
300
+ // Handle drag and drop events
301
+ const isDragging = ref(false)
302
+
303
+ const onDragOver = (e) => {
304
+ e.preventDefault()
305
+ e.stopPropagation()
306
+ isDragging.value = true
307
+ }
308
+
309
+ const onDragLeave = (e) => {
310
+ e.preventDefault()
311
+ e.stopPropagation()
312
+ isDragging.value = false
313
+ }
314
+
315
+ const onDrop = async (e) => {
316
+ e.preventDefault()
317
+ e.stopPropagation()
318
+ isDragging.value = false
319
+
320
+ const files = Array.from(e.dataTransfer?.files || [])
321
+ if (files.length > 0) {
322
+ // Reuse the same logic as onFilesSelected for consistency
323
+ const event = { target: { files: files } }
324
+ await onFilesSelected(event)
325
+ }
326
+ }
327
+
328
+ function createChatRequest() {
329
+ if (hasImage()) {
330
+ return deepClone(config.defaults.image)
331
+ }
332
+ if (hasAudio()) {
333
+ return deepClone(config.defaults.audio)
334
+ }
335
+ if (attachedFiles.value.length) {
336
+ return deepClone(config.defaults.file)
337
+ }
338
+ const text = deepClone(config.defaults.text)
339
+ return text
340
+ }
341
+
342
+ function getTextContent(chat) {
343
+ const textMessage = chat.messages.find(m =>
344
+ m.role === 'user' && Array.isArray(m.content) && m.content.some(c => c.type === 'text'))
345
+ return textMessage?.content.find(c => c.type === 'text')?.text || ''
346
+ }
347
+
348
+ // Send message
349
+ const sendMessage = async () => {
350
+ if (!messageText.value.trim() || isGenerating.value || !props.model) return
351
+
352
+ // Clear any existing error message
353
+ errorStatus.value = null
354
+
355
+ // 1. Construct Structured Content (Text + Attachments)
356
+ let text = messageText.value.trim()
357
+ let content = []
358
+
359
+
360
+ messageText.value = ''
361
+
362
+ // Add Text Block
363
+ content.push({ type: 'text', text: text })
364
+
365
+ // Add Attachment Blocks
366
+ for (const f of attachedFiles.value) {
367
+ const ext = lastRightPart(f.name, '.')
368
+ if (imageExts.includes(ext)) {
369
+ content.push({ type: 'image_url', image_url: { url: f.url } })
370
+ } else if (audioExts.includes(ext)) {
371
+ content.push({ type: 'input_audio', input_audio: { data: f.url, format: ext } })
372
+ } else {
373
+ content.push({ type: 'file', file: { file_data: f.url, filename: f.name } })
374
+ }
375
+ }
376
+
377
+ // Create AbortController for this request
378
+ const controller = new AbortController()
379
+ chatPrompt.abortController.value = controller
380
+
381
+ try {
382
+ let threadId
383
+
384
+ // Create thread if none exists
385
+ if (!currentThread.value) {
386
+ const newThread = await threads.createThread('New Chat', props.model, props.systemPrompt)
387
+ threadId = newThread.id
388
+ // Navigate to the new thread URL
389
+ router.push(`${ai.base}/c/${newThread.id}`)
390
+ } else {
391
+ threadId = currentThread.value.id
392
+ // Update the existing thread's model and systemPrompt to match current selection
393
+ await threads.updateThread(threadId, {
394
+ model: props.model.name,
395
+ info: toModelInfo(props.model),
396
+ systemPrompt: props.systemPrompt
397
+ })
398
+ }
399
+
400
+ // Get the thread to check for duplicates
401
+ let thread = await threads.getThread(threadId)
402
+
403
+ // Handle Editing / Redo Logic
404
+ if (editingMessageId.value) {
405
+ // Check if message still exists
406
+ const messageExists = thread.messages.find(m => m.id === editingMessageId.value)
407
+ if (messageExists) {
408
+ // Update the message content
409
+ await threads.updateMessageInThread(threadId, editingMessageId.value, { content: content })
410
+ // Redo from this message (clears subsequent)
411
+ await threads.redoMessageFromThread(threadId, editingMessageId.value)
412
+
413
+ // Clear editing state
414
+ editingMessageId.value = null
415
+ } else {
416
+ // Fallback if message was deleted
417
+ editingMessageId.value = null
418
+ }
419
+ // Refresh thread state
420
+ thread = await threads.getThread(threadId)
421
+ } else {
422
+ // Regular Send Logic
423
+ const lastMessage = thread.messages[thread.messages.length - 1]
424
+
425
+ // Check duplicate based on text content extracted from potential array
426
+ const getLastText = (msgContent) => {
427
+ if (typeof msgContent === 'string') return msgContent
428
+ if (Array.isArray(msgContent)) return msgContent.find(c => c.type === 'text')?.text || ''
429
+ return ''
430
+ }
431
+ const newText = text // content[0].text
432
+ const lastText = lastMessage && lastMessage.role === 'user' ? getLastText(lastMessage.content) : null
433
+
434
+ const isDuplicate = lastText === newText
435
+
436
+ // Add user message only if it's not a duplicate
437
+ // Note: We are saving the FULL STRUCTURED CONTENT array here
438
+ if (!isDuplicate) {
439
+ await threads.addMessageToThread(threadId, {
440
+ role: 'user',
441
+ content: content
442
+ })
443
+ // Reload thread after adding message
444
+ thread = await threads.getThread(threadId)
445
+ }
446
+ }
447
+
448
+ isGenerating.value = true
449
+
450
+ // Construct API Request from History
451
+ const chatRequest = {
452
+ model: props.model.name,
453
+ messages: [],
454
+ metadata: {}
455
+ }
456
+
457
+ // Add system prompt if present
458
+ if (props.systemPrompt?.trim()) {
459
+ chatRequest.messages.push({
460
+ role: 'system',
461
+ content: props.systemPrompt // assuming system prompt is just string
462
+ })
463
+ }
464
+
465
+ // Add History
466
+ thread.messages.forEach(m => {
467
+ chatRequest.messages.push({
468
+ role: m.role,
469
+ content: m.content
470
+ })
471
+ })
472
+
473
+ // Apply user settings
474
+ applySettings(chatRequest)
475
+ chatRequest.metadata.threadId = threadId
476
+
477
+ console.debug('chatRequest', chatRequest)
478
+
479
+ // Send to API
480
+ const startTime = Date.now()
481
+ const response = await ai.post('/v1/chat/completions', {
482
+ body: JSON.stringify(chatRequest),
483
+ signal: controller.signal
484
+ })
485
+
486
+ let result = null
487
+ if (!response.ok) {
488
+ errorStatus.value = {
489
+ errorCode: `HTTP ${response.status} ${response.statusText}`,
490
+ message: null,
491
+ stackTrace: null
492
+ }
493
+ let errorBody = null
494
+ try {
495
+ errorBody = await response.text()
496
+ if (errorBody) {
497
+ // Try to parse as JSON for better formatting
498
+ try {
499
+ const errorJson = JSON.parse(errorBody)
500
+ const status = errorJson?.responseStatus
501
+ if (status) {
502
+ errorStatus.value.errorCode += ` ${status.errorCode}`
503
+ errorStatus.value.message = status.message
504
+ errorStatus.value.stackTrace = status.stackTrace
505
+ } else {
506
+ errorStatus.value.stackTrace = JSON.stringify(errorJson, null, 2)
507
+ }
508
+ } catch (e) {
509
+ }
510
+ }
511
+ } catch (e) {
512
+ // If we can't read the response body, just use the status
513
+ }
514
+ } else {
515
+ try {
516
+ result = await response.json()
517
+ console.debug('chatResponse', JSON.stringify(result, null, 2))
518
+ } catch (e) {
519
+ errorStatus.value = {
520
+ errorCode: 'Error',
521
+ message: e.message,
522
+ stackTrace: null
523
+ }
524
+ }
525
+ }
526
+
527
+ if (result?.error) {
528
+ errorStatus.value ??= {
529
+ errorCode: 'Error',
530
+ }
531
+ errorStatus.value.message = result.error
532
+ }
533
+
534
+ if (!errorStatus.value) {
535
+ // Add assistant response (save entire message including reasoning)
536
+ const assistantMessage = result.choices?.[0]?.message
537
+
538
+ const usage = result.usage
539
+ if (usage) {
540
+ if (result.metadata?.pricing) {
541
+ const [input, output] = result.metadata.pricing.split('/')
542
+ usage.duration = result.metadata.duration ?? (Date.now() - startTime)
543
+ usage.input = input
544
+ usage.output = output
545
+ usage.tokens = usage.completion_tokens
546
+ usage.price = usage.output
547
+ usage.cost = tokenCost(usage.prompt_tokens / 1_000_000 * parseFloat(input) + usage.completion_tokens / 1_000_000 * parseFloat(output))
548
+ }
549
+ await threads.logRequest(threadId, props.model, chatRequest, result)
550
+ }
551
+ await threads.addMessageToThread(threadId, assistantMessage, usage)
552
+
553
+ nextTick(addCopyButtons)
554
+
555
+ attachedFiles.value = []
556
+ // Error will be cleared when user sends next message (no auto-timeout)
557
+ }
558
+ } catch (error) {
559
+ // Check if the error is due to abort
560
+ if (error.name === 'AbortError') {
561
+ console.log('Request was cancelled by user')
562
+ // Don't show error for cancelled requests
563
+ } else {
564
+ // Re-throw other errors to be handled by outer catch
565
+ throw error
566
+ }
567
+ } finally {
568
+ isGenerating.value = false
569
+ chatPrompt.abortController.value = null
570
+ // Restore focus to the textarea
571
+ nextTick(() => {
572
+ refMessage.value?.focus()
573
+ })
574
+ }
575
+ }
576
+
577
+ const cancelRequest = () => {
578
+ chatPrompt.cancel()
579
+ }
580
+
581
+ const addNewLine = () => {
582
+ // Enter key already adds new line
583
+ //messageText.value += '\n'
584
+ }
585
+
586
+ return {
587
+ isGenerating,
588
+ attachedFiles,
589
+ messageText,
590
+ fileInput,
591
+ refMessage,
592
+ showSettings,
593
+ isDragging,
594
+ triggerFilePicker,
595
+ onFilesSelected,
596
+ onPaste,
597
+ onDragOver,
598
+ onDragLeave,
599
+ onDrop,
600
+ removeAttachment,
601
+ sendMessage,
602
+ cancelRequest,
603
+ addNewLine,
604
+ }
605
+ }
606
+ }