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