llms-py 2.0.20__py3-none-any.whl → 3.0.10__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 (190) hide show
  1. llms/__init__.py +3 -1
  2. llms/db.py +359 -0
  3. llms/{ui/Analytics.mjs → extensions/analytics/ui/index.mjs} +254 -327
  4. llms/extensions/app/README.md +20 -0
  5. llms/extensions/app/__init__.py +589 -0
  6. llms/extensions/app/db.py +536 -0
  7. llms/{ui → extensions/app/ui}/Recents.mjs +99 -73
  8. llms/{ui/Sidebar.mjs → extensions/app/ui/index.mjs} +139 -68
  9. llms/extensions/app/ui/threadStore.mjs +433 -0
  10. llms/extensions/core_tools/CALCULATOR.md +32 -0
  11. llms/extensions/core_tools/__init__.py +637 -0
  12. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +201 -0
  13. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +185 -0
  14. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +101 -0
  15. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +160 -0
  16. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +66 -0
  17. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +27 -0
  18. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +72 -0
  19. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +119 -0
  20. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +98 -0
  21. llms/extensions/core_tools/ui/codemirror/codemirror.css +344 -0
  22. llms/extensions/core_tools/ui/codemirror/codemirror.js +9884 -0
  23. llms/extensions/core_tools/ui/codemirror/doc/docs.css +225 -0
  24. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  25. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +942 -0
  26. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +118 -0
  27. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +962 -0
  28. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +62 -0
  29. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +402 -0
  30. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +40 -0
  31. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +135 -0
  32. llms/extensions/core_tools/ui/index.mjs +650 -0
  33. llms/extensions/gallery/README.md +61 -0
  34. llms/extensions/gallery/__init__.py +63 -0
  35. llms/extensions/gallery/db.py +243 -0
  36. llms/extensions/gallery/ui/index.mjs +482 -0
  37. llms/extensions/katex/README.md +39 -0
  38. llms/extensions/katex/__init__.py +6 -0
  39. llms/extensions/katex/ui/README.md +125 -0
  40. llms/extensions/katex/ui/contrib/auto-render.js +338 -0
  41. llms/extensions/katex/ui/contrib/auto-render.min.js +1 -0
  42. llms/extensions/katex/ui/contrib/auto-render.mjs +244 -0
  43. llms/extensions/katex/ui/contrib/copy-tex.js +127 -0
  44. llms/extensions/katex/ui/contrib/copy-tex.min.js +1 -0
  45. llms/extensions/katex/ui/contrib/copy-tex.mjs +105 -0
  46. llms/extensions/katex/ui/contrib/mathtex-script-type.js +109 -0
  47. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +1 -0
  48. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +24 -0
  49. llms/extensions/katex/ui/contrib/mhchem.js +3213 -0
  50. llms/extensions/katex/ui/contrib/mhchem.min.js +1 -0
  51. llms/extensions/katex/ui/contrib/mhchem.mjs +3109 -0
  52. llms/extensions/katex/ui/contrib/render-a11y-string.js +887 -0
  53. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +1 -0
  54. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +800 -0
  55. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  56. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  57. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  58. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  59. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  60. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  61. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  62. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  63. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  64. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  65. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  66. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  67. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  68. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  69. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  70. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  71. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  72. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  73. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  74. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  75. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  76. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  77. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  78. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  79. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  80. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  81. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  82. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  83. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  84. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  85. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  86. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  87. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  88. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  89. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  90. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  91. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  92. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  93. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  94. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  95. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  96. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  97. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  98. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  99. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  100. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  101. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  102. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  103. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  104. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  115. llms/extensions/katex/ui/index.mjs +92 -0
  116. llms/extensions/katex/ui/katex-swap.css +1230 -0
  117. llms/extensions/katex/ui/katex-swap.min.css +1 -0
  118. llms/extensions/katex/ui/katex.css +1230 -0
  119. llms/extensions/katex/ui/katex.js +19080 -0
  120. llms/extensions/katex/ui/katex.min.css +1 -0
  121. llms/extensions/katex/ui/katex.min.js +1 -0
  122. llms/extensions/katex/ui/katex.min.mjs +1 -0
  123. llms/extensions/katex/ui/katex.mjs +18547 -0
  124. llms/extensions/providers/__init__.py +22 -0
  125. llms/extensions/providers/anthropic.py +233 -0
  126. llms/extensions/providers/cerebras.py +37 -0
  127. llms/extensions/providers/chutes.py +153 -0
  128. llms/extensions/providers/google.py +481 -0
  129. llms/extensions/providers/nvidia.py +103 -0
  130. llms/extensions/providers/openai.py +154 -0
  131. llms/extensions/providers/openrouter.py +74 -0
  132. llms/extensions/providers/zai.py +182 -0
  133. llms/extensions/system_prompts/README.md +22 -0
  134. llms/extensions/system_prompts/__init__.py +45 -0
  135. llms/extensions/system_prompts/ui/index.mjs +280 -0
  136. llms/extensions/system_prompts/ui/prompts.json +1067 -0
  137. llms/extensions/tools/__init__.py +144 -0
  138. llms/extensions/tools/ui/index.mjs +706 -0
  139. llms/index.html +36 -62
  140. llms/llms.json +180 -879
  141. llms/main.py +3640 -899
  142. llms/providers-extra.json +394 -0
  143. llms/providers.json +1 -0
  144. llms/ui/App.mjs +176 -8
  145. llms/ui/ai.mjs +156 -20
  146. llms/ui/app.css +3161 -244
  147. llms/ui/ctx.mjs +412 -0
  148. llms/ui/index.mjs +131 -0
  149. llms/ui/lib/chart.js +14 -0
  150. llms/ui/lib/charts.mjs +16 -0
  151. llms/ui/lib/color.js +14 -0
  152. llms/ui/lib/highlight.min.mjs +1243 -0
  153. llms/ui/lib/idb.min.mjs +8 -0
  154. llms/ui/lib/marked.min.mjs +8 -0
  155. llms/ui/lib/servicestack-client.mjs +1 -0
  156. llms/ui/lib/servicestack-vue.mjs +37 -0
  157. llms/ui/lib/vue-router.min.mjs +6 -0
  158. llms/ui/lib/vue.min.mjs +13 -0
  159. llms/ui/lib/vue.mjs +18530 -0
  160. llms/ui/markdown.mjs +25 -14
  161. llms/ui/modules/chat/ChatBody.mjs +976 -0
  162. llms/ui/{SettingsDialog.mjs → modules/chat/SettingsDialog.mjs} +74 -74
  163. llms/ui/modules/chat/index.mjs +991 -0
  164. llms/ui/modules/icons.mjs +46 -0
  165. llms/ui/modules/layout.mjs +271 -0
  166. llms/ui/modules/model-selector.mjs +811 -0
  167. llms/ui/tailwind.input.css +550 -78
  168. llms/ui/typography.css +54 -36
  169. llms/ui/utils.mjs +197 -92
  170. llms_py-3.0.10.dist-info/METADATA +49 -0
  171. llms_py-3.0.10.dist-info/RECORD +177 -0
  172. {llms_py-2.0.20.dist-info → llms_py-3.0.10.dist-info}/licenses/LICENSE +1 -2
  173. llms/ui/Avatar.mjs +0 -28
  174. llms/ui/Brand.mjs +0 -34
  175. llms/ui/ChatPrompt.mjs +0 -443
  176. llms/ui/Main.mjs +0 -740
  177. llms/ui/ModelSelector.mjs +0 -60
  178. llms/ui/ProviderIcon.mjs +0 -29
  179. llms/ui/ProviderStatus.mjs +0 -105
  180. llms/ui/SignIn.mjs +0 -64
  181. llms/ui/SystemPromptEditor.mjs +0 -31
  182. llms/ui/SystemPromptSelector.mjs +0 -36
  183. llms/ui/Welcome.mjs +0 -8
  184. llms/ui/threadStore.mjs +0 -524
  185. llms/ui.json +0 -1069
  186. llms_py-2.0.20.dist-info/METADATA +0 -931
  187. llms_py-2.0.20.dist-info/RECORD +0 -36
  188. {llms_py-2.0.20.dist-info → llms_py-3.0.10.dist-info}/WHEEL +0 -0
  189. {llms_py-2.0.20.dist-info → llms_py-3.0.10.dist-info}/entry_points.txt +0 -0
  190. {llms_py-2.0.20.dist-info → llms_py-3.0.10.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,991 @@
1
+
2
+ import { ref, watch, computed, nextTick, inject } from 'vue'
3
+ import { $$, createElement, lastRightPart, ApiResult, createErrorStatus } from "@servicestack/client"
4
+ import SettingsDialog, { useSettings } from './SettingsDialog.mjs'
5
+ import { ChatBody, LightboxImage, TypeText, TypeImage, TypeAudio, TypeFile, ViewType, ViewTypes, ViewToolTypes, ToolArguments, ToolOutput, MessageUsage, MessageReasoning } from './ChatBody.mjs'
6
+ import { AppContext } from '../../ctx.mjs'
7
+
8
+ const imageExts = 'png,webp,jpg,jpeg,gif,bmp,svg,tiff,ico'.split(',')
9
+ const audioExts = 'mp3,wav,ogg,flac,m4a,opus,webm'.split(',')
10
+
11
+ /* Example image generation request: https://openrouter.ai/docs/guides/overview/multimodal/image-generation
12
+ {
13
+ "model": "google/gemini-2.5-flash-image-preview",
14
+ "messages": [
15
+ {
16
+ "role": "user",
17
+ "content": "Create a picture of a nano banana dish in a fancy restaurant with a Gemini theme"
18
+ }
19
+ ],
20
+ "modalities": ["image", "text"],
21
+ "image_config": {
22
+ "aspect_ratio": "16:9"
23
+ }
24
+ }
25
+ Example response:
26
+ {
27
+ "choices": [
28
+ {
29
+ "message": {
30
+ "role": "assistant",
31
+ "content": "I've generated a beautiful sunset image for you.",
32
+ "images": [
33
+ {
34
+ "type": "image_url",
35
+ "image_url": {
36
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
37
+ }
38
+ }
39
+ ]
40
+ }
41
+ }
42
+ ]
43
+ }
44
+ */
45
+ const imageAspectRatios = {
46
+ '1024×1024': '1:1',
47
+ '832×1248': '2:3',
48
+ '1248×832': '3:2',
49
+ '864×1184': '3:4',
50
+ '1184×864': '4:3',
51
+ '896×1152': '4:5',
52
+ '1152×896': '5:4',
53
+ '768×1344': '9:16',
54
+ '1344×768': '16:9',
55
+ '1536×672': '21:9',
56
+ }
57
+ // Reverse lookup
58
+ const imageRatioSizes = Object.entries(imageAspectRatios).reduce((acc, [key, value]) => {
59
+ acc[value] = key
60
+ return acc
61
+ }, {})
62
+
63
+ const svg = {
64
+ clipboard: `<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none"><path d="M8 5H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1M8 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M8 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2m0 0h2a2 2 0 0 1 2 2v3m2 4H10m0 0l3-3m-3 3l3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></g></svg>`,
65
+ check: `<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>`,
66
+ }
67
+
68
+ function copyBlock(btn) {
69
+ // console.log('copyBlock',btn)
70
+ const label = btn.previousElementSibling
71
+ const code = btn.parentElement.nextElementSibling
72
+ label.classList.remove('hidden')
73
+ label.innerHTML = 'copied'
74
+ btn.classList.add('border-gray-600', 'bg-gray-700')
75
+ btn.classList.remove('border-gray-700')
76
+ btn.innerHTML = svg.check
77
+ navigator.clipboard.writeText(code.innerText)
78
+ setTimeout(() => {
79
+ label.classList.add('hidden')
80
+ label.innerHTML = ''
81
+ btn.innerHTML = svg.clipboard
82
+ btn.classList.remove('border-gray-600', 'bg-gray-700')
83
+ btn.classList.add('border-gray-700')
84
+ }, 2000)
85
+ }
86
+
87
+ function addCopyButtonToCodeBlocks(sel) {
88
+ globalThis.copyBlock ??= copyBlock
89
+ //console.log('addCopyButtonToCodeBlocks', sel, [...$$(sel)].length)
90
+
91
+ $$(sel).forEach(code => {
92
+ let pre = code.parentElement;
93
+ if (pre.classList.contains('group')) return
94
+ pre.classList.add('relative', 'group')
95
+
96
+ const div = createElement('div', { attrs: { className: 'opacity-0 group-hover:opacity-100 transition-opacity duration-100 flex absolute right-2 -mt-1 select-none' } })
97
+ const label = createElement('div', { attrs: { className: 'hidden font-sans p-1 px-2 mr-1 rounded-md border border-gray-600 bg-gray-700 text-gray-400' } })
98
+ const btn = createElement('button', {
99
+ attrs: {
100
+ type: 'button',
101
+ className: 'p-1 rounded-md border block text-gray-500 hover:text-gray-400 border-gray-700 hover:border-gray-600',
102
+ onclick: 'copyBlock(this)'
103
+ }
104
+ })
105
+ btn.innerHTML = svg.clipboard
106
+ div.appendChild(label)
107
+ div.appendChild(btn)
108
+ pre.insertBefore(div, code)
109
+ })
110
+ }
111
+
112
+ export function addCopyButtons() {
113
+ addCopyButtonToCodeBlocks('.prose pre>code')
114
+ }
115
+
116
+ export function useChatPrompt(ctx) {
117
+ const messageText = ref('')
118
+ const promptHistory = ref([])
119
+ const attachedFiles = ref([])
120
+ const hasImage = () => attachedFiles.value.some(f => imageExts.includes(lastRightPart(f.name, '.')))
121
+ const hasAudio = () => attachedFiles.value.some(f => audioExts.includes(lastRightPart(f.name, '.')))
122
+ const hasFile = () => attachedFiles.value.length > 0
123
+
124
+ const editingMessage = ref(null)
125
+
126
+ function reset() {
127
+ // Ensure initial state is ready to accept input
128
+ attachedFiles.value = []
129
+ messageText.value = ''
130
+ editingMessage.value = null
131
+ }
132
+
133
+ const settings = useSettings()
134
+
135
+ function getModel(name) {
136
+ return ctx.state.models.find(x => x.name === name) ?? ctx.state.models.find(x => x.id === name)
137
+ }
138
+
139
+ function getSelectedModel() {
140
+ const candidates = [ctx.state.selectedModel, ctx.state.config.defaults.text.model]
141
+ const ret = candidates.map(name => name && getModel(name)).find(x => !!x)
142
+ if (!ret) {
143
+ // Try to find a model in the latest threads
144
+ for (const thread in ctx.threads.threads) {
145
+ const model = thread.model && getModel(thread.model)
146
+ if (model) return model
147
+ }
148
+ }
149
+ return ret
150
+ }
151
+
152
+ function setSelectedModel(model) {
153
+ ctx.setState({
154
+ selectedModel: model.name
155
+ })
156
+ ctx.setPrefs({
157
+ model: model.name
158
+ })
159
+ }
160
+
161
+ function getProviderForModel(model) {
162
+ return getModel(model)?.provider
163
+ }
164
+
165
+ const canGenerateImage = model => {
166
+ return model?.modalities?.output?.includes('image')
167
+ }
168
+ const canGenerateAudio = model => {
169
+ return model?.modalities?.output?.includes('audio')
170
+ }
171
+
172
+ function applySettings(request) {
173
+ settings.applySettings(request)
174
+ }
175
+
176
+ function createContent({ text, files }) {
177
+ let content = []
178
+
179
+ // Add Text Block
180
+ if (text) {
181
+ content.push({ type: 'text', text })
182
+ }
183
+
184
+ // Add Attachment Blocks
185
+ if (Array.isArray(files)) {
186
+ for (const f of files) {
187
+ const ext = lastRightPart(f.name, '.')
188
+ if (imageExts.includes(ext)) {
189
+ content.push({ type: 'image_url', image_url: { url: f.url } })
190
+ } else if (audioExts.includes(ext)) {
191
+ content.push({ type: 'input_audio', input_audio: { data: f.url, format: ext } })
192
+ } else {
193
+ content.push({ type: 'file', file: { file_data: f.url, filename: f.name } })
194
+ }
195
+ }
196
+ }
197
+ return content
198
+ }
199
+
200
+ function createRequest({ model, text, files, systemPrompt, aspectRatio }) {
201
+ // Construct API Request from History
202
+ const request = {
203
+ model: model.name,
204
+ messages: [],
205
+ metadata: {}
206
+ }
207
+
208
+ // Apply user settings
209
+ applySettings(request)
210
+
211
+ if (systemPrompt) {
212
+ request.messages = request.messages.filter(m => m.role !== 'system')
213
+ request.messages.unshift({
214
+ role: 'system',
215
+ content: systemPrompt
216
+ })
217
+ }
218
+
219
+ if (canGenerateImage(model)) {
220
+ request.image_config = {
221
+ aspect_ratio: aspectRatio || imageAspectRatios[ctx.state.selectedAspectRatio] || '1:1'
222
+ }
223
+ request.modalities = ["image", "text"]
224
+ }
225
+ else if (canGenerateAudio(model)) {
226
+ request.modalities = ["audio", "text"]
227
+ }
228
+
229
+ if (text) {
230
+ const content = createContent({ text, files })
231
+ request.messages.push({
232
+ role: 'user',
233
+ content
234
+ })
235
+ }
236
+
237
+ return request
238
+ }
239
+
240
+ async function completion({ request, thread, model, controller, redirect }) {
241
+ try {
242
+ let error
243
+ if (!model) {
244
+ if (request.model) {
245
+ model = getModel(request.model)
246
+ } else {
247
+ model = getModel(request.model) ?? getSelectedModel()
248
+ }
249
+ }
250
+
251
+ if (!model) {
252
+ return ctx.createErrorResult({ message: `Model ${request.model || ''} not found`, errorCode: 'NotFound' })
253
+ }
254
+
255
+ if (!request.messages) request.messages = []
256
+ if (!request.metadata) request.metadata = {}
257
+
258
+ if (!thread) {
259
+ const title = getTextContent(request) || 'New Chat'
260
+ thread = await ctx.threads.startNewThread({ title, model, redirect })
261
+ }
262
+
263
+ const threadId = thread?.id
264
+
265
+ const ctxRequest = {
266
+ request,
267
+ thread,
268
+ }
269
+ ctx.chatRequestFilters.forEach(f => f(ctxRequest))
270
+
271
+ console.debug('completion.request', request)
272
+
273
+ // Send to API
274
+ const startTime = Date.now()
275
+ const res = await ctx.post('/v1/chat/completions', {
276
+ body: JSON.stringify(request),
277
+ signal: controller?.signal
278
+ })
279
+
280
+ let response = null
281
+ if (!res.ok) {
282
+ error = ctx.createErrorStatus({ message: `HTTP ${res.status} ${res.statusText}` })
283
+ let errorBody = null
284
+ try {
285
+ errorBody = await res.text()
286
+ if (errorBody) {
287
+ // Try to parse as JSON for better formatting
288
+ try {
289
+ const errorJson = JSON.parse(errorBody)
290
+ const status = errorJson?.responseStatus
291
+ if (status) {
292
+ error.errorCode += ` ${status.errorCode}`
293
+ error.message = status.message
294
+ error.stackTrace = status.stackTrace
295
+ } else {
296
+ error.stackTrace = JSON.stringify(errorJson, null, 2)
297
+ }
298
+ } catch (e) {
299
+ }
300
+ }
301
+ } catch (e) {
302
+ // If we can't read the response body, just use the status
303
+ }
304
+ } else {
305
+ try {
306
+ response = await res.json()
307
+ const ctxResponse = {
308
+ response,
309
+ thread,
310
+ }
311
+ ctx.chatResponseFilters.forEach(f => f(ctxResponse))
312
+ console.debug('completion.response', JSON.stringify(response, null, 2))
313
+ } catch (e) {
314
+ error = createErrorStatus(e.message)
315
+ }
316
+ }
317
+
318
+ if (response?.error) {
319
+ error ??= createErrorStatus()
320
+ error.message = response.error
321
+ }
322
+
323
+ if (error) {
324
+ ctx.chatErrorFilters.forEach(f => f(error))
325
+ return new ApiResult({ error })
326
+ }
327
+
328
+ if (!error) {
329
+ // Add tool history messages if any
330
+ if (response.tool_history && Array.isArray(response.tool_history)) {
331
+ for (const msg of response.tool_history) {
332
+ if (msg.role === 'assistant') {
333
+ msg.model = model.name // tag with model
334
+ }
335
+ }
336
+ }
337
+
338
+ nextTick(addCopyButtons)
339
+
340
+ return new ApiResult({ response })
341
+ }
342
+ } catch (e) {
343
+ console.log('completion.error', e)
344
+ return new ApiResult({ error: createErrorStatus(e.message, 'ChatFailed') })
345
+ }
346
+ }
347
+ function getTextContent(chat) {
348
+ const textMessage = chat.messages.find(m =>
349
+ m.role === 'user' && Array.isArray(m.content) && m.content.some(c => c.type === 'text'))
350
+ return textMessage?.content.find(c => c.type === 'text')?.text || ''
351
+ }
352
+ function getAnswer(response) {
353
+ const textMessage = response.choices?.[0]?.message
354
+ return textMessage?.content || ''
355
+ }
356
+ function selectAspectRatio(ratio) {
357
+ const selectedAspectRatio = imageRatioSizes[ratio] || '1024×1024'
358
+ console.log(`selectAspectRatio(${ratio})`, selectedAspectRatio)
359
+ ctx.setState({ selectedAspectRatio })
360
+ }
361
+
362
+ return {
363
+ completion,
364
+ createContent,
365
+ createRequest,
366
+ applySettings,
367
+ promptHistory,
368
+ messageText,
369
+ attachedFiles,
370
+ editingMessage,
371
+ hasImage,
372
+ hasAudio,
373
+ hasFile,
374
+ reset,
375
+ settings,
376
+ addCopyButtons,
377
+ getModel,
378
+ getSelectedModel,
379
+ setSelectedModel,
380
+ getProviderForModel,
381
+ canGenerateImage,
382
+ canGenerateAudio,
383
+ getTextContent,
384
+ getAnswer,
385
+ selectAspectRatio,
386
+ }
387
+ }
388
+
389
+ const ChatPrompt = {
390
+ template: `
391
+ <div class="mx-auto max-w-3xl">
392
+ <SettingsDialog :isOpen="showSettings" @close="showSettings = false" />
393
+ <div class="flex space-x-2">
394
+ <!-- Attach (+) button and Settings button -->
395
+ <div class="mt-1.5 flex flex-col space-y-1 items-center">
396
+ <div>
397
+ <button type="button"
398
+ @click="triggerFilePicker"
399
+ :disabled="$threads.isWatchingThread.value || !model"
400
+ 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"
401
+ title="Attach image or audio">
402
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256">
403
+ <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>
404
+ </svg>
405
+ </button>
406
+ <!-- Hidden file input -->
407
+ <input ref="fileInput" type="file" multiple @change="onFilesSelected"
408
+ class="hidden" accept="image/*,audio/*,.pdf,.doc,.docx,.xml,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
409
+ />
410
+ </div>
411
+ <div>
412
+ <button type="button" title="Settings" @click="showSettings = true"
413
+ :disabled="$threads.watchingThread || !model"
414
+ 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">
415
+ <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>
416
+ </button>
417
+ </div>
418
+ </div>
419
+
420
+ <div class="flex-1">
421
+ <div class="relative">
422
+ <textarea
423
+ ref="refMessage"
424
+ v-model="messageText"
425
+ @keydown="onKeyDown"
426
+ @keydown.enter.exact.prevent="sendMessage"
427
+ @keydown.enter.shift.exact="addNewLine"
428
+ @paste="onPaste"
429
+ @dragover="onDragOver"
430
+ @dragleave="onDragLeave"
431
+ @drop="onDrop"
432
+ placeholder="Type message... (Enter to send, Shift+Enter for new line, drag & drop or paste files)"
433
+ rows="3"
434
+ :class="[
435
+ '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',
436
+ isDragging
437
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-500'
438
+ : 'border-gray-300 dark:border-gray-600 focus:border-blue-500 focus:ring-blue-500'
439
+ ]"
440
+ :disabled="$threads.watchingThread || !model"
441
+ ></textarea>
442
+ <button v-if="!$threads.watchingThread" title="Send (Enter)" type="button"
443
+ @click="sendMessage"
444
+ :disabled="!messageText.trim() || $threads.watchingThread || !model"
445
+ 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">
446
+ <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>
447
+ </button>
448
+ <button v-else title="Cancel request" type="button"
449
+ @click="$threads.cancelThread()"
450
+ 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">
451
+ <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">
452
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
453
+ </svg>
454
+ </button>
455
+ </div>
456
+
457
+ <!-- Attachments & Image Options -->
458
+ <div class="mt-2 flex justify-between items-start gap-2">
459
+ <div class="flex flex-wrap gap-2">
460
+ <div v-for="(f,i) in $chat.attachedFiles.value" :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">
461
+ <span class="truncate max-w-48" :title="f.name">{{ f.name }}</span>
462
+ <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">
463
+ <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>
464
+ </button>
465
+ </div>
466
+ </div>
467
+
468
+ <!-- Image Aspect Ratio Selector -->
469
+ <div v-if="$chat.canGenerateImage(model)">
470
+ <select name="aspect_ratio" v-model="$state.selectedAspectRatio"
471
+ class="block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-xs text-gray-700 dark:text-gray-300 pl-2 pr-6 py-1 focus:ring-blue-500 focus:border-blue-500">
472
+ <option v-for="(ratio, size) in imageAspectRatios" :key="size" :value="size">
473
+ {{ ratio }}
474
+ </option>
475
+ </select>
476
+ </div>
477
+ </div>
478
+
479
+ <div v-if="!model" class="mt-2 text-sm text-red-600 dark:text-red-400">
480
+ Please select a model
481
+ </div>
482
+ </div>
483
+ </div>
484
+ </div>
485
+ `,
486
+ props: {
487
+ model: {
488
+ type: Object,
489
+ default: null
490
+ }
491
+ },
492
+ setup(props) {
493
+ const ctx = inject('ctx')
494
+ const config = ctx.state.config
495
+ const {
496
+ messageText,
497
+ promptHistory,
498
+ hasImage,
499
+ hasAudio,
500
+ hasFile,
501
+ getTextContent,
502
+ } = ctx.chat
503
+
504
+ const fileInput = ref(null)
505
+ const refMessage = ref(null)
506
+ const showSettings = ref(false)
507
+ const historyIndex = ref(-1)
508
+ const isNavigatingHistory = ref(false)
509
+
510
+ // File attachments (+) handlers
511
+ const triggerFilePicker = () => {
512
+ if (fileInput.value) fileInput.value.click()
513
+ }
514
+ const onFilesSelected = async (e) => {
515
+ const files = Array.from(e.target?.files || [])
516
+ if (files.length) {
517
+ // Upload files immediately
518
+ const uploadedFiles = await Promise.all(files.map(async f => {
519
+ try {
520
+ const response = await ctx.ai.uploadFile(f)
521
+ const metadata = {
522
+ url: response.url,
523
+ name: f.name,
524
+ size: response.size,
525
+ type: f.type,
526
+ width: response.width,
527
+ height: response.height,
528
+ threadId: ctx.threads.currentThread.value?.id,
529
+ created: Date.now()
530
+ }
531
+
532
+ return {
533
+ ...metadata,
534
+ file: f // Keep original file for preview/fallback if needed
535
+ }
536
+ } catch (error) {
537
+ ctx.setError({
538
+ errorCode: 'Upload Failed',
539
+ message: `Failed to upload ${f.name}: ${error.message}`
540
+ })
541
+ return null
542
+ }
543
+ }))
544
+
545
+ ctx.chat.attachedFiles.value.push(...uploadedFiles.filter(f => f))
546
+ }
547
+
548
+ // allow re-selecting the same file
549
+ if (fileInput.value) fileInput.value.value = ''
550
+
551
+ if (!messageText.value?.trim()) {
552
+ if (hasImage()) {
553
+ messageText.value = getTextContent(config.defaults.image)
554
+ } else if (hasAudio()) {
555
+ messageText.value = getTextContent(config.defaults.audio)
556
+ } else {
557
+ messageText.value = getTextContent(config.defaults.file)
558
+ }
559
+ }
560
+ }
561
+ const removeAttachment = (i) => {
562
+ ctx.chat.attachedFiles.value.splice(i, 1)
563
+ }
564
+
565
+ // Handle paste events for clipboard images, audio, and files
566
+ const onPaste = async (e) => {
567
+ // Use the paste event's clipboardData directly (works best for paste events)
568
+ const items = e.clipboardData?.items
569
+ if (!items) return
570
+
571
+ const files = []
572
+
573
+ // Check all clipboard items
574
+ for (let i = 0; i < items.length; i++) {
575
+ const item = items[i]
576
+
577
+ // Handle files (images, audio, etc.)
578
+ if (item.kind === 'file') {
579
+ const file = item.getAsFile()
580
+ if (file) {
581
+ // Generate a better filename based on type
582
+ let filename = file.name
583
+ if (!filename || filename === 'image.png' || filename === 'blob') {
584
+ const ext = file.type.split('/')[1] || 'png'
585
+ const timestamp = new Date().getTime()
586
+ if (file.type.startsWith('image/')) {
587
+ filename = `pasted-image-${timestamp}.${ext}`
588
+ } else if (file.type.startsWith('audio/')) {
589
+ filename = `pasted-audio-${timestamp}.${ext}`
590
+ } else {
591
+ filename = `pasted-file-${timestamp}.${ext}`
592
+ }
593
+ // Create a new File object with the better name
594
+ files.push(new File([file], filename, { type: file.type }))
595
+ } else {
596
+ files.push(file)
597
+ }
598
+ }
599
+ }
600
+ }
601
+
602
+ if (files.length > 0) {
603
+ e.preventDefault()
604
+ // Reuse the same logic as onFilesSelected for consistency
605
+ const event = { target: { files: files } }
606
+ await onFilesSelected(event)
607
+ }
608
+ }
609
+
610
+ // Handle drag and drop events
611
+ const isDragging = ref(false)
612
+
613
+ const onDragOver = (e) => {
614
+ e.preventDefault()
615
+ e.stopPropagation()
616
+ isDragging.value = true
617
+ }
618
+
619
+ const onDragLeave = (e) => {
620
+ e.preventDefault()
621
+ e.stopPropagation()
622
+ isDragging.value = false
623
+ }
624
+
625
+ const onDrop = async (e) => {
626
+ e.preventDefault()
627
+ e.stopPropagation()
628
+ isDragging.value = false
629
+
630
+ const files = Array.from(e.dataTransfer?.files || [])
631
+ if (files.length > 0) {
632
+ // Reuse the same logic as onFilesSelected for consistency
633
+ const event = { target: { files: files } }
634
+ await onFilesSelected(event)
635
+ }
636
+ }
637
+
638
+ // Send message
639
+ const sendMessage = async () => {
640
+ if (!messageText.value?.trim() && !hasImage() && !hasAudio() && !hasFile()) return
641
+ if (ctx.threads.isWatchingThread.value || !props.model) return
642
+
643
+ ctx.clearError()
644
+
645
+ // 1. Construct Structured Content (Text + Attachments)
646
+ let text = messageText.value.trim()
647
+
648
+ if (text) {
649
+ const idx = promptHistory.value.indexOf(text)
650
+ if (idx !== -1) {
651
+ promptHistory.value.splice(idx, 1)
652
+ }
653
+ promptHistory.value.push(text)
654
+ }
655
+
656
+ messageText.value = ''
657
+ let content = ctx.chat.createContent({ text, files: ctx.chat.attachedFiles.value })
658
+
659
+ let thread
660
+
661
+ // Create thread if none exists
662
+ if (!ctx.threads.currentThread.value) {
663
+ thread = await ctx.threads.startNewThread({ model: props.model, redirect: true })
664
+ } else {
665
+ thread = ctx.threads.currentThread.value
666
+ }
667
+
668
+ let threadId = thread.id
669
+ let messages = thread.messages || []
670
+ if (!threadId) {
671
+ console.error('No thread ID found', thread, ctx.threads.currentThread.value)
672
+ return
673
+ }
674
+
675
+ // Handle Editing / Redo Logic
676
+ const editingMessage = ctx.chat.editingMessage.value
677
+ if (editingMessage) {
678
+ let messageIndex = messages.findIndex(m => m.timestamp === editingMessage)
679
+ if (messageIndex == -1) {
680
+ messageIndex = messages.findLastIndex(m => m.role === 'user')
681
+ }
682
+ console.log('Editing message', editingMessage, messageIndex, messages)
683
+
684
+ if (messageIndex >= 0) {
685
+ messages[messageIndex].content = content
686
+ // Truncate messages to only include up to the edited message
687
+ messages.length = messageIndex + 1
688
+ } else {
689
+ messages.push({
690
+ timestamp: new Date().valueOf(),
691
+ role: 'user',
692
+ content,
693
+ })
694
+ }
695
+ } else {
696
+ // Regular Send Logic
697
+ const lastMessage = messages[messages.length - 1]
698
+
699
+ // Check duplicate based on text content extracted from potential array
700
+ const getLastText = (msgContent) => {
701
+ if (typeof msgContent === 'string') return msgContent
702
+ if (Array.isArray(msgContent)) return msgContent.find(c => c.type === 'text')?.text || ''
703
+ return ''
704
+ }
705
+ const newText = text // content[0].text
706
+ const lastText = lastMessage && lastMessage.role === 'user' ? getLastText(lastMessage.content) : null
707
+ const isDuplicate = lastText === newText
708
+
709
+ // Add user message only if it's not a duplicate
710
+ // Note: We are saving the FULL STRUCTURED CONTENT array here
711
+ if (!isDuplicate) {
712
+ messages.push({
713
+ timestamp: new Date().valueOf(),
714
+ role: 'user',
715
+ content,
716
+ })
717
+ }
718
+ }
719
+
720
+ const request = ctx.chat.createRequest({ model: props.model })
721
+
722
+ // Add Thread History
723
+ messages.forEach(m => {
724
+ request.messages.push(m)
725
+ })
726
+
727
+ // Update Thread Title if not set or is default
728
+ if (!thread.title || thread.title === 'New Chat' || request.title === 'New Chat') {
729
+ request.title = text.length > 100
730
+ ? text.slice(0, 100) + '...'
731
+ : text
732
+ console.debug(`changing thread title from '${thread.title}' to '${request.title}'`)
733
+ } else {
734
+ console.debug(`thread title is '${thread.title}'`, request.title)
735
+ }
736
+
737
+ const api = await ctx.threads.queueChat({ request, thread })
738
+ if (api.response) {
739
+ // success
740
+ ctx.chat.editingMessage.value = null
741
+ ctx.chat.attachedFiles.value = []
742
+ thread = api.response
743
+ ctx.threads.replaceThread(thread)
744
+ } else {
745
+ ctx.setError(api.error)
746
+ }
747
+
748
+ // Restore focus to the textarea
749
+ nextTick(() => {
750
+ refMessage.value?.focus()
751
+ })
752
+ }
753
+
754
+ const addNewLine = () => {
755
+ // Enter key already adds new line
756
+ //messageText.value += '\n'
757
+ }
758
+
759
+ const onKeyDown = (e) => {
760
+ if (e.key === 'ArrowUp') {
761
+ if (refMessage.value.selectionStart === 0 && refMessage.value.selectionEnd === 0) {
762
+ if (promptHistory.value.length > 0) {
763
+ e.preventDefault()
764
+ if (historyIndex.value === -1) {
765
+ historyIndex.value = promptHistory.value.length - 1
766
+ } else {
767
+ historyIndex.value = Math.max(0, historyIndex.value - 1)
768
+ }
769
+ isNavigatingHistory.value = true
770
+ messageText.value = promptHistory.value[historyIndex.value]
771
+ nextTick(() => {
772
+ refMessage.value.setSelectionRange(0, 0)
773
+ })
774
+ }
775
+ }
776
+ } else if (e.key === 'ArrowDown') {
777
+ if (historyIndex.value !== -1) {
778
+ e.preventDefault()
779
+ if (historyIndex.value < promptHistory.value.length - 1) {
780
+ historyIndex.value++
781
+ isNavigatingHistory.value = true
782
+ messageText.value = promptHistory.value[historyIndex.value]
783
+ } else {
784
+ historyIndex.value = -1
785
+ isNavigatingHistory.value = true
786
+ messageText.value = ''
787
+ }
788
+ nextTick(() => {
789
+ refMessage.value.setSelectionRange(0, 0)
790
+ })
791
+ }
792
+ }
793
+ }
794
+
795
+ watch(messageText, (newValue) => {
796
+ if (!isNavigatingHistory.value) {
797
+ historyIndex.value = -1
798
+ }
799
+ isNavigatingHistory.value = false
800
+ })
801
+
802
+ watch(() => ctx.state.selectedAspectRatio, newValue => {
803
+ ctx.setPrefs({ aspectRatio: newValue })
804
+ })
805
+
806
+ watch(() => ctx.layout.path, newValue => {
807
+ if (newValue === '/' || newValue.startsWith('/c/')) {
808
+ nextTick(() => {
809
+ refMessage.value?.focus()
810
+ })
811
+ }
812
+ })
813
+
814
+ return {
815
+ messageText,
816
+ fileInput,
817
+ refMessage,
818
+ showSettings,
819
+ isDragging,
820
+ triggerFilePicker,
821
+ onFilesSelected,
822
+ onPaste,
823
+ onDragOver,
824
+ onDragLeave,
825
+ onDrop,
826
+ removeAttachment,
827
+ sendMessage,
828
+ addNewLine,
829
+ onKeyDown,
830
+ imageAspectRatios,
831
+ }
832
+ }
833
+ }
834
+
835
+ const HomeTools = {
836
+ template: `
837
+ <div class="mt-4 flex space-x-3 justify-center items-center">
838
+ <DarkModeToggle />
839
+ </div>
840
+ `,
841
+ }
842
+
843
+ const ThreadHeader = {
844
+ template: `
845
+ <div v-if="showComponents.length" class="flex items-center justify-center gap-2">
846
+ <div v-for="component in showComponents">
847
+ <component :is="component" :thread="thread" />
848
+ </div>
849
+ </div>
850
+ `,
851
+ props: { thread: Object },
852
+ setup(props) {
853
+ const ctx = inject('ctx')
854
+ const showComponents = computed(() => {
855
+ const args = { thread: props.thread }
856
+ return Object.values(ctx.threadHeaderComponents).filter(def => def.show(args)).map(def => def.component)
857
+ })
858
+ return {
859
+ showComponents,
860
+ }
861
+ }
862
+ }
863
+
864
+ const ThreadFooter = {
865
+ template: `
866
+ <div v-if="showComponents.length">
867
+ <div v-for="component in showComponents">
868
+ <component :is="component" :thread="thread" />
869
+ </div>
870
+ </div>
871
+ `,
872
+ props: { thread: Object },
873
+ setup(props) {
874
+ const ctx = inject('ctx')
875
+ const showComponents = computed(() => {
876
+ const args = { thread: props.thread }
877
+ return Object.values(ctx.threadFooterComponents).filter(def => def.show(args)).map(def => def.component)
878
+ })
879
+ return {
880
+ showComponents,
881
+ }
882
+ }
883
+ }
884
+
885
+ const ThreadModel = {
886
+ template: `
887
+ <span @click="$chat.setSelectedModel({ name: thread.model})"
888
+ class="flex items-center cursor-pointer px-1.5 py-0.5 text-xs rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-colors border hover:border-gray-300 dark:hover:border-gray-700">
889
+ <ProviderIcon class="size-4 mr-1" :provider="$chat.getProviderForModel(thread.model)" />
890
+ {{thread.model}}
891
+ </span>
892
+ `,
893
+ props: { thread: Object },
894
+ }
895
+
896
+ const ThreadTools = {
897
+ template: `
898
+ <div class="text-sm flex items-center gap-1 flex items-center px-1.5 py-0.5 text-xs rounded text-gray-600 dark:text-gray-300 border cursor-help" :title="title">
899
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 10h3V7L6.5 3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1-3 3l-6-6a6 6 0 0 1-8-8z"/></svg>
900
+ <span v-if="toolFns.length==1">{{toolFns[0].function.name}}</span>
901
+ <span v-else-if="toolFns.length>1">{{toolFns.length}} Tools</span>
902
+ </div>
903
+ `,
904
+ props: { thread: Object },
905
+ setup(props) {
906
+ const toolFns = computed(() => props.thread.tools.filter(x => x.type === 'function'))
907
+ const title = computed(() => toolFns.value.length == 1
908
+ ? toolFns.value[0].function.name
909
+ : toolFns.value.length > 1
910
+ ? toolFns.value.map(x => x.function.name).join('\n')
911
+ : '')
912
+ return {
913
+ toolFns,
914
+ title,
915
+ }
916
+ }
917
+ }
918
+
919
+ export default {
920
+ /**@param {AppContext} ctx */
921
+ install(ctx) {
922
+ const Home = ChatBody
923
+ ctx.components({
924
+ SettingsDialog,
925
+ ChatPrompt,
926
+
927
+ ChatBody,
928
+ MessageUsage,
929
+ MessageReasoning,
930
+ LightboxImage,
931
+ TypeText,
932
+ TypeImage,
933
+ TypeAudio,
934
+ TypeFile,
935
+ ViewType,
936
+ ViewTypes,
937
+ ViewToolTypes,
938
+ ToolArguments,
939
+ ToolOutput,
940
+
941
+ HomeTools,
942
+ Home,
943
+ ThreadHeader,
944
+ ThreadFooter,
945
+ })
946
+ ctx.setGlobals({
947
+ chat: useChatPrompt(ctx)
948
+ })
949
+
950
+ ctx.setLeftIcons({
951
+ chat: {
952
+ component: {
953
+ template: `<svg @click="$ctx.togglePath('/')" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 2.19c3.13 0 5.68 2.25 5.68 5s-2.55 5-5.68 5a5.7 5.7 0 0 1-1.89-.29l-.75-.26l-.56.56a14 14 0 0 1-2 1.55a.13.13 0 0 1-.07 0v-.06a6.58 6.58 0 0 0 .15-4.29a5.25 5.25 0 0 1-.55-2.16c0-2.77 2.55-5 5.68-5M8 .94c-3.83 0-6.93 2.81-6.93 6.27a6.4 6.4 0 0 0 .64 2.64a5.53 5.53 0 0 1-.18 3.48a1.32 1.32 0 0 0 2 1.5a15 15 0 0 0 2.16-1.71a6.8 6.8 0 0 0 2.31.36c3.83 0 6.93-2.81 6.93-6.27S11.83.94 8 .94"/><ellipse cx="5.2" cy="7.7" fill="currentColor" rx=".8" ry=".75"/><ellipse cx="8" cy="7.7" fill="currentColor" rx=".8" ry=".75"/><ellipse cx="10.8" cy="7.7" fill="currentColor" rx=".8" ry=".75"/></svg>`,
954
+ },
955
+ isActive({ path }) {
956
+ return path === '/' || path.startsWith('/c/')
957
+ }
958
+ }
959
+ })
960
+
961
+ const title = 'Chat'
962
+ ctx.setState({
963
+ title
964
+ })
965
+
966
+ const meta = { title }
967
+ ctx.routes.push(...[
968
+ { path: '/', component: Home, meta },
969
+ { path: '/c/:id', component: ChatBody, meta },
970
+ ])
971
+
972
+ ctx.setThreadHeaders({
973
+ model: {
974
+ component: ThreadModel,
975
+ show({ thread }) { return thread.model }
976
+ },
977
+ tools: {
978
+ component: ThreadTools,
979
+ show({ thread }) { return (thread.tools || []).filter(x => x.type === 'function').length }
980
+ }
981
+ })
982
+
983
+ const prefs = ctx.getPrefs()
984
+ if (prefs.model) {
985
+ ctx.state.selectedModel = prefs.model
986
+ }
987
+ ctx.setState({
988
+ selectedAspectRatio: prefs.aspectRatio || '1:1',
989
+ })
990
+ }
991
+ }