llms-py 2.0.34__py3-none-any.whl → 3.0.0__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/__pycache__/__init__.cpython-312.pyc +0 -0
  3. llms/__pycache__/__init__.cpython-313.pyc +0 -0
  4. llms/__pycache__/__init__.cpython-314.pyc +0 -0
  5. llms/__pycache__/__main__.cpython-312.pyc +0 -0
  6. llms/__pycache__/__main__.cpython-314.pyc +0 -0
  7. llms/__pycache__/llms.cpython-312.pyc +0 -0
  8. llms/__pycache__/main.cpython-312.pyc +0 -0
  9. llms/__pycache__/main.cpython-313.pyc +0 -0
  10. llms/__pycache__/main.cpython-314.pyc +0 -0
  11. llms/__pycache__/plugins.cpython-314.pyc +0 -0
  12. llms/{ui/Analytics.mjs → extensions/analytics/ui/index.mjs} +154 -238
  13. llms/extensions/app/README.md +20 -0
  14. llms/extensions/app/__init__.py +530 -0
  15. llms/extensions/app/__pycache__/__init__.cpython-314.pyc +0 -0
  16. llms/extensions/app/__pycache__/db.cpython-314.pyc +0 -0
  17. llms/extensions/app/__pycache__/db_manager.cpython-314.pyc +0 -0
  18. llms/extensions/app/db.py +644 -0
  19. llms/extensions/app/db_manager.py +195 -0
  20. llms/extensions/app/requests.json +9073 -0
  21. llms/extensions/app/threads.json +15290 -0
  22. llms/{ui → extensions/app/ui}/Recents.mjs +91 -65
  23. llms/{ui/Sidebar.mjs → extensions/app/ui/index.mjs} +124 -58
  24. llms/extensions/app/ui/threadStore.mjs +411 -0
  25. llms/extensions/core_tools/CALCULATOR.md +32 -0
  26. llms/extensions/core_tools/__init__.py +598 -0
  27. llms/extensions/core_tools/__pycache__/__init__.cpython-314.pyc +0 -0
  28. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +201 -0
  29. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +185 -0
  30. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +101 -0
  31. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +160 -0
  32. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +66 -0
  33. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +27 -0
  34. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +72 -0
  35. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +119 -0
  36. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +98 -0
  37. llms/extensions/core_tools/ui/codemirror/doc/docs.css +225 -0
  38. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  39. llms/extensions/core_tools/ui/codemirror/lib/codemirror.css +344 -0
  40. llms/extensions/core_tools/ui/codemirror/lib/codemirror.js +9884 -0
  41. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +942 -0
  42. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +118 -0
  43. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +962 -0
  44. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +62 -0
  45. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +402 -0
  46. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +40 -0
  47. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +135 -0
  48. llms/extensions/core_tools/ui/index.mjs +650 -0
  49. llms/extensions/gallery/README.md +61 -0
  50. llms/extensions/gallery/__init__.py +61 -0
  51. llms/extensions/gallery/__pycache__/__init__.cpython-314.pyc +0 -0
  52. llms/extensions/gallery/__pycache__/db.cpython-314.pyc +0 -0
  53. llms/extensions/gallery/db.py +298 -0
  54. llms/extensions/gallery/ui/index.mjs +482 -0
  55. llms/extensions/katex/README.md +39 -0
  56. llms/extensions/katex/__init__.py +6 -0
  57. llms/extensions/katex/__pycache__/__init__.cpython-314.pyc +0 -0
  58. llms/extensions/katex/ui/README.md +125 -0
  59. llms/extensions/katex/ui/contrib/auto-render.js +338 -0
  60. llms/extensions/katex/ui/contrib/auto-render.min.js +1 -0
  61. llms/extensions/katex/ui/contrib/auto-render.mjs +244 -0
  62. llms/extensions/katex/ui/contrib/copy-tex.js +127 -0
  63. llms/extensions/katex/ui/contrib/copy-tex.min.js +1 -0
  64. llms/extensions/katex/ui/contrib/copy-tex.mjs +105 -0
  65. llms/extensions/katex/ui/contrib/mathtex-script-type.js +109 -0
  66. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +1 -0
  67. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +24 -0
  68. llms/extensions/katex/ui/contrib/mhchem.js +3213 -0
  69. llms/extensions/katex/ui/contrib/mhchem.min.js +1 -0
  70. llms/extensions/katex/ui/contrib/mhchem.mjs +3109 -0
  71. llms/extensions/katex/ui/contrib/render-a11y-string.js +887 -0
  72. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +1 -0
  73. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +800 -0
  74. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  75. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  76. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  77. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  78. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  79. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  80. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  81. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  82. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  83. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  84. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  85. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  86. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  87. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  88. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  89. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  90. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  91. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  92. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  93. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  94. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  95. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  96. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  97. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  98. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  99. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  100. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  101. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  102. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  103. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  104. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  116. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  117. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  118. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  119. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  120. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  121. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  122. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  123. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  124. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  125. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  126. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  127. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  128. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  129. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  130. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  131. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  132. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  133. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  134. llms/extensions/katex/ui/index.mjs +92 -0
  135. llms/extensions/katex/ui/katex-swap.css +1230 -0
  136. llms/extensions/katex/ui/katex-swap.min.css +1 -0
  137. llms/extensions/katex/ui/katex.css +1230 -0
  138. llms/extensions/katex/ui/katex.js +19080 -0
  139. llms/extensions/katex/ui/katex.min.css +1 -0
  140. llms/extensions/katex/ui/katex.min.js +1 -0
  141. llms/extensions/katex/ui/katex.min.mjs +1 -0
  142. llms/extensions/katex/ui/katex.mjs +18547 -0
  143. llms/extensions/providers/__init__.py +18 -0
  144. llms/extensions/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  145. llms/extensions/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  146. llms/extensions/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  147. llms/extensions/providers/__pycache__/google.cpython-314.pyc +0 -0
  148. llms/extensions/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
  149. llms/extensions/providers/__pycache__/openai.cpython-314.pyc +0 -0
  150. llms/extensions/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  151. llms/extensions/providers/anthropic.py +229 -0
  152. llms/extensions/providers/chutes.py +155 -0
  153. llms/extensions/providers/google.py +378 -0
  154. llms/extensions/providers/nvidia.py +105 -0
  155. llms/extensions/providers/openai.py +156 -0
  156. llms/extensions/providers/openrouter.py +72 -0
  157. llms/extensions/system_prompts/README.md +22 -0
  158. llms/extensions/system_prompts/__init__.py +45 -0
  159. llms/extensions/system_prompts/__pycache__/__init__.cpython-314.pyc +0 -0
  160. llms/extensions/system_prompts/ui/index.mjs +280 -0
  161. llms/extensions/system_prompts/ui/prompts.json +1067 -0
  162. llms/extensions/tools/__init__.py +5 -0
  163. llms/extensions/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  164. llms/extensions/tools/ui/index.mjs +204 -0
  165. llms/index.html +35 -77
  166. llms/llms.json +357 -1186
  167. llms/main.py +2847 -999
  168. llms/providers-extra.json +356 -0
  169. llms/providers.json +1 -0
  170. llms/ui/App.mjs +151 -60
  171. llms/ui/ai.mjs +132 -60
  172. llms/ui/app.css +2173 -161
  173. llms/ui/ctx.mjs +365 -0
  174. llms/ui/index.mjs +129 -0
  175. llms/ui/lib/charts.mjs +9 -13
  176. llms/ui/lib/servicestack-vue.mjs +3 -3
  177. llms/ui/lib/vue.min.mjs +10 -9
  178. llms/ui/lib/vue.mjs +1796 -1635
  179. llms/ui/markdown.mjs +18 -7
  180. llms/ui/modules/chat/ChatBody.mjs +691 -0
  181. llms/ui/{SettingsDialog.mjs → modules/chat/SettingsDialog.mjs} +9 -9
  182. llms/ui/modules/chat/index.mjs +828 -0
  183. llms/ui/modules/layout.mjs +243 -0
  184. llms/ui/modules/model-selector.mjs +851 -0
  185. llms/ui/tailwind.input.css +496 -80
  186. llms/ui/utils.mjs +161 -93
  187. {llms_py-2.0.34.dist-info → llms_py-3.0.0.dist-info}/METADATA +1 -1
  188. llms_py-3.0.0.dist-info/RECORD +202 -0
  189. llms/ui/Avatar.mjs +0 -85
  190. llms/ui/Brand.mjs +0 -52
  191. llms/ui/ChatPrompt.mjs +0 -590
  192. llms/ui/Main.mjs +0 -823
  193. llms/ui/ModelSelector.mjs +0 -78
  194. llms/ui/OAuthSignIn.mjs +0 -92
  195. llms/ui/ProviderIcon.mjs +0 -30
  196. llms/ui/ProviderStatus.mjs +0 -105
  197. llms/ui/SignIn.mjs +0 -64
  198. llms/ui/SystemPromptEditor.mjs +0 -31
  199. llms/ui/SystemPromptSelector.mjs +0 -56
  200. llms/ui/Welcome.mjs +0 -8
  201. llms/ui/threadStore.mjs +0 -563
  202. llms/ui.json +0 -1069
  203. llms_py-2.0.34.dist-info/RECORD +0 -48
  204. {llms_py-2.0.34.dist-info → llms_py-3.0.0.dist-info}/WHEEL +0 -0
  205. {llms_py-2.0.34.dist-info → llms_py-3.0.0.dist-info}/entry_points.txt +0 -0
  206. {llms_py-2.0.34.dist-info → llms_py-3.0.0.dist-info}/licenses/LICENSE +0 -0
  207. {llms_py-2.0.34.dist-info → llms_py-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,691 @@
1
+ import { ref, computed, nextTick, watch, onMounted, onUnmounted, inject } from 'vue'
2
+ import { useRouter, useRoute } from 'vue-router'
3
+
4
+ const MessageUsage = {
5
+ template: `
6
+ <div class="mt-2 text-xs opacity-70">
7
+ <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>
8
+ <span>{{ $fmt.time(message.timestamp) }}</span>
9
+ <span v-if="usage" :title="$fmt.tokensTitle(usage)">
10
+ &#8226;
11
+ {{ $fmt.humanifyNumber(usage.tokens) }} tokens
12
+ <span v-if="usage.cost">&#183; {{ $fmt.tokenCostLong(usage.cost) }}</span>
13
+ <span v-if="usage.duration"> in {{ $fmt.humanifyMs(usage.duration) }}</span>
14
+ </span>
15
+ </div>
16
+ `,
17
+ props: {
18
+ usage: Object,
19
+ message: Object,
20
+ }
21
+ }
22
+
23
+ const MessageReasoning = {
24
+ template: `
25
+ <div class="mt-2 mb-2">
26
+ <button type="button" @click="toggleReasoning(message.id)" 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">
27
+ <svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" :class="isReasoningExpanded(message.id) ? 'transform rotate-90' : ''"><path fill="currentColor" d="M7 5l6 5l-6 5z"/></svg>
28
+ <span>{{ isReasoningExpanded(message.id) ? 'Hide reasoning' : 'Show reasoning' }}</span>
29
+ </button>
30
+ <div v-if="isReasoningExpanded(message.id)" class="reasoning mt-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-2">
31
+ <div v-if="typeof reasoning === 'string'" v-html="$fmt.markdown(reasoning)" class="prose prose-xs max-w-none dark:prose-invert"></div>
32
+ <pre v-else class="text-xs whitespace-pre-wrap overflow-x-auto">{{ formatReasoning(reasoning) }}</pre>
33
+ </div>
34
+ </div>
35
+ `,
36
+ props: {
37
+ reasoning: String,
38
+ message: Object,
39
+ },
40
+ setup(props) {
41
+ const expandedReasoning = ref(new Set())
42
+ const isReasoningExpanded = (id) => expandedReasoning.value.has(id)
43
+ const toggleReasoning = (id) => {
44
+ const s = new Set(expandedReasoning.value)
45
+ if (s.has(id)) {
46
+ s.delete(id)
47
+ } else {
48
+ s.add(id)
49
+ }
50
+ expandedReasoning.value = s
51
+ }
52
+ const formatReasoning = (r) => typeof r === 'string' ? r : JSON.stringify(r, null, 2)
53
+
54
+ return {
55
+ expandedReasoning,
56
+ isReasoningExpanded,
57
+ toggleReasoning,
58
+ formatReasoning,
59
+ }
60
+ }
61
+ }
62
+
63
+ export default {
64
+ components: {
65
+ MessageUsage,
66
+ MessageReasoning,
67
+ },
68
+ template: `
69
+ <div class="flex flex-col h-full">
70
+ <!-- Messages Area -->
71
+ <div class="flex-1 overflow-y-auto" ref="messagesContainer">
72
+ <div class="mx-auto max-w-6xl px-4 py-6">
73
+ <div v-if="!$ai.hasAccess">
74
+ <OAuthSignIn v-if="$ai.authType === 'oauth'" @done="$ai.signIn($event)" />
75
+ <SignIn v-else @done="$ai.signIn($event)" />
76
+ </div>
77
+ <!-- Welcome message when no thread is selected -->
78
+ <div v-else-if="!currentThread" class="text-center py-12">
79
+ <Welcome />
80
+ <HomeTools />
81
+ </div>
82
+
83
+ <!-- Messages -->
84
+ <div v-else-if="currentThread?.messages?.length" class="space-y-2">
85
+ <div v-if="currentThread?.messages.length && currentThread?.model" class="flex items-center justify-center select-none">
86
+ <span @click="$chat.setSelectedModel({ name: currentThread.model})"
87
+ class="flex items-center cursor-pointer px-1.5 py-0.5 text-xs rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-colors border hover:border-gray-300 dark:hover:border-gray-700">
88
+ <ProviderIcon class="size-4 mr-1" :provider="$chat.getProviderForModel(currentThread.model)" />
89
+ {{currentThread.model}}
90
+ </span>
91
+ </div>
92
+ <div
93
+ v-for="message in currentThread.messages.filter(x => x.role !== 'system')"
94
+ :key="message.id"
95
+ v-show="!(message.role === 'tool' && isToolLinked(message))"
96
+ class="flex items-start space-x-3 group"
97
+ :class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
98
+ >
99
+ <!-- Avatar outside the bubble -->
100
+ <div class="flex-shrink-0 flex flex-col justify-center">
101
+ <div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
102
+ :class="message.role === 'user'
103
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
104
+ : message.role === 'tool'
105
+ ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800'
106
+ : 'bg-gray-600 dark:bg-gray-500 text-white'"
107
+ >
108
+ <span v-if="message.role === 'user'">U</span>
109
+ <svg v-else-if="message.role === 'tool'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
110
+ <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>
111
+ </svg>
112
+ <span v-else>AI</span>
113
+ </div>
114
+
115
+ <!-- Delete button (shown on hover) -->
116
+ <button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.id)"
117
+ 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"
118
+ title="Delete message">
119
+ <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
120
+ <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>
121
+ </svg>
122
+ </button>
123
+ </div>
124
+
125
+ <!-- Message bubble -->
126
+ <div
127
+ class="message rounded-lg px-4 py-3 relative group"
128
+ :class="message.role === 'user'
129
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
130
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700'"
131
+ >
132
+ <!-- Copy button in top right corner -->
133
+ <button
134
+ type="button"
135
+ @click="copyMessageContent(message)"
136
+ 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"
137
+ :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'"
138
+ title="Copy message content"
139
+ >
140
+ <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>
141
+ <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">
142
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
143
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
144
+ </svg>
145
+ </button>
146
+
147
+ <div
148
+ v-if="message.role === 'assistant'"
149
+ v-html="$fmt.markdown(message.content)"
150
+ class="prose prose-sm max-w-none dark:prose-invert"
151
+ ></div>
152
+
153
+ <!-- Collapsible reasoning section -->
154
+ <MessageReasoning v-if="message.role === 'assistant' && (message.reasoning || message.thinking || message.reasoning_content)"
155
+ :reasoning="message.reasoning || message.thinking || message.reasoning_content" :message="message" />
156
+
157
+ <!-- Tool Calls & Outputs -->
158
+ <div v-if="message.tool_calls && message.tool_calls.length > 0" class="mb-3 space-y-4">
159
+ <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">
160
+ <!-- Tool Call Header -->
161
+ <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">
162
+ <div class="flex items-center gap-2">
163
+ <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>
164
+ <span class="font-mono text-xs font-bold text-gray-700 dark:text-gray-300">{{ tool.function.name }}</span>
165
+ </div>
166
+ <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Tool Call</span>
167
+ </div>
168
+
169
+ <!-- Arguments -->
170
+ <div v-if="tool.function.arguments && tool.function.arguments != '{}'" class="not-prose px-3 py-2">
171
+ <HtmlFormat v-if="hasJsonStructure(tool.function.arguments)" :value="tryParseJson(tool.function.arguments)" :classes="customHtmlClasses" />
172
+ <pre v-else class="tool-arguments">{{ tool.function.arguments }}</pre>
173
+ </div>
174
+
175
+ <!-- Tool Output (Nested) -->
176
+ <div v-if="getToolOutput(tool.id)" class="border-t border-gray-200 dark:border-gray-700">
177
+ <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">
178
+ <div class="flex items-center gap-2 ">
179
+ <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>
180
+ <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Output</span>
181
+ </div>
182
+ <div v-if="hasJsonStructure(getToolOutput(tool.id).content)" class="flex items-center gap-2 text-[10px] uppercase tracking-wider font-medium select-none">
183
+ <span @click="setPrefs({ toolFormat: 'text' })"
184
+ class="cursor-pointer transition-colors"
185
+ :class="prefs.toolFormat !== 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
186
+ text
187
+ </span>
188
+ <span class="text-gray-300 dark:text-gray-700">|</span>
189
+ <span @click="setPrefs({ toolFormat: 'preview' })"
190
+ class="cursor-pointer transition-colors"
191
+ :class="prefs.toolFormat == 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
192
+ preview
193
+ </span>
194
+ </div>
195
+ </div>
196
+ <div class="not-prose px-3 py-2">
197
+ <pre v-if="prefs.toolFormat !== 'preview' || !hasJsonStructure(getToolOutput(tool.id).content)" class="tool-output">{{ getToolOutput(tool.id).content }}</pre>
198
+ <div v-else class="text-xs">
199
+ <HtmlFormat v-if="tryParseJson(getToolOutput(tool.id).content)" :value="tryParseJson(getToolOutput(tool.id).content)" :classes="customHtmlClasses" />
200
+ <div v-else class="text-gray-500 italic p-2">Invalid JSON content</div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ <!-- Tool Output (Orphaned) -->
208
+ <div v-if="message.role === 'tool' && !isToolLinked(message)" class="text-sm">
209
+ <div class="flex items-center gap-2 mb-1 opacity-70">
210
+ <div class="flex items-center text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">
211
+ <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>
212
+ Tool Output
213
+ </div>
214
+ <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">
215
+ {{ message.name }}
216
+ </div>
217
+ <div v-if="message.tool_call_id" class="text-[10px] font-mono text-gray-400">
218
+ {{ message.tool_call_id.slice(0,8) }}
219
+ </div>
220
+ </div>
221
+ <div class="not-prose bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-800 p-2 overflow-x-auto">
222
+ <pre class="tool-output">{{ message.content }}</pre>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- Assistant Images -->
227
+ <div v-if="message.images && message.images.length > 0" class="mt-2 flex flex-wrap gap-2">
228
+ <template v-for="(img, i) in message.images" :key="i">
229
+ <div v-if="img.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(resolveUrl(img.image_url.url))">
230
+ <img :src="resolveUrl(img.image_url.url)" class="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]" />
231
+ </div>
232
+ </template>
233
+ </div>
234
+
235
+ <!-- Assistant Audios -->
236
+ <div v-if="message.audios && message.audios.length > 0" class="mt-2 flex flex-wrap gap-2">
237
+ <template v-for="(audio, i) in message.audios" :key="i">
238
+ <div v-if="audio.type === 'audio_url'" 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">
239
+ <audio controls :src="resolveUrl(audio.audio_url.url)" class="h-8 w-64"></audio>
240
+ </div>
241
+ </template>
242
+ </div>
243
+
244
+ <!-- User Message with separate attachments -->
245
+ <div v-else-if="message.role !== 'assistant' && message.role !== 'tool'">
246
+ <div v-html="$fmt.markdown(message.content)" class="prose prose-sm max-w-none dark:prose-invert break-words"></div>
247
+
248
+ <!-- Attachments Grid -->
249
+ <div v-if="hasAttachments(message)" class="mt-2 flex flex-wrap gap-2">
250
+ <template v-for="(part, i) in getAttachments(message)" :key="i">
251
+ <!-- Image -->
252
+ <div v-if="part.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(part.image_url.url)">
253
+ <img :src="part.image_url.url" class="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]" />
254
+ </div>
255
+ <!-- Audio -->
256
+ <div v-else-if="part.type === 'input_audio'" 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">
257
+ <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>
258
+ <audio controls :src="part.input_audio.data" class="h-8 w-48"></audio>
259
+ </div>
260
+ <!-- File -->
261
+ <a v-else-if="part.type === 'file'" :href="part.file.file_data" target="_blank"
262
+ 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">
263
+ <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>
264
+ <span class="max-w-xs truncate">{{ part.file.filename || 'Attachment' }}</span>
265
+ </a>
266
+ </template>
267
+ </div>
268
+ </div>
269
+
270
+ <MessageUsage :message="message" :usage="getMessageUsage(message)" />
271
+ </div>
272
+
273
+ <!-- Edit and Redo buttons (shown on hover for user messages, outside bubble) -->
274
+ <div v-if="message.role === 'user'" class="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-1">
275
+ <button type="button" @click.stop="editMessage(message)"
276
+ 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"
277
+ title="Edit message">
278
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
279
+ <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>
280
+ </svg>
281
+ Edit
282
+ </button>
283
+ <button type="button" @click.stop="redoMessage(message)"
284
+ 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"
285
+ title="Redo message (clears all responses after this message and re-runs it)">
286
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
287
+ <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>
288
+ </svg>
289
+ Redo
290
+ </button>
291
+ </div>
292
+ </div>
293
+
294
+ <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 dark:text-gray-400 text-sm">
295
+ <span :title="$fmt.statsTitle(currentThread.stats)">
296
+ {{ 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) }}
297
+ </span>
298
+ </div>
299
+
300
+ <!-- Loading indicator -->
301
+ <div v-if="$threads.watchingThread" class="flex items-start space-x-3 group">
302
+ <!-- Avatar outside the bubble -->
303
+ <div class="flex-shrink-0">
304
+ <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">
305
+ AI
306
+ </div>
307
+ </div>
308
+
309
+ <!-- Loading bubble -->
310
+ <div class="rounded-lg px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
311
+ <div class="flex space-x-1">
312
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"></div>
313
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
314
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
315
+ </div>
316
+ </div>
317
+
318
+ <!-- Cancel button -->
319
+ <button type="button" @click="$threads.cancelThread()"
320
+ 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"
321
+ title="Cancel request">
322
+ cancel
323
+ </button>
324
+ </div>
325
+
326
+ <!-- Thread error message bubble -->
327
+ <div v-if="currentThread?.error" class="mt-8 flex items-center space-x-3">
328
+ <!-- Avatar outside the bubble -->
329
+ <div class="flex-shrink-0">
330
+ <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
331
+ !
332
+ </div>
333
+ </div>
334
+ <!-- Error bubble -->
335
+ <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">
336
+ <div class="flex items-start space-x-2">
337
+ <div class="flex-1 min-w-0">
338
+ <div v-if="currentThread.error" class="text-base mb-1">{{ currentThread.error }}</div>
339
+ </div>
340
+ </div>
341
+ </div>
342
+ </div>
343
+
344
+ <!-- Error message bubble -->
345
+ <div v-if="$state.error" class="mt-8 flex items-start space-x-3">
346
+ <!-- Avatar outside the bubble -->
347
+ <div class="flex-shrink-0">
348
+ <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
349
+ !
350
+ </div>
351
+ </div>
352
+
353
+ <!-- Error bubble -->
354
+ <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">
355
+ <div class="flex items-start space-x-2">
356
+ <div class="flex-1 min-w-0">
357
+ <div class="flex justify-between items-start">
358
+ <div class="text-base font-medium mb-1">{{ $state.error?.errorCode || 'Error' }}</div>
359
+ <button type="button" @click="$ctx.clearError()" title="Clear Error"
360
+ class="text-red-400 dark:text-red-300 hover:text-red-600 dark:hover:text-red-100 flex-shrink-0">
361
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
362
+ <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>
363
+ </svg>
364
+ </button>
365
+ </div>
366
+ <div v-if="$state.error?.message" class="text-base mb-1">{{ $state.error.message }}</div>
367
+ <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">
368
+ {{ $state.error.stackTrace }}
369
+ </div>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ </div>
376
+
377
+ </div>
378
+
379
+ <!-- Input Area -->
380
+ <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')">
381
+ <ChatPrompt :model="$chat.getSelectedModel()" />
382
+ </div>
383
+
384
+ <!-- Lightbox -->
385
+ <div v-if="lightboxUrl" class="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4 cursor-pointer"
386
+ @click="closeLightbox">
387
+ <button type="button" @click="closeLightbox"
388
+ class="absolute top-4 right-4 text-white/70 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors z-[101]"
389
+ title="Close">
390
+ <svg class="size-8" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
391
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
392
+ </svg>
393
+ </button>
394
+ <div class="relative max-w-full max-h-full">
395
+ <img :src="lightboxUrl" class="max-w-full max-h-[90vh] object-contain rounded-sm shadow-2xl" @click.stop />
396
+ </div>
397
+ </div>
398
+ </div>
399
+ `,
400
+ setup() {
401
+ const ctx = inject('ctx')
402
+ const models = ctx.state.models
403
+ const config = ctx.state.config
404
+ const threads = ctx.threads
405
+ const chatPrompt = ctx.chat
406
+ const { currentThread } = threads
407
+
408
+ const router = useRouter()
409
+ const route = useRoute()
410
+
411
+ const prefs = ref(ctx.getPrefs())
412
+
413
+ const selectedModel = ref(prefs.value.model || config.defaults.text.model || '')
414
+ const selectedModelObj = computed(() => {
415
+ if (!selectedModel.value || !models) return null
416
+ return models.find(m => m.name === selectedModel.value) || models.find(m => m.id === selectedModel.value)
417
+ })
418
+ const messagesContainer = ref(null)
419
+ const copying = ref(null)
420
+ const lightboxUrl = ref(null)
421
+
422
+ const openLightbox = (url) => {
423
+ lightboxUrl.value = url
424
+ }
425
+ const closeLightbox = () => {
426
+ lightboxUrl.value = null
427
+ }
428
+
429
+ const resolveUrl = (url) => {
430
+ if (url && url.startsWith('~')) {
431
+ return '/' + url
432
+ }
433
+ return ctx.ai.resolveUrl(url)
434
+ }
435
+
436
+ // Auto-scroll to bottom when new messages arrive
437
+ const scrollToBottom = async () => {
438
+ await nextTick()
439
+ if (messagesContainer.value) {
440
+ messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
441
+ }
442
+ }
443
+
444
+ // Watch for new messages and scroll
445
+ watch(() => currentThread.value?.messages?.length, scrollToBottom)
446
+
447
+ // Watch for route changes and load the appropriate thread
448
+ watch(() => route.params.id, async (newId) => {
449
+ // console.debug('watch route.params.id', newId)
450
+ ctx.clearError()
451
+ threads.setCurrentThreadFromRoute(newId, router)
452
+
453
+ if (!newId) {
454
+ chatPrompt.reset()
455
+ }
456
+ nextTick(ctx.chat.addCopyButtons)
457
+ }, { immediate: true })
458
+
459
+ watch(() => [selectedModel.value], () => {
460
+ ctx.setPrefs({
461
+ model: selectedModel.value,
462
+ })
463
+ })
464
+ function configUpdated() {
465
+ console.log('configUpdated', selectedModel.value, models.length, models.includes(selectedModel.value))
466
+ if (selectedModel.value && !models.includes(selectedModel.value)) {
467
+ selectedModel.value = config.defaults.text.model || ''
468
+ }
469
+ }
470
+
471
+ const copyMessageContent = async (message) => {
472
+ let content = ''
473
+ if (Array.isArray(message.content)) {
474
+ content = message.content.map(part => {
475
+ if (part.type === 'text') return part.text
476
+ if (part.type === 'image_url') {
477
+ const name = part.image_url.url.split('/').pop() || 'image'
478
+ return `\n![${name}](${part.image_url.url})\n`
479
+ }
480
+ if (part.type === 'input_audio') {
481
+ const name = part.input_audio.data.split('/').pop() || 'audio'
482
+ return `\n[${name}](${part.input_audio.data})\n`
483
+ }
484
+ if (part.type === 'file') {
485
+ const name = part.file.filename || part.file.file_data.split('/').pop() || 'file'
486
+ return `\n[${name}](${part.file.file_data})`
487
+ }
488
+ return ''
489
+ }).join('\n')
490
+ } else {
491
+ content = message.content
492
+ }
493
+
494
+ try {
495
+ copying.value = message
496
+ await navigator.clipboard.writeText(content)
497
+ // Could add a toast notification here if desired
498
+ } catch (err) {
499
+ console.error('Failed to copy message content:', err)
500
+ // Fallback for older browsers
501
+ const textArea = document.createElement('textarea')
502
+ textArea.value = content
503
+ document.body.appendChild(textArea)
504
+ textArea.select()
505
+ document.execCommand('copy')
506
+ document.body.removeChild(textArea)
507
+ }
508
+ setTimeout(() => { copying.value = null }, 2000)
509
+ }
510
+
511
+ const getAttachments = (message) => {
512
+ if (!Array.isArray(message.content)) return []
513
+ return message.content.filter(c => c.type === 'image_url' || c.type === 'input_audio' || c.type === 'file')
514
+ }
515
+ const hasAttachments = (message) => getAttachments(message).length > 0
516
+
517
+ // Helper to extract content and files from message
518
+ const extractMessageState = async (message) => {
519
+ let text = ''
520
+ let files = []
521
+ const getCacheInfos = []
522
+
523
+ if (Array.isArray(message.content)) {
524
+ for (const part of message.content) {
525
+ if (part.type === 'text') {
526
+ text += part.text
527
+ } else if (part.type === 'image_url') {
528
+ const url = part.image_url.url
529
+ const name = url.split('/').pop() || 'image'
530
+ files.push({ name, url, type: 'image/png' }) // Assume image
531
+ getCacheInfos.push(url)
532
+ } else if (part.type === 'input_audio') {
533
+ const url = part.input_audio.data
534
+ const name = url.split('/').pop() || 'audio'
535
+ files.push({ name, url, type: 'audio/wav' }) // Assume audio
536
+ getCacheInfos.push(url)
537
+ } else if (part.type === 'file') {
538
+ const url = part.file.file_data
539
+ const name = part.file.filename || url.split('/').pop() || 'file'
540
+ files.push({ name, url })
541
+ getCacheInfos.push(url)
542
+ }
543
+ }
544
+ } else {
545
+ text = message.content
546
+ }
547
+
548
+ const infos = await ctx.ai.fetchCacheInfos(getCacheInfos)
549
+ // replace name with info.name
550
+ for (let i = 0; i < files.length; i++) {
551
+ const url = files[i]?.url
552
+ const info = infos[url]
553
+ if (info) {
554
+ files[i].name = info.name
555
+ }
556
+ }
557
+
558
+ return { text, files }
559
+ }
560
+
561
+ // Redo a user message (clear all messages after this one and re-run)
562
+ const redoMessage = async (message) => {
563
+ if (!currentThread.value || message.role !== 'user') return
564
+
565
+ const threadId = currentThread.value.id
566
+
567
+ // Clear all messages after this one
568
+ await threads.redoMessageFromThread(threadId, message.timestamp)
569
+
570
+ const state = await extractMessageState(message)
571
+
572
+ // Set the message text in the chat prompt
573
+ chatPrompt.messageText.value = state.text
574
+
575
+ // Restore attached files
576
+ chatPrompt.attachedFiles.value = state.files
577
+ }
578
+
579
+ // Edit a user message
580
+ const editMessage = async (message) => {
581
+ if (!currentThread.value || message.role !== 'user') return
582
+
583
+ // set the message in the input box
584
+ const state = await extractMessageState(message)
585
+ chatPrompt.messageText.value = state.text
586
+ chatPrompt.attachedFiles.value = state.files
587
+ chatPrompt.editingMessage.value = message.timestamp
588
+
589
+ // Focus the textarea
590
+ nextTick(() => {
591
+ const textarea = document.querySelector('textarea')
592
+ if (textarea) {
593
+ textarea.focus()
594
+ // Set cursor to end
595
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length
596
+ }
597
+ })
598
+ }
599
+
600
+ let sub
601
+ onMounted(() => {
602
+ sub = ctx.events.subscribe(`keydown:Escape`, closeLightbox)
603
+ setTimeout(ctx.chat.addCopyButtons, 1)
604
+ })
605
+ onUnmounted(() => sub?.unsubscribe())
606
+
607
+ const getToolOutput = (toolCallId) => {
608
+ return currentThread.value?.messages?.find(m => m.role === 'tool' && m.tool_call_id === toolCallId)
609
+ }
610
+
611
+ const getMessageUsage = (message) => {
612
+ if (message.usage) return message.usage
613
+ if (message.tool_calls?.length) {
614
+ const toolUsages = message.tool_calls.map(tc => getToolOutput(tc.id)?.usage)
615
+ const agg = {
616
+ tokens: toolUsages.reduce((a, b) => a + (b?.tokens || 0), 0),
617
+ cost: toolUsages.reduce((a, b) => a + (b?.cost || 0), 0),
618
+ duration: toolUsages.reduce((a, b) => a + (b?.duration || 0), 0)
619
+ }
620
+ return agg
621
+ }
622
+ return null
623
+ }
624
+
625
+ const isToolLinked = (message) => {
626
+ if (message.role !== 'tool') return false
627
+ return currentThread.value?.messages?.some(m => m.role === 'assistant' && m.tool_calls?.some(tc => tc.id === message.tool_call_id))
628
+ }
629
+
630
+ const tryParseJson = (str) => {
631
+ try {
632
+ return JSON.parse(str)
633
+ } catch (e) {
634
+ return null
635
+ }
636
+ }
637
+ const hasJsonStructure = (str) => {
638
+ return tryParseJson(str) != null
639
+ }
640
+ /**
641
+ * @param {object|array} type
642
+ * @param {'div'|'table'|'thead'|'th'|'tr'|'td'} tag
643
+ * @param {number} depth
644
+ * @param {string} cls
645
+ * @param {number} index
646
+ */
647
+ const customHtmlClasses = (type, tag, depth, cls, index) => {
648
+ cls = cls.replace('shadow ring-1 ring-black/5 md:rounded-lg', '')
649
+ if (tag == 'th') {
650
+ cls += ' lowercase'
651
+ }
652
+ if (tag == 'td') {
653
+ cls += ' whitespace-pre-wrap'
654
+ }
655
+ return cls
656
+ }
657
+
658
+ function setPrefs(o) {
659
+ Object.assign(prefs.value, o)
660
+ ctx.setPrefs(prefs.value)
661
+ }
662
+
663
+ return {
664
+ prefs,
665
+ setPrefs,
666
+ config,
667
+ models,
668
+ currentThread,
669
+ selectedModel,
670
+ selectedModelObj,
671
+ messagesContainer,
672
+ copying,
673
+ copyMessageContent,
674
+ redoMessage,
675
+ editMessage,
676
+ configUpdated,
677
+ getAttachments,
678
+ hasAttachments,
679
+ lightboxUrl,
680
+ openLightbox,
681
+ closeLightbox,
682
+ resolveUrl,
683
+ getMessageUsage,
684
+ getToolOutput,
685
+ isToolLinked,
686
+ tryParseJson,
687
+ hasJsonStructure,
688
+ customHtmlClasses,
689
+ }
690
+ }
691
+ }