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