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,1156 @@
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('message', (e) => {
33
+ if (e.data && e.data.type === 'stop-resize') {
34
+ ro.disconnect();
35
+ }
36
+ });
37
+ window.addEventListener('load', () => {
38
+ // Inject styles to prevent infinite loops
39
+ const style = document.createElement('style');
40
+ style.textContent = 'html, body { height: auto !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; }';
41
+ document.head.appendChild(style);
42
+
43
+ const body = document.body;
44
+ if (body) {
45
+ ro.observe(body);
46
+ ro.observe(document.documentElement);
47
+ sendHeight();
48
+ }
49
+ });
50
+ <\/script>`
51
+
52
+ const escaped = (html + resizeScript)
53
+ .replace(/&/g, '&amp;')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;')
56
+ .replace(/"/g, '&quot;')
57
+ .replace(/'/g, '&#39;')
58
+ return `<iframe srcdoc="${escaped}" sandbox="allow-scripts" style="width:100%;height:auto;border:none;"></iframe>`
59
+ }
60
+
61
+ export const TypeText = {
62
+ template: `
63
+ <div data-type="text" v-if="text.type === 'text'">
64
+ <div v-html="html?.trim()" class="whitespace-pre-wrap"></div>
65
+ </div>
66
+ `,
67
+ props: {
68
+ text: {
69
+ type: Object,
70
+ required: true
71
+ }
72
+ },
73
+ setup(props) {
74
+ const ctx = inject('ctx')
75
+ const html = computed(() => {
76
+ try {
77
+ return ctx.fmt.markdown(props.text.text)
78
+ } catch (e) {
79
+ console.error('TypeText: markdown', e)
80
+ return `<div>${props.text.text}</div>`
81
+ }
82
+ })
83
+ return { html }
84
+ }
85
+ }
86
+
87
+ export const LightboxImage = {
88
+ template: `
89
+ <div>
90
+ <!-- Thumbnail -->
91
+ <div
92
+ class="cursor-zoom-in hover:opacity-90 transition-opacity"
93
+ @click="isOpen = true"
94
+ >
95
+ <img
96
+ :src="src"
97
+ :alt="alt"
98
+ :width="width"
99
+ :height="height"
100
+ :class="imageClass"
101
+ />
102
+ </div>
103
+
104
+ <!-- Lightbox Modal -->
105
+ <div v-if="isOpen"
106
+ class="fixed inset-0 z-100 flex items-center justify-center bg-black/90 p-4"
107
+ @click="isOpen = false"
108
+ >
109
+ <button type="button"
110
+ class="absolute top-4 right-4 p-2 text-white hover:bg-white/10 rounded-lg transition-colors"
111
+ @click="isOpen = false"
112
+ aria-label="Close lightbox"
113
+ >
114
+ <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>
115
+ </button>
116
+ <div class="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
117
+ <img
118
+ :src="src"
119
+ :alt="alt"
120
+ :width="width"
121
+ :height="height"
122
+ class="max-w-full max-h-full w-auto h-auto object-contain rounded"
123
+ @click.stop
124
+ />
125
+ </div>
126
+ </div>
127
+ </div>
128
+ `,
129
+ props: {
130
+ src: {
131
+ type: String,
132
+ required: true
133
+ },
134
+ alt: {
135
+ type: String,
136
+ default: ''
137
+ },
138
+ width: {
139
+ type: [Number, String],
140
+ default: undefined
141
+ },
142
+ height: {
143
+ type: [Number, String],
144
+ default: undefined
145
+ },
146
+ imageClass: {
147
+ type: String,
148
+ 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]'
149
+ }
150
+ },
151
+ setup(props) {
152
+ const ctx = inject('ctx')
153
+ const isOpen = ref(false)
154
+
155
+ let sub
156
+ onMounted(() => {
157
+ sub = ctx.events.subscribe(`keydown:Escape`, () => isOpen.value = false)
158
+ })
159
+ onUnmounted(() => sub?.unsubscribe())
160
+
161
+ return {
162
+ isOpen
163
+ }
164
+ }
165
+ }
166
+
167
+ export const TypeImage = {
168
+ template: `
169
+ <div data-type="image" v-if="image.type === 'image_url'">
170
+ <LightboxImage :src="$ctx.resolveUrl(image.image_url.url)" />
171
+ </div>
172
+ `,
173
+ props: {
174
+ image: {
175
+ type: Object,
176
+ required: true
177
+ }
178
+ }
179
+ }
180
+
181
+ export const TypeAudio = {
182
+ template: `
183
+ <div data-type="audio" v-if="audio.type === 'audio_url'">
184
+ <slot></slot>
185
+ <audio controls :src="$ctx.resolveUrl(audio.audio_url.url)" class="h-8 w-64"></audio>
186
+ </div>
187
+ `,
188
+ props: {
189
+ audio: {
190
+ type: Object,
191
+ required: true
192
+ }
193
+ }
194
+ }
195
+
196
+ export const TypeFile = {
197
+ template: `
198
+ <a data-type="file" v-if="file.type === 'file'" :href="$ctx.resolveUrl(file.file.file_data)" target="_blank"
199
+ 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">
200
+ <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>
201
+ <span class="max-w-xs truncate">{{ file.file.filename || 'Attachment' }}</span>
202
+ </a>
203
+ `,
204
+ props: {
205
+ file: {
206
+ type: Object,
207
+ required: true
208
+ }
209
+ }
210
+ }
211
+
212
+ export const ViewType = {
213
+ template: `
214
+ <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">
215
+ <TypeText v-if="result.type === 'text'" :text="result" />
216
+ <TypeImage v-else-if="result.type === 'image_url'" :image="result" />
217
+ <TypeAudio v-else-if="result.type === 'audio_url'" :audio="result" />
218
+ <TypeFile v-else-if="result.type === 'file'" :file="result" />
219
+ <div data-type="other" v-else>
220
+ <HtmlFormat :value="result" :classes="$utils.htmlFormatClasses" />
221
+ </div>
222
+ </div>
223
+ `,
224
+ props: {
225
+ result: {
226
+ type: Object,
227
+ required: true
228
+ }
229
+ }
230
+ }
231
+ export const ViewTypes = {
232
+ template: `
233
+ <div v-if="results?.length" class="flex flex-col gap-2">
234
+ <div v-if="texts.length > 0" :class="cls">
235
+ <div v-if="hasResources" v-for="(text, i) in texts" :key="'raw-' + i" class="text-xs whitespace-pre-wrap">{{text.text}}</div>
236
+ <TypeText v-else v-for="(text, i) in texts" :key="'text-' + i" :text="text" />
237
+ </div>
238
+ <div v-if="images.length > 0" :class="cls">
239
+ <TypeImage v-for="(image, i) in images" :key="'image-' + i" :image="image" />
240
+ </div>
241
+ <div v-if="audios.length > 0" :class="cls">
242
+ <TypeAudio v-for="(audio, i) in audios" :key="'audio-' + i" :audio="audio" />
243
+ </div>
244
+ <div v-if="files.length > 0" :class="cls">
245
+ <TypeFile v-for="(file, i) in files" :key="'file-' + i" :file="file" />
246
+ </div>
247
+ <div v-if="others.length > 0" :class="cls">
248
+ <HtmlFormat v-for="(other, i) in others" :key="'other-' + i" :value="other" :classes="$utils.htmlFormatClasses" />
249
+ </div>
250
+ </div>
251
+ `,
252
+ props: {
253
+ results: {
254
+ type: Array,
255
+ required: true
256
+ }
257
+ },
258
+ setup(props) {
259
+ 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"
260
+ const texts = computed(() => props.results.filter(r => r.type === 'text'))
261
+ const images = computed(() => props.results.filter(r => r.type === 'image_url'))
262
+ const audios = computed(() => props.results.filter(r => r.type === 'audio_url'))
263
+ const files = computed(() => props.results.filter(r => r.type === 'file'))
264
+ const others = computed(() => props.results.filter(r => r.type !== 'text' && r.type !== 'image_url' && r.type !== 'audio_url' && r.type !== 'file'))
265
+ // If has resources, render as plain-text to avoid rendering resources multiple times
266
+ const hasResources = computed(() => images.value.length > 0 || audios.value.length > 0 || files.value.length > 0 || others.value.length > 0)
267
+ return { cls, texts, images, audios, files, others, hasResources }
268
+ }
269
+ }
270
+ export const ViewToolTypes = {
271
+ template: `<ViewTypes v-if="results?.length" :results="results" />`,
272
+ props: {
273
+ output: Object,
274
+ },
275
+ setup(props) {
276
+ const results = computed(() => {
277
+ const ret = []
278
+ if (!props.output) return ret
279
+ if (props.output.images) {
280
+ ret.push(...props.output.images)
281
+ }
282
+ if (props.output.audios) {
283
+ ret.push(...props.output.audios)
284
+ }
285
+ if (props.output.files) {
286
+ ret.push(...props.output.files)
287
+ }
288
+ return ret
289
+ })
290
+ return { results }
291
+ }
292
+ }
293
+
294
+
295
+ export const MessageUsage = {
296
+ template: `
297
+ <div class="mt-2 text-xs opacity-70">
298
+ <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>
299
+ <span>{{ $fmt.time(message.timestamp) }}</span>
300
+ <span v-if="usage" :title="$fmt.tokensTitle(usage)">
301
+ &#8226;
302
+ {{ $fmt.humanifyNumber(usage.tokens) }} tokens
303
+ <span v-if="usage.cost">&#183; {{ $fmt.tokenCostLong(usage.cost) }}</span>
304
+ <span v-if="usage.duration"> in {{ $fmt.humanifyMs(usage.duration * 1000) }}</span>
305
+ </span>
306
+ </div>
307
+ `,
308
+ props: {
309
+ usage: Object,
310
+ message: Object,
311
+ }
312
+ }
313
+
314
+ export const MessageReasoning = {
315
+ template: `
316
+ <div class="mt-2 mb-2">
317
+ <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">
318
+ <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>
319
+ <span>{{ isReasoningExpanded(message.timestamp) ? 'Hide reasoning' : 'Show reasoning' }}</span>
320
+ </button>
321
+ <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">
322
+ <div v-if="typeof reasoning === 'string'" v-html="$fmt.markdown(reasoning)" class="prose prose-xs max-w-none dark:prose-invert"></div>
323
+ <pre v-else class="text-xs whitespace-pre-wrap overflow-x-auto">{{ formatReasoning(reasoning) }}</pre>
324
+ </div>
325
+ </div>
326
+ `,
327
+ props: {
328
+ reasoning: String,
329
+ message: Object,
330
+ },
331
+ setup(props) {
332
+ const expandedReasoning = ref(new Set())
333
+ const isReasoningExpanded = (id) => expandedReasoning.value.has(id)
334
+ const toggleReasoning = (id) => {
335
+ const s = new Set(expandedReasoning.value)
336
+ if (s.has(id)) {
337
+ s.delete(id)
338
+ } else {
339
+ s.add(id)
340
+ }
341
+ expandedReasoning.value = s
342
+ }
343
+ const formatReasoning = (r) => typeof r === 'string' ? r : JSON.stringify(r, null, 2)
344
+
345
+ return {
346
+ expandedReasoning,
347
+ isReasoningExpanded,
348
+ toggleReasoning,
349
+ formatReasoning,
350
+ }
351
+ }
352
+ }
353
+
354
+ export const TextViewer = {
355
+ template: `
356
+ <div v-if="text.length > 200" class="relative group">
357
+ <div class="absolute top-0 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center space-x-2 bg-gray-50/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-md px-2 py-1 z-10 border border-gray-200 dark:border-gray-700 shadow-sm">
358
+ <!-- Style Selector -->
359
+ <div class="relative flex items-center">
360
+ <button type="button" @click="toggleDropdown" class="text-[10px] uppercase font-bold tracking-wider text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none flex items-center select-none">
361
+ <span>{{ prefs || 'pre' }}</span>
362
+ <svg class="mb-0.5 size-3 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
363
+ </button>
364
+ <!-- Popover -->
365
+ <div v-if="dropdownOpen" class="absolute right-0 top-full w-28 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-20 overflow-hidden">
366
+ <button
367
+ v-for="style in textStyles"
368
+ :key="style"
369
+ @click="setStyle(style)"
370
+ class="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors uppercase tracking-wider font-medium"
371
+ :class="{ 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20': prefs === style }"
372
+ >
373
+ {{ style }}
374
+ </button>
375
+ </div>
376
+ </div>
377
+
378
+ <div class="w-px h-3 bg-gray-300 dark:bg-gray-600"></div>
379
+
380
+ <!-- Text Length -->
381
+ <span class="text-xs text-gray-500 dark:text-gray-400 tabular-nums" :title="text.length + ' characters'">
382
+ {{ $fmt.humanifyNumber(text.length) }}
383
+ </span>
384
+
385
+ <!-- Copy Button -->
386
+ <button type="button" @click="copyToClipboard" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none p-0.5 rounded transition-colors" title="Copy to clipboard">
387
+ <svg v-if="copied" class="size-4 text-green-600 dark:text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/></svg>
388
+ <svg v-else xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2m0 16H8V7h11z"/></svg>
389
+ </button>
390
+
391
+ <!-- Maximize Toggle -->
392
+ <button type="button" @click="toggleMaximized" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none p-0.5 rounded transition-colors" :title="isMaximized ? 'Minimize' : 'Maximize'">
393
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
394
+ <path v-if="isMaximized" fill="currentColor" d="M9 9H3V7h4V3h2zm0 6H3v2h4v4h2zm12 0h-6v6h2v-4h4zm-6-6h6V7h-4V3h-2z"/>
395
+ <path v-else fill="currentColor" d="M3 3h6v2H5v4H3zm0 18h6v-2H5v-4H3zm12 0h6v-6h-2v4h-4zm6-18h-6v2h4v4h2z"/>
396
+ </svg>
397
+ </button>
398
+ </div>
399
+
400
+ <!-- Content -->
401
+ <div :class="containerClass">
402
+ <div v-if="prefs === 'markdown'" class="prose prose-sm max-w-none dark:prose-invert">
403
+ <div v-html="$fmt.markdown(text)"></div>
404
+ </div>
405
+ <div v-else-if="prefs === 'preview' && jsonValue">
406
+ <HtmlFormat :value="jsonValue" />
407
+ </div>
408
+ <div v-else :class="['p-0.5', contentClass]">{{ text }}</div>
409
+ </div>
410
+ </div>
411
+ <div v-else class="whitespace-pre-wrap">{{ text }}</div>
412
+ `,
413
+ props: {
414
+ prefsName: String,
415
+ text: String,
416
+ },
417
+ setup(props) {
418
+ const ctx = inject('ctx')
419
+ const prefs = ref('pre')
420
+ const maximized = ref({})
421
+ const dropdownOpen = ref(false)
422
+ const hash = computed(() => ctx.utils.hashString(props.text))
423
+ const jsonValue = computed(() => ctx.utils.toJsonObject(props.text))
424
+ const textStyles = computed(() => {
425
+ const ret = ['pre', 'normal', 'markdown']
426
+ if (jsonValue.value) {
427
+ ret.push('preview')
428
+ }
429
+ return ret
430
+ })
431
+
432
+ const toggleDropdown = () => {
433
+ dropdownOpen.value = !dropdownOpen.value
434
+ }
435
+
436
+ const copied = ref(false)
437
+ const copyToClipboard = () => {
438
+ navigator.clipboard.writeText(props.text)
439
+ copied.value = true
440
+ setTimeout(() => {
441
+ copied.value = false
442
+ }, 2000)
443
+ }
444
+
445
+ const setStyle = (style) => {
446
+ prefs.value = style
447
+ dropdownOpen.value = false
448
+ const key = props.prefsName || 'default'
449
+ const currentPrefs = ctx.getPrefs().textStyle || {}
450
+ ctx.setPrefs({
451
+ textStyle: {
452
+ ...currentPrefs,
453
+ [key]: style
454
+ }
455
+ })
456
+ }
457
+
458
+ onMounted(() => {
459
+ const current = ctx.getPrefs()
460
+ const key = props.prefsName || 'default'
461
+ if (current.textStyle && current.textStyle[key]) {
462
+ prefs.value = current.textStyle[key]
463
+ }
464
+ })
465
+
466
+ const isMaximized = computed(() => maximized.value[hash.value])
467
+
468
+ const toggleMaximized = () => {
469
+ maximized.value[hash.value] = !maximized.value[hash.value]
470
+ }
471
+
472
+ const containerClass = computed(() => {
473
+ return isMaximized.value ? 'w-full h-full' : 'max-h-60 overflow-y-auto'
474
+ })
475
+
476
+ const contentClass = computed(() => {
477
+ if (prefs.value === 'pre') return 'whitespace-pre-wrap font-mono text-xs'
478
+ if (prefs.value === 'normal') return 'font-sans text-sm'
479
+ return ''
480
+ })
481
+
482
+ return {
483
+ hash,
484
+ textStyles,
485
+ prefs,
486
+ jsonValue,
487
+ dropdownOpen,
488
+ toggleDropdown,
489
+ setStyle,
490
+ isMaximized,
491
+ toggleMaximized,
492
+
493
+ containerClass,
494
+ contentClass,
495
+ copied,
496
+ copyToClipboard
497
+ }
498
+ }
499
+ }
500
+
501
+ export const ToolArguments = {
502
+ template: `
503
+ <div ref="refArgs" v-if="dict" class="not-prose">
504
+ <div class="prose html-format">
505
+ <table class="table-object border-none">
506
+ <tr v-for="(v, k) in dict" :key="k">
507
+ <td data-arg="name" class="align-top py-2 px-4 text-left text-sm font-medium tracking-wider whitespace-nowrap lowercase">{{ k }}</td>
508
+ <td data-arg="html" v-if="$utils.isHtml(v)" style="margin:0;padding:0;width:100%">
509
+ <div v-html="embedHtml(v)" class="w-full h-full"></div>
510
+ </td>
511
+ <td data-arg="string" v-else-if="typeof v === 'string'" class="align-top py-2 px-4 text-sm">
512
+ <TextViewer prefsName="toolArgs" :text="v" />
513
+ </td>
514
+ <td data-arg="value" v-else class="align-top py-2 px-4 text-sm whitespace-pre-wrap">
515
+ <HtmlFormat :value="v" :classes="$utils.htmlFormatClasses" />
516
+ </td>
517
+ </tr>
518
+ </table>
519
+ </div>
520
+ </div>
521
+ <div v-else-if="list" class="not-prose px-3 py-2">
522
+ <HtmlFormat :value="list" :classes="$utils.htmlFormatClasses" />
523
+ </div>
524
+ <pre v-else-if="!isEmpty(value)" class="tool-arguments">{{ value }}</pre>
525
+ `,
526
+ props: {
527
+ value: String,
528
+ },
529
+ setup(props) {
530
+ const refArgs = ref()
531
+ const maximized = ref({})
532
+ const dict = computed(() => {
533
+ if (isEmpty(props.value)) return null
534
+ const ret = tryParseJson(props.value)
535
+ return typeof ret === 'object' && !Array.isArray(ret) ? ret : null
536
+ })
537
+ const list = computed(() => {
538
+ if (isEmpty(props.value)) return null
539
+ const ret = tryParseJson(props.value)
540
+ return Array.isArray(ret) && ret.length > 0 ? ret : null
541
+ })
542
+
543
+ const handleMessage = (event) => {
544
+ console.log('handleMessage', event)
545
+ if (event.data?.type === 'iframe-resize' && typeof event.data.height === 'number') {
546
+ const iframes = refArgs.value?.querySelectorAll('iframe')
547
+ iframes?.forEach(iframe => {
548
+ if (iframe.contentWindow === event.source) {
549
+ const messages = document.getElementById('messages')
550
+ const maxHeight = messages ? messages.clientHeight : window.innerHeight
551
+ const calculatedHeight = event.data.height + 30
552
+ const targetHeight = Math.min(calculatedHeight, maxHeight)
553
+
554
+ if (iframe.style.height !== targetHeight + 'px') {
555
+ iframe.style.height = targetHeight + 'px'
556
+ }
557
+
558
+ if (calculatedHeight > maxHeight) {
559
+ event.source.postMessage({ type: 'stop-resize' }, '*')
560
+ }
561
+ }
562
+ })
563
+ }
564
+ }
565
+
566
+ onMounted(() => {
567
+ window.addEventListener('message', handleMessage)
568
+ const hasIframes = refArgs.value?.querySelector('iframe')
569
+ if (hasIframes) {
570
+ refArgs.value.classList.add('has-iframes')
571
+ }
572
+ })
573
+
574
+ onUnmounted(() => {
575
+ window.removeEventListener('message', handleMessage)
576
+ })
577
+
578
+ return {
579
+ refArgs,
580
+ maximized,
581
+ dict,
582
+ list,
583
+ isEmpty,
584
+ embedHtml,
585
+ }
586
+ }
587
+ }
588
+
589
+ export const ToolOutput = {
590
+ template: `
591
+ <div v-if="output" class="border-t border-gray-200 dark:border-gray-700">
592
+ <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">
593
+ <div class="flex items-center gap-2 ">
594
+ <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>
595
+ <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Output</span>
596
+ </div>
597
+ <div v-if="hasJsonStructure(output.content)" class="flex items-center gap-2 text-[10px] uppercase tracking-wider font-medium select-none">
598
+ <span @click="$ctx.setPrefs({ toolFormat: 'text' })"
599
+ class="cursor-pointer transition-colors"
600
+ :class="$ctx.prefs.toolFormat !== 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
601
+ text
602
+ </span>
603
+ <span class="text-gray-300 dark:text-gray-700">|</span>
604
+ <span @click="$ctx.setPrefs({ toolFormat: 'preview' })"
605
+ class="cursor-pointer transition-colors"
606
+ :class="$ctx.prefs.toolFormat == 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
607
+ preview
608
+ </span>
609
+ </div>
610
+ </div>
611
+ <div class="px-3 py-2">
612
+ <div v-if="$ctx.prefs.toolFormat !== 'preview' || !hasJsonStructure(output.content)">
613
+ <TextViewer prefsName="toolOutput" :text="output.content" />
614
+ </div>
615
+ <div v-else class="not-prose text-xs">
616
+ <HtmlFormat v-if="tryParseJson(output.content)" :value="tryParseJson(output.content)" :classes="$utils.htmlFormatClasses" />
617
+ <div v-else class="text-gray-500 italic p-2">Invalid JSON content</div>
618
+ </div>
619
+ </div>
620
+ <ViewToolTypes :output="output" class="p-2" />
621
+ </div>
622
+ `,
623
+ props: {
624
+ tool: Object,
625
+ output: Object,
626
+ },
627
+ setup(props) {
628
+
629
+ return {
630
+ tryParseJson,
631
+ hasJsonStructure,
632
+ }
633
+ }
634
+ }
635
+
636
+ export const ChatBody = {
637
+ template: `
638
+ <div class="flex flex-col h-full">
639
+ <!-- Messages Area -->
640
+ <div id="messages" class="flex-1 overflow-y-auto" ref="messagesContainer">
641
+ <div class="mx-auto max-w-6xl px-4 py-6">
642
+
643
+ <div v-if="!$ai.hasAccess">
644
+ <OAuthSignIn v-if="$ai.authType === 'oauth'" @done="$ai.signIn($event)" />
645
+ <SignIn v-else @done="$ai.signIn($event)" />
646
+ </div>
647
+ <!-- Welcome message when no thread is selected -->
648
+ <div v-else-if="!currentThread" class="text-center py-12">
649
+ <Welcome />
650
+ <HomeTools />
651
+ </div>
652
+
653
+ <!-- Messages -->
654
+ <div v-else-if="currentThread">
655
+ <ThreadHeader v-if="currentThread" :thread="currentThread" class="mb-2" />
656
+ <div class="space-y-2" v-if="currentThread?.messages?.length">
657
+ <div
658
+ v-for="message in currentThreadMessages"
659
+ :key="message.timestamp"
660
+ v-show="!(message.role === 'tool' && isToolLinked(message))"
661
+ class="flex items-start space-x-3 group"
662
+ :class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
663
+ >
664
+ <!-- Avatar outside the bubble -->
665
+ <div class="flex-shrink-0 flex flex-col justify-center">
666
+ <div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
667
+ :class="message.role === 'user'
668
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
669
+ : message.role === 'tool'
670
+ ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800'
671
+ : 'bg-gray-600 dark:bg-gray-500 text-white'"
672
+ >
673
+ <span v-if="message.role === 'user'">U</span>
674
+ <svg v-else-if="message.role === 'tool'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
675
+ <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>
676
+ </svg>
677
+ <span v-else>AI</span>
678
+ </div>
679
+
680
+ <!-- Delete button (shown on hover) -->
681
+ <button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.timestamp)"
682
+ 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"
683
+ title="Delete message">
684
+ <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
685
+ <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>
686
+ </svg>
687
+ </button>
688
+ </div>
689
+
690
+ <!-- Message bubble -->
691
+ <div
692
+ class="message rounded-lg px-4 py-3 relative group"
693
+ :class="message.role === 'user'
694
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
695
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700'"
696
+ >
697
+ <!-- Copy button in top right corner -->
698
+ <button v-if="message.content"
699
+ type="button"
700
+ @click="copyMessageContent(message)"
701
+ 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"
702
+ :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'"
703
+ title="Copy message content"
704
+ >
705
+ <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>
706
+ <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">
707
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
708
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
709
+ </svg>
710
+ </button>
711
+
712
+ <div
713
+ v-if="message.role === 'assistant'"
714
+ v-html="$fmt.markdown(message.content)"
715
+ class="prose prose-sm max-w-none dark:prose-invert"
716
+ ></div>
717
+
718
+ <!-- Collapsible reasoning section -->
719
+ <MessageReasoning v-if="message.role === 'assistant' && (message.reasoning || message.thinking || message.reasoning_content)"
720
+ :reasoning="message.reasoning || message.thinking || message.reasoning_content" :message="message" />
721
+
722
+ <!-- Tool Calls & Outputs -->
723
+ <div v-if="message.tool_calls && message.tool_calls.length > 0" class="mb-3 space-y-4">
724
+ <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">
725
+ <!-- Tool Call Header -->
726
+ <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">
727
+ <div class="flex items-center gap-2">
728
+ <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>
729
+ <span class="font-mono text-xs font-bold text-gray-700 dark:text-gray-300">{{ tool.function.name }}</span>
730
+ </div>
731
+ <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Tool Call</span>
732
+ </div>
733
+
734
+ <ToolArguments :value="tool.function.arguments" />
735
+
736
+ <ToolOutput :tool="tool" :output="getToolOutput(tool.id)" />
737
+
738
+ </div>
739
+ </div>
740
+
741
+ <!-- Tool Output (Orphaned) -->
742
+ <div v-if="message.role === 'tool' && !isToolLinked(message)" class="text-sm">
743
+ <div class="flex items-center gap-2 mb-1 opacity-70">
744
+ <div class="flex items-center text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">
745
+ <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>
746
+ Tool Output
747
+ </div>
748
+ <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">
749
+ {{ message.name }}
750
+ </div>
751
+ <div v-if="message.tool_call_id" class="text-[10px] font-mono text-gray-400">
752
+ {{ message.tool_call_id.slice(0,8) }}
753
+ </div>
754
+ </div>
755
+ <div class="not-prose bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-800 p-2 overflow-x-auto">
756
+ <pre class="tool-output">{{ message.content }}</pre>
757
+ </div>
758
+ </div>
759
+
760
+ <!-- Assistant Images -->
761
+ <div v-if="message.images && message.images.length > 0" class="mt-2 flex flex-wrap gap-2">
762
+ <template v-for="(img, i) in message.images" :key="i">
763
+ <TypeImage v-if="img.type === 'image_url'" :image="img" />
764
+ </template>
765
+ </div>
766
+
767
+ <!-- Assistant Audios -->
768
+ <div v-if="message.audios && message.audios.length > 0" class="mt-2 flex flex-wrap gap-2">
769
+ <template v-for="(audio, i) in message.audios" :key="i">
770
+ <TypeAudio v-if="audio.type === 'audio_url'" :audio="audio"
771
+ 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">
772
+ <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>
773
+ </TypeAudio>
774
+ </template>
775
+ </div>
776
+
777
+ <!-- User Message with separate attachments -->
778
+ <div v-else-if="message.role !== 'assistant' && message.role !== 'tool'">
779
+ <div v-html="$fmt.content(message.content)" class="prose prose-sm max-w-none dark:prose-invert break-words"></div>
780
+ <ViewTypes :results="getAttachments(message)" />
781
+ </div>
782
+
783
+ <MessageUsage :message="message" :usage="getMessageUsage(message)" />
784
+ </div>
785
+
786
+ <!-- Edit and Redo buttons (shown on hover for user messages, outside bubble) -->
787
+ <div v-if="message.role === 'user'" class="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-1">
788
+ <button type="button" @click.stop="editMessage(message)"
789
+ 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"
790
+ title="Edit message">
791
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
792
+ <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>
793
+ </svg>
794
+ Edit
795
+ </button>
796
+ <button type="button" @click.stop="redoMessage(message)"
797
+ 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"
798
+ title="Redo message (clears all responses after this message and re-runs it)">
799
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
800
+ <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>
801
+ </svg>
802
+ Redo
803
+ </button>
804
+ </div>
805
+ </div>
806
+
807
+ <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 dark:text-gray-400 text-sm">
808
+ <span :title="$fmt.statsTitle(currentThread.stats)">
809
+ {{ 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) }}
810
+ </span>
811
+ </div>
812
+
813
+ <!-- Loading indicator -->
814
+ <div v-if="$threads.watchingThread" class="flex items-start space-x-3 group">
815
+ <!-- Avatar outside the bubble -->
816
+ <div class="flex-shrink-0">
817
+ <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">
818
+ AI
819
+ </div>
820
+ </div>
821
+
822
+ <!-- Loading bubble -->
823
+ <div class="rounded-lg px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
824
+ <div class="flex space-x-1">
825
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"></div>
826
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
827
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
828
+ </div>
829
+ </div>
830
+
831
+ <!-- Cancel button -->
832
+ <button type="button" @click="$threads.cancelThread()"
833
+ 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"
834
+ title="Cancel request">
835
+ cancel
836
+ </button>
837
+ </div>
838
+
839
+ <!-- Thread error message bubble -->
840
+ <div v-if="currentThread?.error" class="mt-8 flex items-center">
841
+ <!-- Avatar outside the bubble -->
842
+ <div class="flex-shrink-0">
843
+ <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
844
+ !
845
+ </div>
846
+ </div>
847
+ <!-- Error bubble -->
848
+ <div class="ml-3 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">
849
+ <div class="flex items-start space-x-2">
850
+ <div class="flex-1 min-w-0">
851
+ <div v-if="currentThread.error" class="text-base mb-1">{{ currentThread.error }}</div>
852
+ </div>
853
+ </div>
854
+ </div>
855
+ <button type="button" @click="$chat.sendUserMessage('retry')" title="Retry request"
856
+ class="ml-1 px-3 py-1 rounded text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-900/30 border border-transparent hover:border-gray-300 dark:hover:border-gray-600 transition-all">
857
+ retry
858
+ </button>
859
+ </div>
860
+
861
+ <!-- Error message bubble -->
862
+ <div v-if="$state.error" class="mt-8 flex items-start space-x-3">
863
+ <!-- Avatar outside the bubble -->
864
+ <div class="flex-shrink-0">
865
+ <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
866
+ !
867
+ </div>
868
+ </div>
869
+
870
+ <!-- Error bubble -->
871
+ <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">
872
+ <div class="flex items-start space-x-2">
873
+ <div class="flex-1 min-w-0">
874
+ <div class="flex justify-between items-start">
875
+ <div class="text-base font-medium mb-1">{{ $state.error?.errorCode || 'Error' }}</div>
876
+ <button type="button" @click="$ctx.clearError()" title="Clear Error"
877
+ class="text-red-400 dark:text-red-300 hover:text-red-600 dark:hover:text-red-100 flex-shrink-0">
878
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
879
+ <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>
880
+ </svg>
881
+ </button>
882
+ </div>
883
+ <div v-if="$state.error?.message" class="text-base mb-1">{{ $state.error.message }}</div>
884
+ <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">
885
+ {{ $state.error.stackTrace }}
886
+ </div>
887
+ </div>
888
+ </div>
889
+ </div>
890
+ </div>
891
+ </div>
892
+ <ThreadFooter v-if="!$threads.watchingThread && $threads.threadDetails.value[currentThread.id]" :thread="$threads.threadDetails.value[currentThread.id]" />
893
+ </div>
894
+ </div>
895
+ </div>
896
+
897
+ <!-- Input Area -->
898
+ <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')">
899
+ <ChatPrompt :model="$chat.getSelectedModel()" />
900
+ </div>
901
+ </div>
902
+ `,
903
+ setup() {
904
+ const ctx = inject('ctx')
905
+ const models = ctx.state.models
906
+ const config = ctx.state.config
907
+ const threads = ctx.threads
908
+ const chatPrompt = ctx.chat
909
+ const { currentThread } = threads
910
+
911
+ const router = useRouter()
912
+ const route = useRoute()
913
+
914
+ const prefs = ref(ctx.getPrefs())
915
+
916
+ const selectedModel = ref(prefs.value.model || config.defaults.text.model || '')
917
+ const selectedModelObj = computed(() => {
918
+ if (!selectedModel.value || !models) return null
919
+ return models.find(m => m.name === selectedModel.value) || models.find(m => m.id === selectedModel.value)
920
+ })
921
+ const messagesContainer = ref(null)
922
+ const copying = ref(null)
923
+
924
+ const resolveUrl = (url) => {
925
+ if (url && url.startsWith('~')) {
926
+ return '/' + url
927
+ }
928
+ return ctx.ai.resolveUrl(url)
929
+ }
930
+
931
+ // Auto-scroll to bottom when new messages arrive
932
+ const scrollToBottom = async () => {
933
+ await nextTick()
934
+ if (messagesContainer.value) {
935
+ messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
936
+ }
937
+ }
938
+
939
+ // Watch for new messages and scroll
940
+ watch(() => currentThread.value?.messages?.length, scrollToBottom)
941
+
942
+ // Watch for route changes and load the appropriate thread
943
+ watch(() => route.params.id, async (newId) => {
944
+ // console.debug('watch route.params.id', newId)
945
+ ctx.clearError()
946
+ threads.setCurrentThreadFromRoute(newId, router)
947
+
948
+ if (!newId) {
949
+ chatPrompt.reset()
950
+ }
951
+ nextTick(ctx.chat.addCopyButtons)
952
+ }, { immediate: true })
953
+
954
+ watch(() => [selectedModel.value], () => {
955
+ ctx.setPrefs({
956
+ model: selectedModel.value,
957
+ })
958
+ })
959
+ function configUpdated() {
960
+ console.log('configUpdated', selectedModel.value, models.length, models.includes(selectedModel.value))
961
+ if (selectedModel.value && !models.includes(selectedModel.value)) {
962
+ selectedModel.value = config.defaults.text.model || ''
963
+ }
964
+ }
965
+
966
+ const copyMessageContent = async (message) => {
967
+ let content = ''
968
+ if (Array.isArray(message.content)) {
969
+ content = message.content.map(part => {
970
+ if (part.type === 'text') return part.text
971
+ if (part.type === 'image_url') {
972
+ const name = part.image_url.url.split('/').pop() || 'image'
973
+ return `\n![${name}](${part.image_url.url})\n`
974
+ }
975
+ if (part.type === 'input_audio') {
976
+ const name = part.input_audio.data.split('/').pop() || 'audio'
977
+ return `\n[${name}](${part.input_audio.data})\n`
978
+ }
979
+ if (part.type === 'file') {
980
+ const name = part.file.filename || part.file.file_data.split('/').pop() || 'file'
981
+ return `\n[${name}](${part.file.file_data})`
982
+ }
983
+ return ''
984
+ }).join('\n')
985
+ } else {
986
+ content = message.content
987
+ }
988
+
989
+ try {
990
+ copying.value = message
991
+ await navigator.clipboard.writeText(content)
992
+ // Could add a toast notification here if desired
993
+ } catch (err) {
994
+ console.error('Failed to copy message content:', err)
995
+ // Fallback for older browsers
996
+ const textArea = document.createElement('textarea')
997
+ textArea.value = content
998
+ document.body.appendChild(textArea)
999
+ textArea.select()
1000
+ document.execCommand('copy')
1001
+ document.body.removeChild(textArea)
1002
+ }
1003
+ setTimeout(() => { copying.value = null }, 2000)
1004
+ }
1005
+
1006
+ const getAttachments = (message) => {
1007
+ if (!Array.isArray(message.content)) return []
1008
+ return message.content.filter(c => c.type === 'image_url' || c.type === 'input_audio' || c.type === 'file')
1009
+ }
1010
+ const hasAttachments = (message) => getAttachments(message).length > 0
1011
+
1012
+ // Helper to extract content and files from message
1013
+ const extractMessageState = async (message) => {
1014
+ let text = ''
1015
+ let files = []
1016
+ const getCacheInfos = []
1017
+
1018
+ if (Array.isArray(message.content)) {
1019
+ for (const part of message.content) {
1020
+ if (part.type === 'text') {
1021
+ text += part.text
1022
+ } else if (part.type === 'image_url') {
1023
+ const url = part.image_url.url
1024
+ const name = url.split('/').pop() || 'image'
1025
+ files.push({ name, url, type: 'image/png' }) // Assume image
1026
+ getCacheInfos.push(url)
1027
+ } else if (part.type === 'input_audio') {
1028
+ const url = part.input_audio.data
1029
+ const name = url.split('/').pop() || 'audio'
1030
+ files.push({ name, url, type: 'audio/wav' }) // Assume audio
1031
+ getCacheInfos.push(url)
1032
+ } else if (part.type === 'file') {
1033
+ const url = part.file.file_data
1034
+ const name = part.file.filename || url.split('/').pop() || 'file'
1035
+ files.push({ name, url })
1036
+ getCacheInfos.push(url)
1037
+ }
1038
+ }
1039
+ } else {
1040
+ text = message.content
1041
+ }
1042
+
1043
+ const infos = await ctx.ai.fetchCacheInfos(getCacheInfos)
1044
+ // replace name with info.name
1045
+ for (let i = 0; i < files.length; i++) {
1046
+ const url = files[i]?.url
1047
+ const info = infos[url]
1048
+ if (info) {
1049
+ files[i].name = info.name
1050
+ }
1051
+ }
1052
+
1053
+ return { text, files }
1054
+ }
1055
+
1056
+ // Redo a user message (clear all messages after this one and re-run)
1057
+ const redoMessage = async (message) => {
1058
+ if (!currentThread.value || message.role !== 'user') return
1059
+
1060
+ const threadId = currentThread.value.id
1061
+
1062
+ // Clear all messages after this one
1063
+ await threads.redoMessageFromThread(threadId, message.timestamp)
1064
+
1065
+ const state = await extractMessageState(message)
1066
+
1067
+ // Set the message text in the chat prompt
1068
+ chatPrompt.messageText.value = state.text
1069
+
1070
+ // Restore attached files
1071
+ chatPrompt.attachedFiles.value = state.files
1072
+ }
1073
+
1074
+ // Edit a user message
1075
+ const editMessage = async (message) => {
1076
+ if (!currentThread.value || message.role !== 'user') return
1077
+
1078
+ // set the message in the input box
1079
+ const state = await extractMessageState(message)
1080
+ chatPrompt.messageText.value = state.text
1081
+ chatPrompt.attachedFiles.value = state.files
1082
+ chatPrompt.editingMessage.value = message.timestamp
1083
+
1084
+ // Focus the textarea
1085
+ nextTick(() => {
1086
+ const textarea = document.querySelector('textarea')
1087
+ if (textarea) {
1088
+ textarea.focus()
1089
+ // Set cursor to end
1090
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length
1091
+ }
1092
+ })
1093
+ }
1094
+
1095
+ let sub
1096
+ onMounted(() => setTimeout(ctx.chat.addCopyButtons, 1))
1097
+ onUnmounted(() => sub?.unsubscribe())
1098
+
1099
+ const getToolOutput = (toolCallId) => {
1100
+ return currentThread.value?.messages?.find(m => m.role === 'tool' && m.tool_call_id === toolCallId)
1101
+ }
1102
+
1103
+ const getMessageUsage = (message) => {
1104
+ if (message.usage) return message.usage
1105
+ if (message.tool_calls?.length) {
1106
+ const toolUsages = message.tool_calls.map(tc => getToolOutput(tc.id)?.usage)
1107
+ const agg = {
1108
+ tokens: toolUsages.reduce((a, b) => a + (b?.tokens || 0), 0),
1109
+ cost: toolUsages.reduce((a, b) => a + (b?.cost || 0), 0),
1110
+ duration: toolUsages.reduce((a, b) => a + (b?.duration || 0), 0)
1111
+ }
1112
+ return agg
1113
+ }
1114
+ return null
1115
+ }
1116
+
1117
+ const isToolLinked = (message) => {
1118
+ if (message.role !== 'tool') return false
1119
+ return currentThread.value?.messages?.some(m => m.role === 'assistant' && m.tool_calls?.some(tc => tc.id === message.tool_call_id))
1120
+ }
1121
+
1122
+ function setPrefs(o) {
1123
+ Object.assign(prefs.value, o)
1124
+ ctx.setPrefs(prefs.value)
1125
+ }
1126
+
1127
+ const ignoreUserMessages = ['proceed', 'retry']
1128
+ const currentThreadMessages = computed(() =>
1129
+ currentThread.value?.messages?.filter(x => x.role !== 'system' && !(x.role === 'user' && Array.isArray(x.content) && ignoreUserMessages.includes(x.content[0]?.text))))
1130
+
1131
+ return {
1132
+ prefs,
1133
+ setPrefs,
1134
+ config,
1135
+ models,
1136
+ currentThread,
1137
+ currentThreadMessages,
1138
+ selectedModel,
1139
+ selectedModelObj,
1140
+ messagesContainer,
1141
+ copying,
1142
+ copyMessageContent,
1143
+ redoMessage,
1144
+ editMessage,
1145
+ configUpdated,
1146
+ getAttachments,
1147
+ hasAttachments,
1148
+ resolveUrl,
1149
+ getMessageUsage,
1150
+ getToolOutput,
1151
+ isToolLinked,
1152
+ tryParseJson,
1153
+ hasJsonStructure,
1154
+ }
1155
+ }
1156
+ }