llms-py 2.0.9__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 (194) hide show
  1. llms/__init__.py +4 -0
  2. llms/__main__.py +9 -0
  3. llms/db.py +359 -0
  4. llms/extensions/analytics/ui/index.mjs +1444 -0
  5. llms/extensions/app/README.md +20 -0
  6. llms/extensions/app/__init__.py +589 -0
  7. llms/extensions/app/db.py +536 -0
  8. {llms_py-2.0.9.data/data → llms/extensions/app}/ui/Recents.mjs +100 -73
  9. llms_py-2.0.9.data/data/ui/Sidebar.mjs → llms/extensions/app/ui/index.mjs +150 -79
  10. llms/extensions/app/ui/threadStore.mjs +433 -0
  11. llms/extensions/core_tools/CALCULATOR.md +32 -0
  12. llms/extensions/core_tools/__init__.py +637 -0
  13. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +201 -0
  14. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +185 -0
  15. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +101 -0
  16. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +160 -0
  17. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +66 -0
  18. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +27 -0
  19. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +72 -0
  20. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +119 -0
  21. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +98 -0
  22. llms/extensions/core_tools/ui/codemirror/codemirror.css +344 -0
  23. llms/extensions/core_tools/ui/codemirror/codemirror.js +9884 -0
  24. llms/extensions/core_tools/ui/codemirror/doc/docs.css +225 -0
  25. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  26. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +942 -0
  27. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +118 -0
  28. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +962 -0
  29. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +62 -0
  30. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +402 -0
  31. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +40 -0
  32. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +135 -0
  33. llms/extensions/core_tools/ui/index.mjs +650 -0
  34. llms/extensions/gallery/README.md +61 -0
  35. llms/extensions/gallery/__init__.py +63 -0
  36. llms/extensions/gallery/db.py +243 -0
  37. llms/extensions/gallery/ui/index.mjs +482 -0
  38. llms/extensions/katex/README.md +39 -0
  39. llms/extensions/katex/__init__.py +6 -0
  40. llms/extensions/katex/ui/README.md +125 -0
  41. llms/extensions/katex/ui/contrib/auto-render.js +338 -0
  42. llms/extensions/katex/ui/contrib/auto-render.min.js +1 -0
  43. llms/extensions/katex/ui/contrib/auto-render.mjs +244 -0
  44. llms/extensions/katex/ui/contrib/copy-tex.js +127 -0
  45. llms/extensions/katex/ui/contrib/copy-tex.min.js +1 -0
  46. llms/extensions/katex/ui/contrib/copy-tex.mjs +105 -0
  47. llms/extensions/katex/ui/contrib/mathtex-script-type.js +109 -0
  48. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +1 -0
  49. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +24 -0
  50. llms/extensions/katex/ui/contrib/mhchem.js +3213 -0
  51. llms/extensions/katex/ui/contrib/mhchem.min.js +1 -0
  52. llms/extensions/katex/ui/contrib/mhchem.mjs +3109 -0
  53. llms/extensions/katex/ui/contrib/render-a11y-string.js +887 -0
  54. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +1 -0
  55. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +800 -0
  56. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  57. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  58. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  59. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  60. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  61. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  62. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  63. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  64. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  65. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  66. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  67. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  68. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  69. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  70. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  71. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  72. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  73. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  74. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  75. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  76. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  77. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  78. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  79. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  80. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  81. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  82. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  83. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  84. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  85. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  86. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  87. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  88. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  89. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  90. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  91. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  92. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  93. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  94. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  95. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  96. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  97. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  98. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  99. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  100. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  101. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  102. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  103. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  104. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  116. llms/extensions/katex/ui/index.mjs +92 -0
  117. llms/extensions/katex/ui/katex-swap.css +1230 -0
  118. llms/extensions/katex/ui/katex-swap.min.css +1 -0
  119. llms/extensions/katex/ui/katex.css +1230 -0
  120. llms/extensions/katex/ui/katex.js +19080 -0
  121. llms/extensions/katex/ui/katex.min.css +1 -0
  122. llms/extensions/katex/ui/katex.min.js +1 -0
  123. llms/extensions/katex/ui/katex.min.mjs +1 -0
  124. llms/extensions/katex/ui/katex.mjs +18547 -0
  125. llms/extensions/providers/__init__.py +22 -0
  126. llms/extensions/providers/anthropic.py +233 -0
  127. llms/extensions/providers/cerebras.py +37 -0
  128. llms/extensions/providers/chutes.py +153 -0
  129. llms/extensions/providers/google.py +481 -0
  130. llms/extensions/providers/nvidia.py +103 -0
  131. llms/extensions/providers/openai.py +154 -0
  132. llms/extensions/providers/openrouter.py +74 -0
  133. llms/extensions/providers/zai.py +182 -0
  134. llms/extensions/system_prompts/README.md +22 -0
  135. llms/extensions/system_prompts/__init__.py +45 -0
  136. llms/extensions/system_prompts/ui/index.mjs +280 -0
  137. llms/extensions/system_prompts/ui/prompts.json +1067 -0
  138. llms/extensions/tools/__init__.py +144 -0
  139. llms/extensions/tools/ui/index.mjs +706 -0
  140. llms/index.html +58 -0
  141. llms/llms.json +400 -0
  142. llms/main.py +4407 -0
  143. llms/providers-extra.json +394 -0
  144. llms/providers.json +1 -0
  145. llms/ui/App.mjs +188 -0
  146. llms/ui/ai.mjs +217 -0
  147. llms/ui/app.css +7081 -0
  148. llms/ui/ctx.mjs +412 -0
  149. llms/ui/index.mjs +131 -0
  150. llms/ui/lib/chart.js +14 -0
  151. llms/ui/lib/charts.mjs +16 -0
  152. llms/ui/lib/color.js +14 -0
  153. llms/ui/lib/servicestack-vue.mjs +37 -0
  154. llms/ui/lib/vue.min.mjs +13 -0
  155. llms/ui/lib/vue.mjs +18530 -0
  156. {llms_py-2.0.9.data/data → llms}/ui/markdown.mjs +33 -15
  157. llms/ui/modules/chat/ChatBody.mjs +976 -0
  158. llms/ui/modules/chat/SettingsDialog.mjs +374 -0
  159. llms/ui/modules/chat/index.mjs +991 -0
  160. llms/ui/modules/icons.mjs +46 -0
  161. llms/ui/modules/layout.mjs +271 -0
  162. llms/ui/modules/model-selector.mjs +811 -0
  163. llms/ui/tailwind.input.css +742 -0
  164. {llms_py-2.0.9.data/data → llms}/ui/typography.css +133 -7
  165. llms/ui/utils.mjs +261 -0
  166. llms_py-3.0.10.dist-info/METADATA +49 -0
  167. llms_py-3.0.10.dist-info/RECORD +177 -0
  168. llms_py-3.0.10.dist-info/entry_points.txt +2 -0
  169. {llms_py-2.0.9.dist-info → llms_py-3.0.10.dist-info}/licenses/LICENSE +1 -2
  170. llms.py +0 -1402
  171. llms_py-2.0.9.data/data/index.html +0 -64
  172. llms_py-2.0.9.data/data/llms.json +0 -447
  173. llms_py-2.0.9.data/data/requirements.txt +0 -1
  174. llms_py-2.0.9.data/data/ui/App.mjs +0 -20
  175. llms_py-2.0.9.data/data/ui/ChatPrompt.mjs +0 -389
  176. llms_py-2.0.9.data/data/ui/Main.mjs +0 -680
  177. llms_py-2.0.9.data/data/ui/app.css +0 -3951
  178. llms_py-2.0.9.data/data/ui/lib/servicestack-vue.min.mjs +0 -37
  179. llms_py-2.0.9.data/data/ui/lib/vue.min.mjs +0 -12
  180. llms_py-2.0.9.data/data/ui/tailwind.input.css +0 -261
  181. llms_py-2.0.9.data/data/ui/threadStore.mjs +0 -273
  182. llms_py-2.0.9.data/data/ui/utils.mjs +0 -114
  183. llms_py-2.0.9.data/data/ui.json +0 -1069
  184. llms_py-2.0.9.dist-info/METADATA +0 -941
  185. llms_py-2.0.9.dist-info/RECORD +0 -30
  186. llms_py-2.0.9.dist-info/entry_points.txt +0 -2
  187. {llms_py-2.0.9.data/data → llms}/ui/fav.svg +0 -0
  188. {llms_py-2.0.9.data/data → llms}/ui/lib/highlight.min.mjs +0 -0
  189. {llms_py-2.0.9.data/data → llms}/ui/lib/idb.min.mjs +0 -0
  190. {llms_py-2.0.9.data/data → llms}/ui/lib/marked.min.mjs +0 -0
  191. /llms_py-2.0.9.data/data/ui/lib/servicestack-client.min.mjs → /llms/ui/lib/servicestack-client.mjs +0 -0
  192. {llms_py-2.0.9.data/data → llms}/ui/lib/vue-router.min.mjs +0 -0
  193. {llms_py-2.0.9.dist-info → llms_py-3.0.10.dist-info}/WHEEL +0 -0
  194. {llms_py-2.0.9.dist-info → llms_py-3.0.10.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,976 @@
1
+ import { ref, computed, nextTick, watch, onMounted, onUnmounted, inject } from 'vue'
2
+ import { useRouter, useRoute } from 'vue-router'
3
+
4
+ function tryParseJson(str) {
5
+ try {
6
+ return JSON.parse(str)
7
+ } catch (e) {
8
+ return null
9
+ }
10
+ }
11
+ function hasJsonStructure(str) {
12
+ return tryParseJson(str) != null
13
+ }
14
+
15
+ function isEmpty(v) {
16
+ return !v || v === '{}' || v === '[]' || v === 'null' || v === 'undefined' || v === '""' || v === "''" || v === "``"
17
+ }
18
+ function embedHtml(html) {
19
+ const resizeScript = `<script>
20
+ let lastH = 0;
21
+ const sendHeight = () => {
22
+ const body = document.body;
23
+ if (!body) return;
24
+ // Force re-calc
25
+ const h = document.documentElement.getBoundingClientRect().height;
26
+ if (Math.abs(h - lastH) > 2) {
27
+ lastH = h;
28
+ window.parent.postMessage({ type: 'iframe-resize', height: h }, '*');
29
+ }
30
+ }
31
+ const ro = new ResizeObserver(sendHeight);
32
+ window.addEventListener('load', () => {
33
+ // Inject styles to prevent infinite loops
34
+ const style = document.createElement('style');
35
+ style.textContent = 'html, body { height: auto !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; }';
36
+ document.head.appendChild(style);
37
+
38
+ const body = document.body;
39
+ if (body) {
40
+ ro.observe(body);
41
+ ro.observe(document.documentElement);
42
+ sendHeight();
43
+ }
44
+ });
45
+ <\/script>`
46
+
47
+ const escaped = (html + resizeScript)
48
+ .replace(/&/g, '&amp;')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;')
51
+ .replace(/"/g, '&quot;')
52
+ .replace(/'/g, '&#39;')
53
+ return `<iframe srcdoc="${escaped}" sandbox="allow-scripts" style="width:100%;height:auto;border:none;"></iframe>`
54
+ }
55
+
56
+ export const TypeText = {
57
+ template: `
58
+ <div v-if="text.type === 'text'">
59
+ <div v-html="html"></div>
60
+ </div>
61
+ `,
62
+ props: {
63
+ text: {
64
+ type: Object,
65
+ required: true
66
+ }
67
+ },
68
+ setup(props) {
69
+ const ctx = inject('ctx')
70
+ const html = computed(() => {
71
+ try {
72
+ return ctx.fmt.markdown(props.text.text)
73
+ } catch (e) {
74
+ console.error('TypeText: markdown', e)
75
+ return `<div class="whitespace-pre-wrap">${props.text.text}</div>`
76
+ }
77
+ })
78
+ return { html }
79
+ }
80
+ }
81
+
82
+ export const LightboxImage = {
83
+ template: `
84
+ <div>
85
+ <!-- Thumbnail -->
86
+ <div
87
+ class="cursor-zoom-in hover:opacity-90 transition-opacity"
88
+ @click="isOpen = true"
89
+ >
90
+ <img
91
+ :src="src"
92
+ :alt="alt"
93
+ :width="width"
94
+ :height="height"
95
+ :class="imageClass"
96
+ />
97
+ </div>
98
+
99
+ <!-- Lightbox Modal -->
100
+ <div v-if="isOpen"
101
+ class="fixed inset-0 z-100 flex items-center justify-center bg-black/90 p-4"
102
+ @click="isOpen = false"
103
+ >
104
+ <button type="button"
105
+ class="absolute top-4 right-4 p-2 text-white hover:bg-white/10 rounded-lg transition-colors"
106
+ @click="isOpen = false"
107
+ aria-label="Close lightbox"
108
+ >
109
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
110
+ </button>
111
+ <div class="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
112
+ <img
113
+ :src="src"
114
+ :alt="alt"
115
+ :width="width"
116
+ :height="height"
117
+ class="max-w-full max-h-full w-auto h-auto object-contain rounded"
118
+ @click.stop
119
+ />
120
+ </div>
121
+ </div>
122
+ </div>
123
+ `,
124
+ props: {
125
+ src: {
126
+ type: String,
127
+ required: true
128
+ },
129
+ alt: {
130
+ type: String,
131
+ default: ''
132
+ },
133
+ width: {
134
+ type: [Number, String],
135
+ default: undefined
136
+ },
137
+ height: {
138
+ type: [Number, String],
139
+ default: undefined
140
+ },
141
+ imageClass: {
142
+ type: String,
143
+ default: 'max-w-[400px] max-h-96 rounded-lg border border-gray-200 dark:border-gray-700 object-contain bg-gray-50 dark:bg-gray-900 shadow-sm transition-transform hover:scale-[1.02]'
144
+ }
145
+ },
146
+ setup(props) {
147
+ const ctx = inject('ctx')
148
+ const isOpen = ref(false)
149
+
150
+ let sub
151
+ onMounted(() => {
152
+ sub = ctx.events.subscribe(`keydown:Escape`, () => isOpen.value = false)
153
+ })
154
+ onUnmounted(() => sub?.unsubscribe())
155
+
156
+ return {
157
+ isOpen
158
+ }
159
+ }
160
+ }
161
+
162
+ export const TypeImage = {
163
+ template: `
164
+ <div v-if="image.type === 'image_url'">
165
+ <LightboxImage :src="$ctx.resolveUrl(image.image_url.url)" />
166
+ </div>
167
+ `,
168
+ props: {
169
+ image: {
170
+ type: Object,
171
+ required: true
172
+ }
173
+ }
174
+ }
175
+
176
+ export const TypeAudio = {
177
+ template: `
178
+ <div v-if="audio.type === 'audio_url'">
179
+ <slot></slot>
180
+ <audio controls :src="$ctx.resolveUrl(audio.audio_url.url)" class="h-8 w-64"></audio>
181
+ </div>
182
+ `,
183
+ props: {
184
+ audio: {
185
+ type: Object,
186
+ required: true
187
+ }
188
+ }
189
+ }
190
+
191
+ export const TypeFile = {
192
+ template: `
193
+ <a v-if="file.type === 'file'" :href="$ctx.resolveUrl(file.file.file_data)" target="_blank"
194
+ class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm text-blue-600 dark:text-blue-400 hover:underline">
195
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
196
+ <span class="max-w-xs truncate">{{ file.file.filename || 'Attachment' }}</span>
197
+ </a>
198
+ `,
199
+ props: {
200
+ file: {
201
+ type: Object,
202
+ required: true
203
+ }
204
+ }
205
+ }
206
+
207
+ export const ViewType = {
208
+ template: `
209
+ <div class="flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
210
+ <TypeText v-if="result.type === 'text'" :text="result" />
211
+ <TypeImage v-else-if="result.type === 'image_url'" :image="result" />
212
+ <TypeAudio v-else-if="result.type === 'audio_url'" :audio="result" />
213
+ <TypeFile v-else-if="result.type === 'file'" :file="result" />
214
+ <div v-else>
215
+ <HtmlFormat :value="result" :classes="$utils.htmlFormatClasses" />
216
+ </div>
217
+ </div>
218
+ `,
219
+ props: {
220
+ result: {
221
+ type: Object,
222
+ required: true
223
+ }
224
+ }
225
+ }
226
+ export const ViewTypes = {
227
+ template: `
228
+ <div v-if="results?.length" class="flex flex-col gap-2">
229
+ <div v-if="texts.length > 0" :class="cls">
230
+ <div v-if="hasResources" v-for="(text, i) in texts" :key="'raw-' + i" class="text-xs whitespace-pre-wrap">{{text.text}}</div>
231
+ <TypeText v-else v-for="(text, i) in texts" :key="'text-' + i" :text="text" />
232
+ </div>
233
+ <div v-if="images.length > 0" :class="cls">
234
+ <TypeImage v-for="(image, i) in images" :key="'image-' + i" :image="image" />
235
+ </div>
236
+ <div v-if="audios.length > 0" :class="cls">
237
+ <TypeAudio v-for="(audio, i) in audios" :key="'audio-' + i" :audio="audio" />
238
+ </div>
239
+ <div v-if="files.length > 0" :class="cls">
240
+ <TypeFile v-for="(file, i) in files" :key="'file-' + i" :file="file" />
241
+ </div>
242
+ <div v-if="others.length > 0" :class="cls">
243
+ <HtmlFormat v-for="(other, i) in others" :key="'other-' + i" :value="other" :classes="$utils.htmlFormatClasses" />
244
+ </div>
245
+ </div>
246
+ `,
247
+ props: {
248
+ results: {
249
+ type: Array,
250
+ required: true
251
+ }
252
+ },
253
+ setup(props) {
254
+ const cls = "flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
255
+ const texts = computed(() => props.results.filter(r => r.type === 'text'))
256
+ const images = computed(() => props.results.filter(r => r.type === 'image_url'))
257
+ const audios = computed(() => props.results.filter(r => r.type === 'audio_url'))
258
+ const files = computed(() => props.results.filter(r => r.type === 'file'))
259
+ const others = computed(() => props.results.filter(r => r.type !== 'text' && r.type !== 'image_url' && r.type !== 'audio_url' && r.type !== 'file'))
260
+ // If has resources, render as plain-text to avoid rendering resources multiple times
261
+ const hasResources = computed(() => images.value.length > 0 || audios.value.length > 0 || files.value.length > 0 || others.value.length > 0)
262
+ return { cls, texts, images, audios, files, others, hasResources }
263
+ }
264
+ }
265
+ export const ViewToolTypes = {
266
+ template: `<ViewTypes v-if="results?.length" :results="results" />`,
267
+ props: {
268
+ output: Object,
269
+ },
270
+ setup(props) {
271
+ const results = computed(() => {
272
+ const ret = []
273
+ if (!props.output) return ret
274
+ if (props.output.images) {
275
+ ret.push(...props.output.images)
276
+ }
277
+ if (props.output.audios) {
278
+ ret.push(...props.output.audios)
279
+ }
280
+ if (props.output.files) {
281
+ ret.push(...props.output.files)
282
+ }
283
+ return ret
284
+ })
285
+ return { results }
286
+ }
287
+ }
288
+
289
+
290
+ export const MessageUsage = {
291
+ template: `
292
+ <div class="mt-2 text-xs opacity-70">
293
+ <span v-if="message.model" @click="$chat.setSelectedModel({ name: message.model })" title="Select model"><span class="cursor-pointer hover:underline">{{ message.model }}</span> &#8226; </span>
294
+ <span>{{ $fmt.time(message.timestamp) }}</span>
295
+ <span v-if="usage" :title="$fmt.tokensTitle(usage)">
296
+ &#8226;
297
+ {{ $fmt.humanifyNumber(usage.tokens) }} tokens
298
+ <span v-if="usage.cost">&#183; {{ $fmt.tokenCostLong(usage.cost) }}</span>
299
+ <span v-if="usage.duration"> in {{ $fmt.humanifyMs(usage.duration * 1000) }}</span>
300
+ </span>
301
+ </div>
302
+ `,
303
+ props: {
304
+ usage: Object,
305
+ message: Object,
306
+ }
307
+ }
308
+
309
+ export const MessageReasoning = {
310
+ template: `
311
+ <div class="mt-2 mb-2">
312
+ <button type="button" @click="toggleReasoning(message.timestamp)" class="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 flex items-center space-x-1">
313
+ <svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" :class="isReasoningExpanded(message.timestamp) ? 'transform rotate-90' : ''"><path fill="currentColor" d="M7 5l6 5l-6 5z"/></svg>
314
+ <span>{{ isReasoningExpanded(message.timestamp) ? 'Hide reasoning' : 'Show reasoning' }}</span>
315
+ </button>
316
+ <div v-if="isReasoningExpanded(message.timestamp)" class="reasoning mt-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-2">
317
+ <div v-if="typeof reasoning === 'string'" v-html="$fmt.markdown(reasoning)" class="prose prose-xs max-w-none dark:prose-invert"></div>
318
+ <pre v-else class="text-xs whitespace-pre-wrap overflow-x-auto">{{ formatReasoning(reasoning) }}</pre>
319
+ </div>
320
+ </div>
321
+ `,
322
+ props: {
323
+ reasoning: String,
324
+ message: Object,
325
+ },
326
+ setup(props) {
327
+ const expandedReasoning = ref(new Set())
328
+ const isReasoningExpanded = (id) => expandedReasoning.value.has(id)
329
+ const toggleReasoning = (id) => {
330
+ const s = new Set(expandedReasoning.value)
331
+ if (s.has(id)) {
332
+ s.delete(id)
333
+ } else {
334
+ s.add(id)
335
+ }
336
+ expandedReasoning.value = s
337
+ }
338
+ const formatReasoning = (r) => typeof r === 'string' ? r : JSON.stringify(r, null, 2)
339
+
340
+ return {
341
+ expandedReasoning,
342
+ isReasoningExpanded,
343
+ toggleReasoning,
344
+ formatReasoning,
345
+ }
346
+ }
347
+ }
348
+
349
+ export const ToolArguments = {
350
+ template: `
351
+ <div ref="refArgs" v-if="dict" class="not-prose">
352
+ <div class="prose html-format">
353
+ <table class="table-object border-none">
354
+ <tr v-for="(v, k) in dict" :key="k">
355
+ <td class="align-top py-2 px-4 text-left text-sm font-medium tracking-wider whitespace-nowrap lowercase">{{ k }}</td>
356
+ <td v-if="$utils.isHtml(v)" style="margin:0;padding:0;width:100%">
357
+ <div v-html="embedHtml(v)" class="w-full h-full"></div>
358
+ </td>
359
+ <td v-else class="align-top py-2 px-4 text-sm whitespace-pre-wrap">
360
+ <HtmlFormat :value="v" :classes="$utils.htmlFormatClasses" />
361
+ </td>
362
+ </tr>
363
+ </table>
364
+ </div>
365
+ </div>
366
+ <div v-else-if="list" class="not-prose px-3 py-2">
367
+ <HtmlFormat :value="list" :classes="$utils.htmlFormatClasses" />
368
+ </div>
369
+ <pre v-else-if="!isEmpty(value)" class="tool-arguments">{{ value }}</pre>
370
+ `,
371
+ props: {
372
+ value: String,
373
+ },
374
+ setup(props) {
375
+ const refArgs = ref()
376
+ const dict = computed(() => {
377
+ if (isEmpty(props.value)) return null
378
+ const ret = tryParseJson(props.value)
379
+ return typeof ret === 'object' && !Array.isArray(ret) ? ret : null
380
+ })
381
+ const list = computed(() => {
382
+ if (isEmpty(props.value)) return null
383
+ const ret = tryParseJson(props.value)
384
+ return Array.isArray(ret) && ret.length > 0 ? ret : null
385
+ })
386
+
387
+ const handleMessage = (event) => {
388
+ if (event.data?.type === 'iframe-resize' && typeof event.data.height === 'number') {
389
+ const iframes = refArgs.value?.querySelectorAll('iframe')
390
+ iframes?.forEach(iframe => {
391
+ if (iframe.contentWindow === event.source) {
392
+ iframe.style.height = (event.data.height + 30) + 'px'
393
+ }
394
+ })
395
+ }
396
+ }
397
+
398
+ onMounted(() => {
399
+ window.addEventListener('message', handleMessage)
400
+ const hasIframes = refArgs.value?.querySelector('iframe')
401
+ if (hasIframes) {
402
+ refArgs.value.classList.add('has-iframes')
403
+ }
404
+ })
405
+
406
+ onUnmounted(() => {
407
+ window.removeEventListener('message', handleMessage)
408
+ })
409
+
410
+ return {
411
+ refArgs,
412
+ dict,
413
+ list,
414
+ isEmpty,
415
+ embedHtml,
416
+ }
417
+ }
418
+ }
419
+
420
+ export const ToolOutput = {
421
+ template: `
422
+ <div v-if="output" class="border-t border-gray-200 dark:border-gray-700">
423
+ <div class="px-3 py-1.5 flex justify-between items-center border-b border-gray-200 dark:border-gray-800 bg-gray-50/30 dark:bg-gray-800">
424
+ <div class="flex items-center gap-2 ">
425
+ <svg class="size-3.5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
426
+ <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Output</span>
427
+ </div>
428
+ <div v-if="hasJsonStructure(output.content)" class="flex items-center gap-2 text-[10px] uppercase tracking-wider font-medium select-none">
429
+ <span @click="$ctx.setPrefs({ toolFormat: 'text' })"
430
+ class="cursor-pointer transition-colors"
431
+ :class="$ctx.prefs.toolFormat !== 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
432
+ text
433
+ </span>
434
+ <span class="text-gray-300 dark:text-gray-700">|</span>
435
+ <span @click="$ctx.setPrefs({ toolFormat: 'preview' })"
436
+ class="cursor-pointer transition-colors"
437
+ :class="$ctx.prefs.toolFormat == 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
438
+ preview
439
+ </span>
440
+ </div>
441
+ </div>
442
+ <div class="not-prose px-3 py-2">
443
+ <pre v-if="$ctx.prefs.toolFormat !== 'preview' || !hasJsonStructure(output.content)" class="tool-output">{{ output.content }}</pre>
444
+ <div v-else class="text-xs">
445
+ <HtmlFormat v-if="tryParseJson(output.content)" :value="tryParseJson(output.content)" :classes="$utils.htmlFormatClasses" />
446
+ <div v-else class="text-gray-500 italic p-2">Invalid JSON content</div>
447
+ </div>
448
+ </div>
449
+ <ViewToolTypes :output="output" class="p-2" />
450
+ </div>
451
+ `,
452
+ props: {
453
+ tool: Object,
454
+ output: Object,
455
+ },
456
+ setup(props) {
457
+
458
+ return {
459
+ tryParseJson,
460
+ hasJsonStructure,
461
+ }
462
+ }
463
+ }
464
+
465
+ export const ChatBody = {
466
+ template: `
467
+ <div class="flex flex-col h-full">
468
+ <!-- Messages Area -->
469
+ <div class="flex-1 overflow-y-auto" ref="messagesContainer">
470
+ <div class="mx-auto max-w-6xl px-4 py-6">
471
+
472
+ <div v-if="!$ai.hasAccess">
473
+ <OAuthSignIn v-if="$ai.authType === 'oauth'" @done="$ai.signIn($event)" />
474
+ <SignIn v-else @done="$ai.signIn($event)" />
475
+ </div>
476
+ <!-- Welcome message when no thread is selected -->
477
+ <div v-else-if="!currentThread" class="text-center py-12">
478
+ <Welcome />
479
+ <HomeTools />
480
+ </div>
481
+
482
+ <!-- Messages -->
483
+ <div v-else-if="currentThread">
484
+ <ThreadHeader v-if="currentThread" :thread="currentThread" class="mb-2" />
485
+ <div class="space-y-2" v-if="currentThread?.messages?.length">
486
+ <div
487
+ v-for="message in currentThread.messages.filter(x => x.role !== 'system')"
488
+ :key="message.timestamp"
489
+ v-show="!(message.role === 'tool' && isToolLinked(message))"
490
+ class="flex items-start space-x-3 group"
491
+ :class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
492
+ >
493
+ <!-- Avatar outside the bubble -->
494
+ <div class="flex-shrink-0 flex flex-col justify-center">
495
+ <div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
496
+ :class="message.role === 'user'
497
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
498
+ : message.role === 'tool'
499
+ ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800'
500
+ : 'bg-gray-600 dark:bg-gray-500 text-white'"
501
+ >
502
+ <span v-if="message.role === 'user'">U</span>
503
+ <svg v-else-if="message.role === 'tool'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
504
+ <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
505
+ </svg>
506
+ <span v-else>AI</span>
507
+ </div>
508
+
509
+ <!-- Delete button (shown on hover) -->
510
+ <button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.timestamp)"
511
+ class="mx-auto opacity-0 group-hover:opacity-100 mt-2 rounded text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-all"
512
+ title="Delete message">
513
+ <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
514
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
515
+ </svg>
516
+ </button>
517
+ </div>
518
+
519
+ <!-- Message bubble -->
520
+ <div
521
+ class="message rounded-lg px-4 py-3 relative group"
522
+ :class="message.role === 'user'
523
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
524
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700'"
525
+ >
526
+ <!-- Copy button in top right corner -->
527
+ <button
528
+ type="button"
529
+ @click="copyMessageContent(message)"
530
+ class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 focus:outline-none focus:ring-0"
531
+ :class="message.role === 'user' ? 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'"
532
+ title="Copy message content"
533
+ >
534
+ <svg v-if="copying === message" class="size-4 text-green-500 dark:text-green-400" 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>
535
+ <svg v-else class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
536
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
537
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
538
+ </svg>
539
+ </button>
540
+
541
+ <div
542
+ v-if="message.role === 'assistant'"
543
+ v-html="$fmt.markdown(message.content)"
544
+ class="prose prose-sm max-w-none dark:prose-invert"
545
+ ></div>
546
+
547
+ <!-- Collapsible reasoning section -->
548
+ <MessageReasoning v-if="message.role === 'assistant' && (message.reasoning || message.thinking || message.reasoning_content)"
549
+ :reasoning="message.reasoning || message.thinking || message.reasoning_content" :message="message" />
550
+
551
+ <!-- Tool Calls & Outputs -->
552
+ <div v-if="message.tool_calls && message.tool_calls.length > 0" class="mb-3 space-y-4">
553
+ <div v-for="(tool, i) in message.tool_calls" :key="i" class="rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
554
+ <!-- Tool Call Header -->
555
+ <div class="px-3 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between bg-gray-50/30 dark:bg-gray-800 space-x-4">
556
+ <div class="flex items-center gap-2">
557
+ <svg class="size-3.5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>
558
+ <span class="font-mono text-xs font-bold text-gray-700 dark:text-gray-300">{{ tool.function.name }}</span>
559
+ </div>
560
+ <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Tool Call</span>
561
+ </div>
562
+
563
+ <ToolArguments :value="tool.function.arguments" />
564
+
565
+ <ToolOutput :tool="tool" :output="getToolOutput(tool.id)" />
566
+
567
+ </div>
568
+ </div>
569
+
570
+ <!-- Tool Output (Orphaned) -->
571
+ <div v-if="message.role === 'tool' && !isToolLinked(message)" class="text-sm">
572
+ <div class="flex items-center gap-2 mb-1 opacity-70">
573
+ <div class="flex items-center text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">
574
+ <svg class="size-3 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
575
+ Tool Output
576
+ </div>
577
+ <div v-if="message.name" class="text-xs font-mono bg-gray-200 dark:bg-gray-700 px-1.5 rounded text-gray-700 dark:text-gray-300">
578
+ {{ message.name }}
579
+ </div>
580
+ <div v-if="message.tool_call_id" class="text-[10px] font-mono text-gray-400">
581
+ {{ message.tool_call_id.slice(0,8) }}
582
+ </div>
583
+ </div>
584
+ <div class="not-prose bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-800 p-2 overflow-x-auto">
585
+ <pre class="tool-output">{{ message.content }}</pre>
586
+ </div>
587
+ </div>
588
+
589
+ <!-- Assistant Images -->
590
+ <div v-if="message.images && message.images.length > 0" class="mt-2 flex flex-wrap gap-2">
591
+ <template v-for="(img, i) in message.images" :key="i">
592
+ <TypeImage v-if="img.type === 'image_url'" :image="img" />
593
+ </template>
594
+ </div>
595
+
596
+ <!-- Assistant Audios -->
597
+ <div v-if="message.audios && message.audios.length > 0" class="mt-2 flex flex-wrap gap-2">
598
+ <template v-for="(audio, i) in message.audios" :key="i">
599
+ <TypeAudio v-if="audio.type === 'audio_url'" :audio="audio"
600
+ class="flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
601
+ <svg class="w-5 h-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>
602
+ </TypeAudio>
603
+ </template>
604
+ </div>
605
+
606
+ <!-- User Message with separate attachments -->
607
+ <div v-else-if="message.role !== 'assistant' && message.role !== 'tool'">
608
+ <div v-html="$fmt.content(message.content)" class="prose prose-sm max-w-none dark:prose-invert break-words"></div>
609
+ <ViewTypes :results="getAttachments(message)" />
610
+ </div>
611
+
612
+ <MessageUsage :message="message" :usage="getMessageUsage(message)" />
613
+ </div>
614
+
615
+ <!-- Edit and Redo buttons (shown on hover for user messages, outside bubble) -->
616
+ <div v-if="message.role === 'user'" class="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-1">
617
+ <button type="button" @click.stop="editMessage(message)"
618
+ class="whitespace-nowrap text-xs px-2 py-1 rounded text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30 transition-all"
619
+ title="Edit message">
620
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
621
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
622
+ </svg>
623
+ Edit
624
+ </button>
625
+ <button type="button" @click.stop="redoMessage(message)"
626
+ class="whitespace-nowrap text-xs px-2 py-1 rounded text-gray-400 dark:text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-all"
627
+ title="Redo message (clears all responses after this message and re-runs it)">
628
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
629
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
630
+ </svg>
631
+ Redo
632
+ </button>
633
+ </div>
634
+ </div>
635
+
636
+ <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 dark:text-gray-400 text-sm">
637
+ <span :title="$fmt.statsTitle(currentThread.stats)">
638
+ {{ currentThread.stats.cost ? $fmt.costLong(currentThread.stats.cost) + ' for ' : '' }} {{ $fmt.humanifyNumber(currentThread.stats.inputTokens) }} → {{ $fmt.humanifyNumber(currentThread.stats.outputTokens) }} tokens over {{ currentThread.stats.requests }} request{{currentThread.stats.requests===1?'':'s'}} in {{ $fmt.humanifyMs(currentThread.stats.duration * 1000) }}
639
+ </span>
640
+ </div>
641
+
642
+ <!-- Loading indicator -->
643
+ <div v-if="$threads.watchingThread" class="flex items-start space-x-3 group">
644
+ <!-- Avatar outside the bubble -->
645
+ <div class="flex-shrink-0">
646
+ <div class="w-8 h-8 rounded-full bg-gray-600 dark:bg-gray-500 text-white flex items-center justify-center text-sm font-medium">
647
+ AI
648
+ </div>
649
+ </div>
650
+
651
+ <!-- Loading bubble -->
652
+ <div class="rounded-lg px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
653
+ <div class="flex space-x-1">
654
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"></div>
655
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
656
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
657
+ </div>
658
+ </div>
659
+
660
+ <!-- Cancel button -->
661
+ <button type="button" @click="$threads.cancelThread()"
662
+ class="px-3 py-1 rounded text-sm text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 border border-transparent hover:border-red-300 dark:hover:border-red-600 transition-all"
663
+ title="Cancel request">
664
+ cancel
665
+ </button>
666
+ </div>
667
+
668
+ <!-- Thread error message bubble -->
669
+ <div v-if="currentThread?.error" class="mt-8 flex items-center space-x-3">
670
+ <!-- Avatar outside the bubble -->
671
+ <div class="flex-shrink-0">
672
+ <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
673
+ !
674
+ </div>
675
+ </div>
676
+ <!-- Error bubble -->
677
+ <div class="max-w-[85%] rounded-lg px-3 py-1 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 shadow-sm">
678
+ <div class="flex items-start space-x-2">
679
+ <div class="flex-1 min-w-0">
680
+ <div v-if="currentThread.error" class="text-base mb-1">{{ currentThread.error }}</div>
681
+ </div>
682
+ </div>
683
+ </div>
684
+ </div>
685
+
686
+ <!-- Error message bubble -->
687
+ <div v-if="$state.error" class="mt-8 flex items-start space-x-3">
688
+ <!-- Avatar outside the bubble -->
689
+ <div class="flex-shrink-0">
690
+ <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
691
+ !
692
+ </div>
693
+ </div>
694
+
695
+ <!-- Error bubble -->
696
+ <div class="max-w-[85%] rounded-lg px-4 py-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 shadow-sm">
697
+ <div class="flex items-start space-x-2">
698
+ <div class="flex-1 min-w-0">
699
+ <div class="flex justify-between items-start">
700
+ <div class="text-base font-medium mb-1">{{ $state.error?.errorCode || 'Error' }}</div>
701
+ <button type="button" @click="$ctx.clearError()" title="Clear Error"
702
+ class="text-red-400 dark:text-red-300 hover:text-red-600 dark:hover:text-red-100 flex-shrink-0">
703
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
704
+ <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
705
+ </svg>
706
+ </button>
707
+ </div>
708
+ <div v-if="$state.error?.message" class="text-base mb-1">{{ $state.error.message }}</div>
709
+ <div v-if="$state.error?.stackTrace" class="mt-2 text-sm whitespace-pre-wrap break-words max-h-80 overflow-y-auto font-mono p-2 border border-red-200/70 dark:border-red-800/70">
710
+ {{ $state.error.stackTrace }}
711
+ </div>
712
+ </div>
713
+ </div>
714
+ </div>
715
+ </div>
716
+ </div>
717
+ <ThreadFooter v-if="$threads.threadDetails.value[currentThread.id]" :thread="$threads.threadDetails.value[currentThread.id]" />
718
+ </div>
719
+ </div>
720
+ </div>
721
+
722
+ <!-- Input Area -->
723
+ <div v-if="$ai.hasAccess" :class="$ctx.cls('chat-input', 'flex-shrink-0 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-6 py-4')">
724
+ <ChatPrompt :model="$chat.getSelectedModel()" />
725
+ </div>
726
+ </div>
727
+ `,
728
+ setup() {
729
+ const ctx = inject('ctx')
730
+ const models = ctx.state.models
731
+ const config = ctx.state.config
732
+ const threads = ctx.threads
733
+ const chatPrompt = ctx.chat
734
+ const { currentThread } = threads
735
+
736
+ const router = useRouter()
737
+ const route = useRoute()
738
+
739
+ const prefs = ref(ctx.getPrefs())
740
+
741
+ const selectedModel = ref(prefs.value.model || config.defaults.text.model || '')
742
+ const selectedModelObj = computed(() => {
743
+ if (!selectedModel.value || !models) return null
744
+ return models.find(m => m.name === selectedModel.value) || models.find(m => m.id === selectedModel.value)
745
+ })
746
+ const messagesContainer = ref(null)
747
+ const copying = ref(null)
748
+
749
+ const resolveUrl = (url) => {
750
+ if (url && url.startsWith('~')) {
751
+ return '/' + url
752
+ }
753
+ return ctx.ai.resolveUrl(url)
754
+ }
755
+
756
+ // Auto-scroll to bottom when new messages arrive
757
+ const scrollToBottom = async () => {
758
+ await nextTick()
759
+ if (messagesContainer.value) {
760
+ messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
761
+ }
762
+ }
763
+
764
+ // Watch for new messages and scroll
765
+ watch(() => currentThread.value?.messages?.length, scrollToBottom)
766
+
767
+ // Watch for route changes and load the appropriate thread
768
+ watch(() => route.params.id, async (newId) => {
769
+ // console.debug('watch route.params.id', newId)
770
+ ctx.clearError()
771
+ threads.setCurrentThreadFromRoute(newId, router)
772
+
773
+ if (!newId) {
774
+ chatPrompt.reset()
775
+ }
776
+ nextTick(ctx.chat.addCopyButtons)
777
+ }, { immediate: true })
778
+
779
+ watch(() => [selectedModel.value], () => {
780
+ ctx.setPrefs({
781
+ model: selectedModel.value,
782
+ })
783
+ })
784
+ function configUpdated() {
785
+ console.log('configUpdated', selectedModel.value, models.length, models.includes(selectedModel.value))
786
+ if (selectedModel.value && !models.includes(selectedModel.value)) {
787
+ selectedModel.value = config.defaults.text.model || ''
788
+ }
789
+ }
790
+
791
+ const copyMessageContent = async (message) => {
792
+ let content = ''
793
+ if (Array.isArray(message.content)) {
794
+ content = message.content.map(part => {
795
+ if (part.type === 'text') return part.text
796
+ if (part.type === 'image_url') {
797
+ const name = part.image_url.url.split('/').pop() || 'image'
798
+ return `\n![${name}](${part.image_url.url})\n`
799
+ }
800
+ if (part.type === 'input_audio') {
801
+ const name = part.input_audio.data.split('/').pop() || 'audio'
802
+ return `\n[${name}](${part.input_audio.data})\n`
803
+ }
804
+ if (part.type === 'file') {
805
+ const name = part.file.filename || part.file.file_data.split('/').pop() || 'file'
806
+ return `\n[${name}](${part.file.file_data})`
807
+ }
808
+ return ''
809
+ }).join('\n')
810
+ } else {
811
+ content = message.content
812
+ }
813
+
814
+ try {
815
+ copying.value = message
816
+ await navigator.clipboard.writeText(content)
817
+ // Could add a toast notification here if desired
818
+ } catch (err) {
819
+ console.error('Failed to copy message content:', err)
820
+ // Fallback for older browsers
821
+ const textArea = document.createElement('textarea')
822
+ textArea.value = content
823
+ document.body.appendChild(textArea)
824
+ textArea.select()
825
+ document.execCommand('copy')
826
+ document.body.removeChild(textArea)
827
+ }
828
+ setTimeout(() => { copying.value = null }, 2000)
829
+ }
830
+
831
+ const getAttachments = (message) => {
832
+ if (!Array.isArray(message.content)) return []
833
+ return message.content.filter(c => c.type === 'image_url' || c.type === 'input_audio' || c.type === 'file')
834
+ }
835
+ const hasAttachments = (message) => getAttachments(message).length > 0
836
+
837
+ // Helper to extract content and files from message
838
+ const extractMessageState = async (message) => {
839
+ let text = ''
840
+ let files = []
841
+ const getCacheInfos = []
842
+
843
+ if (Array.isArray(message.content)) {
844
+ for (const part of message.content) {
845
+ if (part.type === 'text') {
846
+ text += part.text
847
+ } else if (part.type === 'image_url') {
848
+ const url = part.image_url.url
849
+ const name = url.split('/').pop() || 'image'
850
+ files.push({ name, url, type: 'image/png' }) // Assume image
851
+ getCacheInfos.push(url)
852
+ } else if (part.type === 'input_audio') {
853
+ const url = part.input_audio.data
854
+ const name = url.split('/').pop() || 'audio'
855
+ files.push({ name, url, type: 'audio/wav' }) // Assume audio
856
+ getCacheInfos.push(url)
857
+ } else if (part.type === 'file') {
858
+ const url = part.file.file_data
859
+ const name = part.file.filename || url.split('/').pop() || 'file'
860
+ files.push({ name, url })
861
+ getCacheInfos.push(url)
862
+ }
863
+ }
864
+ } else {
865
+ text = message.content
866
+ }
867
+
868
+ const infos = await ctx.ai.fetchCacheInfos(getCacheInfos)
869
+ // replace name with info.name
870
+ for (let i = 0; i < files.length; i++) {
871
+ const url = files[i]?.url
872
+ const info = infos[url]
873
+ if (info) {
874
+ files[i].name = info.name
875
+ }
876
+ }
877
+
878
+ return { text, files }
879
+ }
880
+
881
+ // Redo a user message (clear all messages after this one and re-run)
882
+ const redoMessage = async (message) => {
883
+ if (!currentThread.value || message.role !== 'user') return
884
+
885
+ const threadId = currentThread.value.id
886
+
887
+ // Clear all messages after this one
888
+ await threads.redoMessageFromThread(threadId, message.timestamp)
889
+
890
+ const state = await extractMessageState(message)
891
+
892
+ // Set the message text in the chat prompt
893
+ chatPrompt.messageText.value = state.text
894
+
895
+ // Restore attached files
896
+ chatPrompt.attachedFiles.value = state.files
897
+ }
898
+
899
+ // Edit a user message
900
+ const editMessage = async (message) => {
901
+ if (!currentThread.value || message.role !== 'user') return
902
+
903
+ // set the message in the input box
904
+ const state = await extractMessageState(message)
905
+ chatPrompt.messageText.value = state.text
906
+ chatPrompt.attachedFiles.value = state.files
907
+ chatPrompt.editingMessage.value = message.timestamp
908
+
909
+ // Focus the textarea
910
+ nextTick(() => {
911
+ const textarea = document.querySelector('textarea')
912
+ if (textarea) {
913
+ textarea.focus()
914
+ // Set cursor to end
915
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length
916
+ }
917
+ })
918
+ }
919
+
920
+ let sub
921
+ onMounted(() => setTimeout(ctx.chat.addCopyButtons, 1))
922
+ onUnmounted(() => sub?.unsubscribe())
923
+
924
+ const getToolOutput = (toolCallId) => {
925
+ return currentThread.value?.messages?.find(m => m.role === 'tool' && m.tool_call_id === toolCallId)
926
+ }
927
+
928
+ const getMessageUsage = (message) => {
929
+ if (message.usage) return message.usage
930
+ if (message.tool_calls?.length) {
931
+ const toolUsages = message.tool_calls.map(tc => getToolOutput(tc.id)?.usage)
932
+ const agg = {
933
+ tokens: toolUsages.reduce((a, b) => a + (b?.tokens || 0), 0),
934
+ cost: toolUsages.reduce((a, b) => a + (b?.cost || 0), 0),
935
+ duration: toolUsages.reduce((a, b) => a + (b?.duration || 0), 0)
936
+ }
937
+ return agg
938
+ }
939
+ return null
940
+ }
941
+
942
+ const isToolLinked = (message) => {
943
+ if (message.role !== 'tool') return false
944
+ return currentThread.value?.messages?.some(m => m.role === 'assistant' && m.tool_calls?.some(tc => tc.id === message.tool_call_id))
945
+ }
946
+
947
+ function setPrefs(o) {
948
+ Object.assign(prefs.value, o)
949
+ ctx.setPrefs(prefs.value)
950
+ }
951
+
952
+ return {
953
+ prefs,
954
+ setPrefs,
955
+ config,
956
+ models,
957
+ currentThread,
958
+ selectedModel,
959
+ selectedModelObj,
960
+ messagesContainer,
961
+ copying,
962
+ copyMessageContent,
963
+ redoMessage,
964
+ editMessage,
965
+ configUpdated,
966
+ getAttachments,
967
+ hasAttachments,
968
+ resolveUrl,
969
+ getMessageUsage,
970
+ getToolOutput,
971
+ isToolLinked,
972
+ tryParseJson,
973
+ hasJsonStructure,
974
+ }
975
+ }
976
+ }