llms-py 3.0.0__py3-none-any.whl → 3.0.0b2__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 (195) hide show
  1. llms/__pycache__/main.cpython-314.pyc +0 -0
  2. llms/index.html +37 -26
  3. llms/llms.json +21 -70
  4. llms/main.py +731 -1426
  5. llms/providers.json +1 -1
  6. llms/{extensions/analytics/ui/index.mjs → ui/Analytics.mjs} +238 -154
  7. llms/ui/App.mjs +63 -133
  8. llms/ui/Avatar.mjs +86 -0
  9. llms/ui/Brand.mjs +52 -0
  10. llms/ui/ChatPrompt.mjs +597 -0
  11. llms/ui/Main.mjs +862 -0
  12. llms/ui/OAuthSignIn.mjs +61 -0
  13. llms/ui/ProviderIcon.mjs +36 -0
  14. llms/ui/ProviderStatus.mjs +104 -0
  15. llms/{extensions/app/ui → ui}/Recents.mjs +57 -82
  16. llms/ui/{modules/chat/SettingsDialog.mjs → SettingsDialog.mjs} +9 -9
  17. llms/{extensions/app/ui/index.mjs → ui/Sidebar.mjs} +57 -122
  18. llms/ui/SignIn.mjs +65 -0
  19. llms/ui/Welcome.mjs +8 -0
  20. llms/ui/ai.mjs +13 -117
  21. llms/ui/app.css +49 -1776
  22. llms/ui/index.mjs +171 -87
  23. llms/ui/lib/charts.mjs +13 -9
  24. llms/ui/lib/servicestack-vue.mjs +3 -3
  25. llms/ui/lib/vue.min.mjs +9 -10
  26. llms/ui/lib/vue.mjs +1602 -1763
  27. llms/ui/markdown.mjs +2 -10
  28. llms/ui/model-selector.mjs +686 -0
  29. llms/ui/tailwind.input.css +1 -55
  30. llms/ui/threadStore.mjs +583 -0
  31. llms/ui/utils.mjs +118 -113
  32. llms/ui.json +1069 -0
  33. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/METADATA +1 -1
  34. llms_py-3.0.0b2.dist-info/RECORD +58 -0
  35. llms/extensions/app/README.md +0 -20
  36. llms/extensions/app/__init__.py +0 -530
  37. llms/extensions/app/__pycache__/__init__.cpython-314.pyc +0 -0
  38. llms/extensions/app/__pycache__/db.cpython-314.pyc +0 -0
  39. llms/extensions/app/__pycache__/db_manager.cpython-314.pyc +0 -0
  40. llms/extensions/app/db.py +0 -644
  41. llms/extensions/app/db_manager.py +0 -195
  42. llms/extensions/app/requests.json +0 -9073
  43. llms/extensions/app/threads.json +0 -15290
  44. llms/extensions/app/ui/threadStore.mjs +0 -411
  45. llms/extensions/core_tools/CALCULATOR.md +0 -32
  46. llms/extensions/core_tools/__init__.py +0 -598
  47. llms/extensions/core_tools/__pycache__/__init__.cpython-314.pyc +0 -0
  48. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +0 -201
  49. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +0 -185
  50. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +0 -101
  51. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +0 -160
  52. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +0 -66
  53. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +0 -27
  54. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +0 -72
  55. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +0 -119
  56. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +0 -98
  57. llms/extensions/core_tools/ui/codemirror/doc/docs.css +0 -225
  58. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  59. llms/extensions/core_tools/ui/codemirror/lib/codemirror.css +0 -344
  60. llms/extensions/core_tools/ui/codemirror/lib/codemirror.js +0 -9884
  61. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +0 -942
  62. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +0 -118
  63. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +0 -962
  64. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +0 -62
  65. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +0 -402
  66. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +0 -40
  67. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +0 -135
  68. llms/extensions/core_tools/ui/index.mjs +0 -650
  69. llms/extensions/gallery/README.md +0 -61
  70. llms/extensions/gallery/__init__.py +0 -61
  71. llms/extensions/gallery/__pycache__/__init__.cpython-314.pyc +0 -0
  72. llms/extensions/gallery/__pycache__/db.cpython-314.pyc +0 -0
  73. llms/extensions/gallery/db.py +0 -298
  74. llms/extensions/gallery/ui/index.mjs +0 -482
  75. llms/extensions/katex/README.md +0 -39
  76. llms/extensions/katex/__init__.py +0 -6
  77. llms/extensions/katex/__pycache__/__init__.cpython-314.pyc +0 -0
  78. llms/extensions/katex/ui/README.md +0 -125
  79. llms/extensions/katex/ui/contrib/auto-render.js +0 -338
  80. llms/extensions/katex/ui/contrib/auto-render.min.js +0 -1
  81. llms/extensions/katex/ui/contrib/auto-render.mjs +0 -244
  82. llms/extensions/katex/ui/contrib/copy-tex.js +0 -127
  83. llms/extensions/katex/ui/contrib/copy-tex.min.js +0 -1
  84. llms/extensions/katex/ui/contrib/copy-tex.mjs +0 -105
  85. llms/extensions/katex/ui/contrib/mathtex-script-type.js +0 -109
  86. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +0 -1
  87. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +0 -24
  88. llms/extensions/katex/ui/contrib/mhchem.js +0 -3213
  89. llms/extensions/katex/ui/contrib/mhchem.min.js +0 -1
  90. llms/extensions/katex/ui/contrib/mhchem.mjs +0 -3109
  91. llms/extensions/katex/ui/contrib/render-a11y-string.js +0 -887
  92. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +0 -1
  93. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +0 -800
  94. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  95. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  96. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  97. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  98. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  99. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  100. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  101. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  102. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  103. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  104. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  116. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  117. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  118. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  119. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  120. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  121. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  122. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  123. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  124. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  125. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  126. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  127. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  128. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  129. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  130. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  131. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  132. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  133. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  134. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  135. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  136. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  137. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  138. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  139. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  140. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  141. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  142. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  143. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  144. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  145. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  146. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  147. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  148. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  149. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  150. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  151. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  152. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  153. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  154. llms/extensions/katex/ui/index.mjs +0 -92
  155. llms/extensions/katex/ui/katex-swap.css +0 -1230
  156. llms/extensions/katex/ui/katex-swap.min.css +0 -1
  157. llms/extensions/katex/ui/katex.css +0 -1230
  158. llms/extensions/katex/ui/katex.js +0 -19080
  159. llms/extensions/katex/ui/katex.min.css +0 -1
  160. llms/extensions/katex/ui/katex.min.js +0 -1
  161. llms/extensions/katex/ui/katex.min.mjs +0 -1
  162. llms/extensions/katex/ui/katex.mjs +0 -18547
  163. llms/extensions/providers/__init__.py +0 -18
  164. llms/extensions/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  165. llms/extensions/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  166. llms/extensions/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  167. llms/extensions/providers/__pycache__/google.cpython-314.pyc +0 -0
  168. llms/extensions/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
  169. llms/extensions/providers/__pycache__/openai.cpython-314.pyc +0 -0
  170. llms/extensions/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  171. llms/extensions/providers/anthropic.py +0 -229
  172. llms/extensions/providers/chutes.py +0 -155
  173. llms/extensions/providers/google.py +0 -378
  174. llms/extensions/providers/nvidia.py +0 -105
  175. llms/extensions/providers/openai.py +0 -156
  176. llms/extensions/providers/openrouter.py +0 -72
  177. llms/extensions/system_prompts/README.md +0 -22
  178. llms/extensions/system_prompts/__init__.py +0 -45
  179. llms/extensions/system_prompts/__pycache__/__init__.cpython-314.pyc +0 -0
  180. llms/extensions/system_prompts/ui/index.mjs +0 -280
  181. llms/extensions/system_prompts/ui/prompts.json +0 -1067
  182. llms/extensions/tools/__init__.py +0 -5
  183. llms/extensions/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  184. llms/extensions/tools/ui/index.mjs +0 -204
  185. llms/providers-extra.json +0 -356
  186. llms/ui/ctx.mjs +0 -365
  187. llms/ui/modules/chat/ChatBody.mjs +0 -691
  188. llms/ui/modules/chat/index.mjs +0 -828
  189. llms/ui/modules/layout.mjs +0 -243
  190. llms/ui/modules/model-selector.mjs +0 -851
  191. llms_py-3.0.0.dist-info/RECORD +0 -202
  192. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/WHEEL +0 -0
  193. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/entry_points.txt +0 -0
  194. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/licenses/LICENSE +0 -0
  195. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/top_level.txt +0 -0
llms/ui/Main.mjs ADDED
@@ -0,0 +1,862 @@
1
+ import { ref, computed, nextTick, watch, onMounted, provide, inject } from 'vue'
2
+ import { useRouter, useRoute } from 'vue-router'
3
+ import { useFormatters } from '@servicestack/vue'
4
+ import { useThreadStore } from './threadStore.mjs'
5
+ import { addCopyButtons, formatCost, statsTitle, fetchCacheInfos } from './utils.mjs'
6
+ import { renderMarkdown } from './markdown.mjs'
7
+ import ChatPrompt, { useChatPrompt } from './ChatPrompt.mjs'
8
+ import SignIn from './SignIn.mjs'
9
+ import OAuthSignIn from './OAuthSignIn.mjs'
10
+ import Avatar from './Avatar.mjs'
11
+ import { useSettings } from "./SettingsDialog.mjs"
12
+ import Welcome from './Welcome.mjs'
13
+
14
+ const { humanifyMs, humanifyNumber } = useFormatters()
15
+
16
+ const TopBar = {
17
+ template: `
18
+ <div class="flex space-x-2">
19
+ <div v-for="(ext, index) in extensions" :key="ext.id" class="relative flex items-center justify-center">
20
+ <component :is="ext.topBarIcon"
21
+ class="size-7 p-1 cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 block"
22
+ :class="{ 'bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded' : ext.isActive($layout.top) }"
23
+ @mouseenter="tooltip = ext.name"
24
+ @mouseleave="tooltip = ''"
25
+ />
26
+ <div v-if="tooltip === ext.name"
27
+ class="absolute top-full mt-2 px-2 py-1 text-xs text-white bg-gray-900 dark:bg-gray-800 rounded shadow-md z-50 whitespace-nowrap pointer-events-none"
28
+ :class="index <= extensions.length - 1 ? 'right-0' : 'left-1/2 -translate-x-1/2'">
29
+ {{ext.name}}
30
+ </div>
31
+ </div>
32
+ </div>
33
+ `,
34
+ setup() {
35
+ const ctx = inject('ctx')
36
+ const tooltip = ref('')
37
+ const extensions = computed(() => ctx.extensions.filter(x => x.topBarIcon))
38
+ return {
39
+ extensions,
40
+ tooltip,
41
+ }
42
+ }
43
+ }
44
+
45
+ const TopPanel = {
46
+ template: `
47
+ <component v-if="component" :is="component" />
48
+ `,
49
+ setup() {
50
+ const ctx = inject('ctx')
51
+ const component = computed(() => ctx.component(ctx.layout.top))
52
+ return {
53
+ component,
54
+ }
55
+ }
56
+ }
57
+
58
+ export default {
59
+ components: {
60
+ TopBar,
61
+ TopPanel,
62
+ ChatPrompt,
63
+ SignIn,
64
+ OAuthSignIn,
65
+ Avatar,
66
+ Welcome,
67
+ },
68
+ template: `
69
+ <div class="flex flex-col h-full w-full">
70
+ <!-- Header with model selectors -->
71
+ <div v-if="$ai.hasAccess"
72
+ :class="!$ai.isSidebarOpen ? 'pl-6' : ''"
73
+ class="flex items-center border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 w-full min-h-16">
74
+ <div class="flex flex-wrap items-center justify-between w-full">
75
+ <ModelSelector :models="models" v-model="selectedModel" @updated="configUpdated" />
76
+
77
+ <div class="flex items-center space-x-2 pl-4">
78
+ <TopBar />
79
+ <Avatar />
80
+ </div>
81
+ </div>
82
+ </div>
83
+
84
+ <TopPanel />
85
+
86
+ <!-- Messages Area -->
87
+ <div class="flex-1 overflow-y-auto" ref="messagesContainer">
88
+ <div class="mx-auto max-w-6xl px-4 py-6">
89
+ <div v-if="!$ai.hasAccess">
90
+ <OAuthSignIn v-if="$ai.authType === 'oauth'" @done="$ai.signIn($event)" />
91
+ <SignIn v-else @done="$ai.signIn($event)" />
92
+ </div>
93
+ <!-- Welcome message when no thread is selected -->
94
+ <div v-else-if="!currentThread" class="text-center py-12">
95
+ <Welcome />
96
+
97
+ <!-- Chat input for new conversation -->
98
+ <!-- Moved to bottom input area -->
99
+ <div class="h-2"></div>
100
+
101
+ <!-- Export/Import buttons -->
102
+ <div class="mt-2 flex space-x-3 justify-center items-center">
103
+ <button type="button"
104
+ @click="(e) => e.altKey ? exportRequests() : exportThreads()"
105
+ :disabled="isExporting"
106
+ :title="'Export ' + threads?.threads?.value?.length + ' conversations'"
107
+ class="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
108
+ >
109
+ <svg v-if="!isExporting" class="size-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
110
+ <path fill="currentColor" d="m12 16l-5-5l1.4-1.45l2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z"></path>
111
+ </svg>
112
+ <svg v-else class="size-5 mr-1 animate-spin" fill="none" viewBox="0 0 24 24">
113
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
114
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
115
+ </svg>
116
+ {{ isExporting ? 'Exporting...' : 'Export' }}
117
+ </button>
118
+
119
+ <button type="button"
120
+ @click="triggerImport"
121
+ :disabled="isImporting"
122
+ title="Import conversations from JSON file"
123
+ class="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
124
+ >
125
+ <svg v-if="!isImporting" class="size-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
126
+ <path fill="currentColor" d="m14 12l-4-4v3H2v2h8v3m10 2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v3h2V6h12v12H6v-3H4v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2"/>
127
+ </svg>
128
+ <svg v-else class="size-5 mr-1 animate-spin" fill="none" viewBox="0 0 24 24">
129
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
130
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
131
+ </svg>
132
+ {{ isImporting ? 'Importing...' : 'Import' }}
133
+ </button>
134
+
135
+ <!-- Hidden file input for import -->
136
+ <input
137
+ ref="fileInput"
138
+ type="file"
139
+ accept=".json"
140
+ @change="handleFileImport"
141
+ class="hidden"
142
+ />
143
+
144
+ <DarkModeToggle />
145
+
146
+ </div>
147
+
148
+ </div>
149
+
150
+ <!-- Messages -->
151
+ <div v-else class="space-y-6">
152
+ <div
153
+ v-for="message in currentThread.messages"
154
+ :key="message.id"
155
+ class="flex items-start space-x-3 group"
156
+ :class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
157
+ >
158
+ <!-- Avatar outside the bubble -->
159
+ <div class="flex-shrink-0 flex flex-col justify-center">
160
+ <div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
161
+ :class="message.role === 'user'
162
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
163
+ : 'bg-gray-600 dark:bg-gray-500 text-white'"
164
+ >
165
+ {{ message.role === 'user' ? 'U' : 'AI' }}
166
+ </div>
167
+
168
+ <!-- Delete button (shown on hover) -->
169
+ <button type="button" @click.stop="threads.deleteMessageFromThread(currentThread.id, message.id)"
170
+ 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"
171
+ title="Delete message">
172
+ <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
173
+ <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>
174
+ </svg>
175
+ </button>
176
+ </div>
177
+
178
+ <!-- Message bubble -->
179
+ <div
180
+ class="message rounded-lg px-4 py-3 relative group"
181
+ :class="message.role === 'user'
182
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
183
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700'"
184
+ >
185
+ <!-- Copy button in top right corner -->
186
+ <button
187
+ type="button"
188
+ @click="copyMessageContent(message)"
189
+ 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"
190
+ :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'"
191
+ title="Copy message content"
192
+ >
193
+ <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>
194
+ <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">
195
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
196
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
197
+ </svg>
198
+ </button>
199
+
200
+ <div
201
+ v-if="message.role === 'assistant'"
202
+ v-html="renderMarkdown(message.content)"
203
+ class="prose prose-sm max-w-none dark:prose-invert"
204
+ ></div>
205
+
206
+ <!-- Collapsible reasoning section -->
207
+ <div v-if="message.role === 'assistant' && message.reasoning" class="mt-2">
208
+ <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">
209
+ <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>
210
+ <span>{{ isReasoningExpanded(message.id) ? 'Hide reasoning' : 'Show reasoning' }}</span>
211
+ </button>
212
+ <div v-if="isReasoningExpanded(message.id)" class="mt-2 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-2">
213
+ <div v-if="typeof message.reasoning === 'string'" v-html="renderMarkdown(message.reasoning)" class="prose prose-xs max-w-none dark:prose-invert"></div>
214
+ <pre v-else class="text-xs whitespace-pre-wrap overflow-x-auto text-gray-900 dark:text-gray-100">{{ formatReasoning(message.reasoning) }}</pre>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- User Message with separate attachments -->
219
+ <div v-if="message.role !== 'assistant'">
220
+ <div v-html="renderMarkdown(message.content)" class="prose prose-sm max-w-none dark:prose-invert break-words"></div>
221
+
222
+ <!-- Attachments Grid -->
223
+ <div v-if="hasAttachments(message)" class="mt-2 flex flex-wrap gap-2">
224
+ <template v-for="(part, i) in getAttachments(message)" :key="i">
225
+ <!-- Image -->
226
+ <div v-if="part.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(part.image_url.url)">
227
+ <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]" />
228
+ </div>
229
+ <!-- Audio -->
230
+ <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">
231
+ <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>
232
+ <audio controls :src="part.input_audio.data" class="h-8 w-48"></audio>
233
+ </div>
234
+ <!-- File -->
235
+ <a v-else-if="part.type === 'file'" :href="part.file.file_data" target="_blank"
236
+ 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">
237
+ <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>
238
+ <span class="max-w-xs truncate">{{ part.file.filename || 'Attachment' }}</span>
239
+ </a>
240
+ </template>
241
+ </div>
242
+ </div>
243
+
244
+ <div class="mt-2 text-xs opacity-70">
245
+ <span>{{ formatTime(message.timestamp) }}</span>
246
+ <span v-if="message.usage" :title="tokensTitle(message.usage)">
247
+ &#8226;
248
+ {{ humanifyNumber(message.usage.tokens) }} tokens
249
+ <span v-if="message.usage.cost">&#183; {{ message.usage.cost }}</span>
250
+ <span v-if="message.usage.duration"> in {{ humanifyMs(message.usage.duration) }}</span>
251
+ </span>
252
+ </div>
253
+ </div>
254
+
255
+ <!-- Edit and Redo buttons (shown on hover for user messages, outside bubble) -->
256
+ <div v-if="message.role === 'user'" class="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-1">
257
+ <button type="button" @click.stop="editMessage(message)"
258
+ 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"
259
+ title="Edit message">
260
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
261
+ <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>
262
+ </svg>
263
+ Edit
264
+ </button>
265
+ <button type="button" @click.stop="redoMessage(message)"
266
+ 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"
267
+ title="Redo message (clears all responses after this message and re-runs it)">
268
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
269
+ <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>
270
+ </svg>
271
+ Redo
272
+ </button>
273
+ </div>
274
+ </div>
275
+
276
+ <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 dark:text-gray-400 text-sm">
277
+ <span :title="statsTitle(currentThread.stats)">
278
+ {{ currentThread.stats.cost ? formatCost(currentThread.stats.cost) + ' for ' : '' }} {{ humanifyNumber(currentThread.stats.inputTokens) }} → {{ humanifyNumber(currentThread.stats.outputTokens) }} tokens over {{ currentThread.stats.requests }} request{{currentThread.stats.requests===1?'':'s'}} in {{ humanifyMs(currentThread.stats.duration) }}
279
+ </span>
280
+ </div>
281
+
282
+ <!-- Loading indicator -->
283
+ <div v-if="isGenerating" class="flex items-start space-x-3 group">
284
+ <!-- Avatar outside the bubble -->
285
+ <div class="flex-shrink-0">
286
+ <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">
287
+ AI
288
+ </div>
289
+ </div>
290
+
291
+ <!-- Loading bubble -->
292
+ <div class="rounded-lg px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
293
+ <div class="flex space-x-1">
294
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"></div>
295
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
296
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
297
+ </div>
298
+ </div>
299
+
300
+ <!-- Cancel button -->
301
+ <button type="button" @click="cancelRequest"
302
+ 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"
303
+ title="Cancel request">
304
+ cancel
305
+ </button>
306
+ </div>
307
+
308
+ <!-- Error message bubble -->
309
+ <div v-if="errorStatus" class="flex items-start space-x-3">
310
+ <!-- Avatar outside the bubble -->
311
+ <div class="flex-shrink-0">
312
+ <div class="w-8 h-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-sm font-medium">
313
+ !
314
+ </div>
315
+ </div>
316
+
317
+ <!-- Error bubble -->
318
+ <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">
319
+ <div class="flex items-start space-x-2">
320
+ <div class="flex-1 min-w-0">
321
+ <div class="text-base font-medium mb-1">{{ errorStatus?.errorCode || 'Error' }}</div>
322
+ <div v-if="errorStatus?.message" class="text-base mb-1">{{ errorStatus.message }}</div>
323
+ <div v-if="errorStatus?.stackTrace" class="text-sm whitespace-pre-wrap break-words max-h-80 overflow-y-auto font-mono p-2 rounded bg-red-100 dark:bg-red-950/50">
324
+ {{ errorStatus.stackTrace }}
325
+ </div>
326
+ </div>
327
+ <button type="button"
328
+ @click="errorStatus = null"
329
+ class="text-red-400 dark:text-red-300 hover:text-red-600 dark:hover:text-red-100 flex-shrink-0"
330
+ >
331
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
332
+ <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>
333
+ </svg>
334
+ </button>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </div>
340
+
341
+ </div>
342
+
343
+ <!-- Input Area -->
344
+ <div v-if="$ai.hasAccess" class="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-6 py-4">
345
+ <ChatPrompt :model="selectedModelObj" />
346
+ </div>
347
+
348
+ <!-- Lightbox -->
349
+ <div v-if="lightboxUrl" class="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4 cursor-pointer" @click="closeLightbox">
350
+ <div class="relative max-w-full max-h-full">
351
+ <img :src="lightboxUrl" class="max-w-full max-h-[90vh] object-contain rounded-sm shadow-2xl" @click.stop />
352
+ <button type="button" @click="closeLightbox"
353
+ class="absolute -top-12 right-0 text-white/70 hover:text-white p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
354
+ title="Close">
355
+ <svg class="size-8" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
356
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
357
+ </svg>
358
+ </button>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ `,
363
+ setup() {
364
+ const ctx = inject('ctx')
365
+ const models = ctx.state.models
366
+ const config = ctx.state.config
367
+ const router = useRouter()
368
+ const route = useRoute()
369
+ const threads = useThreadStore()
370
+ const { currentThread } = threads
371
+ const chatPrompt = useChatPrompt()
372
+ const chatSettings = useSettings()
373
+ const {
374
+ errorStatus,
375
+ isGenerating,
376
+ } = chatPrompt
377
+ provide('threads', threads)
378
+ provide('chatPrompt', chatPrompt)
379
+ provide('chatSettings', chatSettings)
380
+
381
+ const prefs = ctx.getPrefs()
382
+
383
+ const selectedModel = ref(prefs.model || config.defaults.text.model || '')
384
+ const selectedModelObj = computed(() => {
385
+ if (!selectedModel.value || !models) return null
386
+ return models.find(m => m.name === selectedModel.value) || models.find(m => m.id === selectedModel.value)
387
+ })
388
+ const messagesContainer = ref(null)
389
+ const isExporting = ref(false)
390
+ const isImporting = ref(false)
391
+ const fileInput = ref(null)
392
+ const copying = ref(null)
393
+ const lightboxUrl = ref(null)
394
+
395
+ const openLightbox = (url) => {
396
+ lightboxUrl.value = url
397
+ }
398
+ const closeLightbox = () => {
399
+ lightboxUrl.value = null
400
+ }
401
+
402
+ // Auto-scroll to bottom when new messages arrive
403
+ const scrollToBottom = async () => {
404
+ await nextTick()
405
+ if (messagesContainer.value) {
406
+ messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
407
+ }
408
+ }
409
+
410
+ // Watch for new messages and scroll
411
+ watch(() => currentThread.value?.messages?.length, scrollToBottom)
412
+
413
+ // Watch for route changes and load the appropriate thread
414
+ watch(() => route.params.id, async (newId) => {
415
+ const thread = await threads.setCurrentThreadFromRoute(newId, router)
416
+
417
+ // If the selected thread specifies a model and it's available, switch to it
418
+ if (thread?.model && Array.isArray(models) && models.includes(thread.model)) {
419
+ selectedModel.value = thread.model
420
+ }
421
+
422
+ if (!newId) {
423
+ chatPrompt.reset()
424
+ }
425
+ nextTick(addCopyButtons)
426
+ }, { immediate: true })
427
+
428
+ watch(() => [selectedModel.value], () => {
429
+ ctx.setPrefs({
430
+ model: selectedModel.value,
431
+ })
432
+ })
433
+
434
+ async function exportThreads() {
435
+ if (isExporting.value) return
436
+
437
+ isExporting.value = true
438
+ try {
439
+ // Load all threads from IndexedDB
440
+ await threads.loadThreads()
441
+ const allThreads = threads.threads.value
442
+
443
+ // Create export data with metadata
444
+ const exportData = {
445
+ exportedAt: new Date().toISOString(),
446
+ version: '1.0',
447
+ source: 'llmspy',
448
+ threadCount: allThreads.length,
449
+ threads: allThreads
450
+ }
451
+
452
+ // Create and download JSON file
453
+ const jsonString = JSON.stringify(exportData, null, 2)
454
+ const blob = new Blob([jsonString], { type: 'application/json' })
455
+ const url = URL.createObjectURL(blob)
456
+
457
+ const link = document.createElement('a')
458
+ link.href = url
459
+ link.download = `llmsthreads-export-${new Date().toISOString().split('T')[0]}.json`
460
+ document.body.appendChild(link)
461
+ link.click()
462
+ document.body.removeChild(link)
463
+ URL.revokeObjectURL(url)
464
+
465
+ } catch (error) {
466
+ console.error('Failed to export threads:', error)
467
+ alert('Failed to export threads: ' + error.message)
468
+ } finally {
469
+ isExporting.value = false
470
+ }
471
+ }
472
+
473
+ async function exportRequests() {
474
+ if (isExporting.value) return
475
+
476
+ isExporting.value = true
477
+ try {
478
+ // Load all threads from IndexedDB
479
+ const allRequests = await threads.getAllRequests()
480
+
481
+ // Create export data with metadata
482
+ const exportData = {
483
+ exportedAt: new Date().toISOString(),
484
+ version: '1.0',
485
+ source: 'llmspy',
486
+ requestsCount: allRequests.length,
487
+ requests: allRequests
488
+ }
489
+
490
+ // Create and download JSON file
491
+ const jsonString = JSON.stringify(exportData, null, 2)
492
+ const blob = new Blob([jsonString], { type: 'application/json' })
493
+ const url = URL.createObjectURL(blob)
494
+
495
+ const link = document.createElement('a')
496
+ link.href = url
497
+ link.download = `llmsrequests-export-${new Date().toISOString().split('T')[0]}.json`
498
+ document.body.appendChild(link)
499
+ link.click()
500
+ document.body.removeChild(link)
501
+ URL.revokeObjectURL(url)
502
+
503
+ } catch (error) {
504
+ console.error('Failed to export requests:', error)
505
+ alert('Failed to export requests: ' + error.message)
506
+ } finally {
507
+ isExporting.value = false
508
+ }
509
+ }
510
+
511
+ function triggerImport() {
512
+ if (isImporting.value) return
513
+ fileInput.value?.click()
514
+ }
515
+
516
+ async function handleFileImport(event) {
517
+ const file = event.target.files?.[0]
518
+ if (!file) return
519
+
520
+ isImporting.value = true
521
+ var importType = 'threads'
522
+ try {
523
+ const text = await file.text()
524
+ const importData = JSON.parse(text)
525
+ importType = importData.threads
526
+ ? 'threads'
527
+ : importData.requests
528
+ ? 'requests'
529
+ : 'unknown'
530
+
531
+ // Import threads one by one
532
+ let importedCount = 0
533
+ let existingCount = 0
534
+
535
+ const db = await threads.initDB()
536
+
537
+ if (importData.threads) {
538
+ if (!Array.isArray(importData.threads)) {
539
+ throw new Error('Invalid import file: missing or invalid threads array')
540
+ }
541
+
542
+ const threadIds = new Set(await threads.getAllThreadIds())
543
+
544
+ for (const threadData of importData.threads) {
545
+ if (!threadData.id) {
546
+ console.warn('Skipping thread without ID:', threadData)
547
+ continue
548
+ }
549
+
550
+ try {
551
+ // Check if thread already exists
552
+ const existingThread = threadIds.has(threadData.id)
553
+ if (existingThread) {
554
+ existingCount++
555
+ } else {
556
+ // Add new thread directly to IndexedDB
557
+ const tx = db.transaction(['threads'], 'readwrite')
558
+ await tx.objectStore('threads').add(threadData)
559
+ await tx.complete
560
+ importedCount++
561
+ }
562
+ } catch (error) {
563
+ console.error('Failed to import thread:', threadData.id, error)
564
+ }
565
+ }
566
+
567
+ // Reload threads to reflect changes
568
+ await threads.loadThreads()
569
+
570
+ alert(`Import completed!\nNew threads: ${importedCount}\nExisting threads: ${existingCount}`)
571
+ }
572
+ if (importData.requests) {
573
+ if (!Array.isArray(importData.requests)) {
574
+ throw new Error('Invalid import file: missing or invalid requests array')
575
+ }
576
+
577
+ const requestIds = new Set(await threads.getAllRequestIds())
578
+
579
+ for (const requestData of importData.requests) {
580
+ if (!requestData.id) {
581
+ console.warn('Skipping request without ID:', requestData)
582
+ continue
583
+ }
584
+
585
+ try {
586
+ // Check if request already exists
587
+ const existingRequest = requestIds.has(requestData.id)
588
+ if (existingRequest) {
589
+ existingCount++
590
+ } else {
591
+ // Add new request directly to IndexedDB
592
+ const db = await threads.initDB()
593
+ const tx = db.transaction(['requests'], 'readwrite')
594
+ await tx.objectStore('requests').add(requestData)
595
+ await tx.complete
596
+ importedCount++
597
+ }
598
+ } catch (error) {
599
+ console.error('Failed to import request:', requestData.id, error)
600
+ }
601
+ }
602
+
603
+ alert(`Import completed!\nNew requests: ${importedCount}\nExisting requests: ${existingCount}`)
604
+ }
605
+
606
+ } catch (error) {
607
+ console.error('Failed to import ' + importType + ':', error)
608
+ alert('Failed to import ' + importType + ': ' + error.message)
609
+ } finally {
610
+ isImporting.value = false
611
+ // Clear the file input
612
+ if (fileInput.value) {
613
+ fileInput.value.value = ''
614
+ }
615
+ }
616
+ }
617
+
618
+ function configUpdated() {
619
+ console.log('configUpdated', selectedModel.value, models.length, models.includes(selectedModel.value))
620
+ if (selectedModel.value && !models.includes(selectedModel.value)) {
621
+ selectedModel.value = config.defaults.text.model || ''
622
+ }
623
+ }
624
+
625
+ // Format timestamp
626
+ const formatTime = (timestamp) => {
627
+ return new Date(timestamp).toLocaleTimeString([], {
628
+ hour: '2-digit',
629
+ minute: '2-digit'
630
+ })
631
+ }
632
+
633
+ // Reasoning collapse state and helpers
634
+ const expandedReasoning = ref(new Set())
635
+ const isReasoningExpanded = (id) => expandedReasoning.value.has(id)
636
+ const toggleReasoning = (id) => {
637
+ const s = new Set(expandedReasoning.value)
638
+ if (s.has(id)) {
639
+ s.delete(id)
640
+ } else {
641
+ s.add(id)
642
+ }
643
+ expandedReasoning.value = s
644
+ }
645
+ const formatReasoning = (r) => typeof r === 'string' ? r : JSON.stringify(r, null, 2)
646
+
647
+ const copyMessageContent = async (message) => {
648
+ let content = ''
649
+ if (Array.isArray(message.content)) {
650
+ content = message.content.map(part => {
651
+ if (part.type === 'text') return part.text
652
+ if (part.type === 'image_url') {
653
+ const name = part.image_url.url.split('/').pop() || 'image'
654
+ return `\n![${name}](${part.image_url.url})\n`
655
+ }
656
+ if (part.type === 'input_audio') {
657
+ const name = part.input_audio.data.split('/').pop() || 'audio'
658
+ return `\n[${name}](${part.input_audio.data})\n`
659
+ }
660
+ if (part.type === 'file') {
661
+ const name = part.file.filename || part.file.file_data.split('/').pop() || 'file'
662
+ return `\n[${name}](${part.file.file_data})`
663
+ }
664
+ return ''
665
+ }).join('\n')
666
+ } else {
667
+ content = message.content
668
+ }
669
+
670
+ try {
671
+ copying.value = message
672
+ await navigator.clipboard.writeText(content)
673
+ // Could add a toast notification here if desired
674
+ } catch (err) {
675
+ console.error('Failed to copy message content:', err)
676
+ // Fallback for older browsers
677
+ const textArea = document.createElement('textarea')
678
+ textArea.value = content
679
+ document.body.appendChild(textArea)
680
+ textArea.select()
681
+ document.execCommand('copy')
682
+ document.body.removeChild(textArea)
683
+ }
684
+ setTimeout(() => { copying.value = null }, 2000)
685
+ }
686
+
687
+ const getAttachments = (message) => {
688
+ if (!Array.isArray(message.content)) return []
689
+ return message.content.filter(c => c.type === 'image_url' || c.type === 'input_audio' || c.type === 'file')
690
+ }
691
+ const hasAttachments = (message) => getAttachments(message).length > 0
692
+
693
+ // Helper to extract content and files from message
694
+ const extractMessageState = async (message) => {
695
+ let text = ''
696
+ let files = []
697
+ const getCacheInfos = []
698
+
699
+ if (Array.isArray(message.content)) {
700
+ for (const part of message.content) {
701
+ if (part.type === 'text') {
702
+ text += part.text
703
+ } else if (part.type === 'image_url') {
704
+ const url = part.image_url.url
705
+ const name = url.split('/').pop() || 'image'
706
+ files.push({ name, url, type: 'image/png' }) // Assume image
707
+ getCacheInfos.push(url)
708
+ } else if (part.type === 'input_audio') {
709
+ const url = part.input_audio.data
710
+ const name = url.split('/').pop() || 'audio'
711
+ files.push({ name, url, type: 'audio/wav' }) // Assume audio
712
+ getCacheInfos.push(url)
713
+ } else if (part.type === 'file') {
714
+ const url = part.file.file_data
715
+ const name = part.file.filename || url.split('/').pop() || 'file'
716
+ files.push({ name, url })
717
+ getCacheInfos.push(url)
718
+ }
719
+ }
720
+ } else {
721
+ text = message.content
722
+ }
723
+
724
+ const infos = await fetchCacheInfos(getCacheInfos)
725
+ // replace name with info.name
726
+ for (let i = 0; i < files.length; i++) {
727
+ const url = files[i]?.url
728
+ const info = infos[url]
729
+ if (info) {
730
+ files[i].name = info.name
731
+ }
732
+ }
733
+
734
+ return { text, files }
735
+ }
736
+
737
+ // Redo a user message (clear all messages after this one and re-run)
738
+ const redoMessage = async (message) => {
739
+ if (!currentThread.value || message.role !== 'user') return
740
+
741
+ try {
742
+ const threadId = currentThread.value.id
743
+
744
+ // Clear all messages after this one
745
+ await threads.redoMessageFromThread(threadId, message.id)
746
+
747
+ const state = await extractMessageState(message)
748
+
749
+ // Set the message text in the chat prompt
750
+ chatPrompt.messageText.value = state.text
751
+
752
+ // Restore attached files
753
+ chatPrompt.attachedFiles.value = state.files
754
+
755
+ // Trigger send by simulating the send action
756
+ // We'll use a small delay to ensure the UI updates
757
+ await nextTick()
758
+
759
+ // Find the send button and click it
760
+ const sendButton = document.querySelector('button[title*="Send"]')
761
+ if (sendButton && !sendButton.disabled) {
762
+ sendButton.click()
763
+ }
764
+ } catch (error) {
765
+ console.error('Failed to redo message:', error)
766
+ errorStatus.value = {
767
+ errorCode: 'Error',
768
+ message: 'Failed to redo message: ' + error.message,
769
+ stackTrace: null
770
+ }
771
+ }
772
+ }
773
+
774
+ // Edit a user message
775
+ const editMessage = async (message) => {
776
+ if (!currentThread.value || message.role !== 'user') return
777
+
778
+ // set the message in the input box
779
+ const state = await extractMessageState(message)
780
+ chatPrompt.messageText.value = state.text
781
+ chatPrompt.attachedFiles.value = state.files
782
+ chatPrompt.editingMessageId.value = message.id
783
+
784
+ // Focus the textarea
785
+ nextTick(() => {
786
+ const textarea = document.querySelector('textarea')
787
+ if (textarea) {
788
+ textarea.focus()
789
+ // Set cursor to end
790
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length
791
+ }
792
+ })
793
+ }
794
+
795
+ // Cancel pending request
796
+ const cancelRequest = () => {
797
+ chatPrompt.cancel()
798
+ }
799
+
800
+ function tokensTitle(usage) {
801
+ let title = []
802
+ if (usage.tokens && usage.price) {
803
+ const msg = parseFloat(usage.price) > 0
804
+ ? `${usage.tokens} tokens @ ${usage.price} = ${tokenCost(usage.price, usage.tokens)}`
805
+ : `${usage.tokens} tokens`
806
+ const duration = usage.duration ? ` in ${usage.duration}ms` : ''
807
+ title.push(msg + duration)
808
+ }
809
+ return title.join('\n')
810
+ }
811
+ const numFmt = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 6 })
812
+ function tokenCost(price, tokens) {
813
+ if (!price || !tokens) return ''
814
+ return numFmt.format(parseFloat(price) * tokens)
815
+ }
816
+
817
+ onMounted(() => {
818
+ setTimeout(addCopyButtons, 1)
819
+ })
820
+
821
+ return {
822
+ config,
823
+ models,
824
+ threads,
825
+ isGenerating,
826
+ currentThread,
827
+ selectedModel,
828
+ selectedModelObj,
829
+ messagesContainer,
830
+ errorStatus,
831
+ copying,
832
+ formatTime,
833
+ renderMarkdown,
834
+ isReasoningExpanded,
835
+ toggleReasoning,
836
+ formatReasoning,
837
+ copyMessageContent,
838
+ redoMessage,
839
+ editMessage,
840
+ cancelRequest,
841
+ configUpdated,
842
+ exportThreads,
843
+ exportRequests,
844
+ isExporting,
845
+ triggerImport,
846
+ handleFileImport,
847
+ isImporting,
848
+ fileInput,
849
+ tokensTitle,
850
+ humanifyMs,
851
+ humanifyNumber,
852
+ formatCost,
853
+ formatCost,
854
+ statsTitle,
855
+ getAttachments,
856
+ hasAttachments,
857
+ lightboxUrl,
858
+ openLightbox,
859
+ closeLightbox,
860
+ }
861
+ }
862
+ }