llms-py 2.0.18__py3-none-any.whl → 2.0.33__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 (41) hide show
  1. llms/index.html +17 -1
  2. llms/llms.json +1132 -1075
  3. llms/main.py +561 -103
  4. llms/ui/Analytics.mjs +115 -104
  5. llms/ui/App.mjs +81 -4
  6. llms/ui/Avatar.mjs +61 -4
  7. llms/ui/Brand.mjs +29 -11
  8. llms/ui/ChatPrompt.mjs +163 -16
  9. llms/ui/Main.mjs +177 -94
  10. llms/ui/ModelSelector.mjs +28 -10
  11. llms/ui/OAuthSignIn.mjs +92 -0
  12. llms/ui/ProviderStatus.mjs +12 -12
  13. llms/ui/Recents.mjs +13 -13
  14. llms/ui/SettingsDialog.mjs +65 -65
  15. llms/ui/Sidebar.mjs +24 -19
  16. llms/ui/SystemPromptEditor.mjs +5 -5
  17. llms/ui/SystemPromptSelector.mjs +26 -6
  18. llms/ui/Welcome.mjs +2 -2
  19. llms/ui/ai.mjs +69 -5
  20. llms/ui/app.css +548 -34
  21. llms/ui/lib/servicestack-vue.mjs +9 -9
  22. llms/ui/markdown.mjs +8 -8
  23. llms/ui/tailwind.input.css +2 -0
  24. llms/ui/threadStore.mjs +39 -0
  25. llms/ui/typography.css +54 -36
  26. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/METADATA +403 -47
  27. llms_py-2.0.33.dist-info/RECORD +48 -0
  28. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/licenses/LICENSE +1 -2
  29. llms/__pycache__/__init__.cpython-312.pyc +0 -0
  30. llms/__pycache__/__init__.cpython-313.pyc +0 -0
  31. llms/__pycache__/__init__.cpython-314.pyc +0 -0
  32. llms/__pycache__/__main__.cpython-312.pyc +0 -0
  33. llms/__pycache__/__main__.cpython-314.pyc +0 -0
  34. llms/__pycache__/llms.cpython-312.pyc +0 -0
  35. llms/__pycache__/main.cpython-312.pyc +0 -0
  36. llms/__pycache__/main.cpython-313.pyc +0 -0
  37. llms/__pycache__/main.cpython-314.pyc +0 -0
  38. llms_py-2.0.18.dist-info/RECORD +0 -56
  39. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/WHEEL +0 -0
  40. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/entry_points.txt +0 -0
  41. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/top_level.txt +0 -0
llms/ui/Main.mjs CHANGED
@@ -6,6 +6,7 @@ import { storageObject, addCopyButtons, formatCost, statsTitle } from './utils.m
6
6
  import { renderMarkdown } from './markdown.mjs'
7
7
  import ChatPrompt, { useChatPrompt } from './ChatPrompt.mjs'
8
8
  import SignIn from './SignIn.mjs'
9
+ import OAuthSignIn from './OAuthSignIn.mjs'
9
10
  import Avatar from './Avatar.mjs'
10
11
  import ModelSelector from './ModelSelector.mjs'
11
12
  import SystemPromptSelector from './SystemPromptSelector.mjs'
@@ -22,32 +23,36 @@ export default {
22
23
  SystemPromptEditor,
23
24
  ChatPrompt,
24
25
  SignIn,
26
+ OAuthSignIn,
25
27
  Avatar,
26
28
  Welcome,
27
29
  },
28
30
  template: `
29
31
  <div class="flex flex-col h-full w-full">
30
- <!-- Header with model and prompt selectors -->
31
- <div class="border-b border-gray-200 bg-white px-2 py-2 w-full min-h-16">
32
- <div class="flex items-center justify-between 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">
33
37
  <ModelSelector :models="models" v-model="selectedModel" @updated="configUpdated" />
34
38
 
35
- <div class="flex items-center space-x-2">
36
- <SystemPromptSelector :prompts="prompts" v-model="selectedPrompt"
39
+ <div class="flex items-center space-x-2 pl-4">
40
+ <SystemPromptSelector :prompts="prompts" v-model="selectedPrompt"
37
41
  :show="showSystemPrompt" @toggle="showSystemPrompt = !showSystemPrompt" />
38
42
  <Avatar />
39
43
  </div>
40
44
  </div>
41
45
  </div>
42
46
 
43
- <SystemPromptEditor v-if="showSystemPrompt"
47
+ <SystemPromptEditor v-if="showSystemPrompt && !($ai.requiresAuth && !$ai.auth)"
44
48
  v-model="currentSystemPrompt" :prompts="prompts" :selected="selectedPrompt" />
45
49
 
46
50
  <!-- Messages Area -->
47
51
  <div class="flex-1 overflow-y-auto" ref="messagesContainer">
48
52
  <div class="mx-auto max-w-6xl px-4 py-6">
49
53
  <div v-if="$ai.requiresAuth && !$ai.auth">
50
- <SignIn @done="$ai.signIn($event)" />
54
+ <OAuthSignIn v-if="$ai.authType === 'oauth'" @done="$ai.signIn($event)" />
55
+ <SignIn v-else @done="$ai.signIn($event)" />
51
56
  </div>
52
57
  <!-- Welcome message when no thread is selected -->
53
58
  <div v-else-if="!currentThread" class="text-center py-12">
@@ -59,12 +64,12 @@ export default {
59
64
  </div>
60
65
 
61
66
  <!-- Export/Import buttons -->
62
- <div class="mt-2 flex space-x-3 justify-center">
67
+ <div class="mt-2 flex space-x-3 justify-center items-center">
63
68
  <button type="button"
64
- @click="exportThreads"
69
+ @click="(e) => e.altKey ? exportRequests() : exportThreads()"
65
70
  :disabled="isExporting"
66
71
  :title="'Export ' + threads?.threads?.value?.length + ' conversations'"
67
- class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
72
+ 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"
68
73
  >
69
74
  <svg v-if="!isExporting" class="size-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
70
75
  <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>
@@ -80,7 +85,7 @@ export default {
80
85
  @click="triggerImport"
81
86
  :disabled="isImporting"
82
87
  title="Import conversations from JSON file"
83
- class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
88
+ 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"
84
89
  >
85
90
  <svg v-if="!isImporting" class="size-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
86
91
  <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"/>
@@ -100,6 +105,9 @@ export default {
100
105
  @change="handleFileImport"
101
106
  class="hidden"
102
107
  />
108
+
109
+ <DarkModeToggle />
110
+
103
111
  </div>
104
112
 
105
113
  </div>
@@ -116,15 +124,15 @@ export default {
116
124
  <div class="flex-shrink-0 flex flex-col justify-center">
117
125
  <div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
118
126
  :class="message.role === 'user'
119
- ? 'bg-blue-600 text-white'
120
- : 'bg-gray-600 text-white'"
127
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
128
+ : 'bg-gray-600 dark:bg-gray-500 text-white'"
121
129
  >
122
130
  {{ message.role === 'user' ? 'U' : 'AI' }}
123
131
  </div>
124
132
 
125
133
  <!-- Delete button (shown on hover) -->
126
134
  <button type="button" @click.stop="threads.deleteMessageFromThread(currentThread.id, message.id)"
127
- class="mx-auto opacity-0 group-hover:opacity-100 mt-2 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition-all"
135
+ 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"
128
136
  title="Delete message">
129
137
  <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
130
138
  <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>
@@ -136,18 +144,18 @@ export default {
136
144
  <div
137
145
  class="message rounded-lg px-4 py-3 relative group"
138
146
  :class="message.role === 'user'
139
- ? 'bg-blue-600 text-white'
140
- : 'bg-gray-100 text-gray-900 border border-gray-200'"
147
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
148
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700'"
141
149
  >
142
150
  <!-- Copy button in top right corner -->
143
151
  <button
144
152
  type="button"
145
153
  @click="copyMessageContent(message)"
146
- class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 p-1 rounded hover:bg-black/10 focus:outline-none focus:ring-0"
147
- :class="message.role === 'user' ? 'text-white/70 hover:text-white hover:bg-white/20' : 'text-gray-500 hover:text-gray-700'"
154
+ 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"
155
+ :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'"
148
156
  title="Copy message content"
149
157
  >
150
- <svg v-if="copying === message" class="size-4 text-green-500" 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-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>
151
159
  <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">
152
160
  <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
153
161
  <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
@@ -157,18 +165,18 @@ export default {
157
165
  <div
158
166
  v-if="message.role === 'assistant'"
159
167
  v-html="renderMarkdown(message.content)"
160
- class="prose prose-sm max-w-none"
168
+ class="prose prose-sm max-w-none dark:prose-invert"
161
169
  ></div>
162
170
 
163
171
  <!-- Collapsible reasoning section -->
164
172
  <div v-if="message.role === 'assistant' && message.reasoning" class="mt-2">
165
- <button type="button" @click="toggleReasoning(message.id)" class="text-xs text-gray-600 hover:text-gray-800 flex items-center space-x-1">
173
+ <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">
166
174
  <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>
167
175
  <span>{{ isReasoningExpanded(message.id) ? 'Hide reasoning' : 'Show reasoning' }}</span>
168
176
  </button>
169
- <div v-if="isReasoningExpanded(message.id)" class="mt-2 rounded border border-gray-200 bg-gray-50 p-2">
170
- <div v-if="typeof message.reasoning === 'string'" v-html="renderMarkdown(message.reasoning)" class="prose prose-xs max-w-none"></div>
171
- <pre v-else class="text-xs whitespace-pre-wrap overflow-x-auto">{{ formatReasoning(message.reasoning) }}</pre>
177
+ <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">
178
+ <div v-if="typeof message.reasoning === 'string'" v-html="renderMarkdown(message.reasoning)" class="prose prose-xs max-w-none dark:prose-invert"></div>
179
+ <pre v-else class="text-xs whitespace-pre-wrap overflow-x-auto text-gray-900 dark:text-gray-100">{{ formatReasoning(message.reasoning) }}</pre>
172
180
  </div>
173
181
  </div>
174
182
 
@@ -187,7 +195,7 @@ export default {
187
195
  <!-- Edit and Redo buttons (shown on hover for user messages, outside bubble) -->
188
196
  <div v-if="message.role === 'user'" class="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-1">
189
197
  <button type="button" @click.stop="editMessage(message)"
190
- class="whitespace-nowrap text-xs px-2 py-1 rounded text-gray-400 hover:text-green-600 hover:bg-green-50 transition-all"
198
+ 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"
191
199
  title="Edit message">
192
200
  <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
193
201
  <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>
@@ -195,7 +203,7 @@ export default {
195
203
  Edit
196
204
  </button>
197
205
  <button type="button" @click.stop="redoMessage(message)"
198
- class="whitespace-nowrap text-xs px-2 py-1 rounded text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-all"
206
+ 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"
199
207
  title="Redo message (clears all responses after this message and re-runs it)">
200
208
  <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
201
209
  <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>
@@ -205,53 +213,60 @@ export default {
205
213
  </div>
206
214
  </div>
207
215
 
208
- <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 text-sm">
216
+ <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 dark:text-gray-400 text-sm">
209
217
  <span :title="statsTitle(currentThread.stats)">
210
218
  {{ 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) }}
211
219
  </span>
212
220
  </div>
213
221
 
214
222
  <!-- Loading indicator -->
215
- <div v-if="isGenerating" class="flex items-start space-x-3">
223
+ <div v-if="isGenerating" class="flex items-start space-x-3 group">
216
224
  <!-- Avatar outside the bubble -->
217
225
  <div class="flex-shrink-0">
218
- <div class="w-8 h-8 rounded-full bg-gray-600 text-white flex items-center justify-center text-sm font-medium">
226
+ <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">
219
227
  AI
220
228
  </div>
221
229
  </div>
222
230
 
223
231
  <!-- Loading bubble -->
224
- <div class="rounded-lg px-4 py-3 bg-gray-100 border border-gray-200">
232
+ <div class="rounded-lg px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
225
233
  <div class="flex space-x-1">
226
- <div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
227
- <div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
228
- <div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
234
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"></div>
235
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
236
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
229
237
  </div>
230
238
  </div>
239
+
240
+ <!-- Cancel button -->
241
+ <button type="button" @click="cancelRequest"
242
+ 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"
243
+ title="Cancel request">
244
+ cancel
245
+ </button>
231
246
  </div>
232
247
 
233
248
  <!-- Error message bubble -->
234
249
  <div v-if="errorStatus" class="flex items-start space-x-3">
235
250
  <!-- Avatar outside the bubble -->
236
251
  <div class="flex-shrink-0">
237
- <div class="w-8 h-8 rounded-full bg-red-600 text-white flex items-center justify-center text-sm font-medium">
252
+ <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">
238
253
  !
239
254
  </div>
240
255
  </div>
241
256
 
242
257
  <!-- Error bubble -->
243
- <div class="max-w-[85%] rounded-lg px-4 py-3 bg-red-50 border border-red-200 text-red-800 shadow-sm">
258
+ <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">
244
259
  <div class="flex items-start space-x-2">
245
260
  <div class="flex-1 min-w-0">
246
261
  <div class="text-base font-medium mb-1">{{ errorStatus?.errorCode || 'Error' }}</div>
247
262
  <div v-if="errorStatus?.message" class="text-base mb-1">{{ errorStatus.message }}</div>
248
- <div v-if="errorStatus?.stackTrace" class="text-sm whitespace-pre-wrap break-words max-h-80 overflow-y-auto font-mono p-2 rounded">
263
+ <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">
249
264
  {{ errorStatus.stackTrace }}
250
265
  </div>
251
266
  </div>
252
267
  <button type="button"
253
268
  @click="errorStatus = null"
254
- class="text-red-400 hover:text-red-600 flex-shrink-0"
269
+ class="text-red-400 dark:text-red-300 hover:text-red-600 dark:hover:text-red-100 flex-shrink-0"
255
270
  >
256
271
  <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
257
272
  <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>
@@ -265,21 +280,21 @@ export default {
265
280
 
266
281
  <!-- Edit message modal -->
267
282
  <div v-if="editingMessageId" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
268
- <div class="relative bg-white rounded-lg shadow-lg p-6 max-w-2xl w-full mx-4">
283
+ <div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-2xl w-full mx-4">
269
284
  <CloseButton @click="cancelEdit" class="" />
270
- <h3 class="text-lg font-semibold text-gray-900 mb-4">Edit Message</h3>
285
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Edit Message</h3>
271
286
  <textarea
272
287
  v-model="editingMessageContent"
273
- class="w-full h-40 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
288
+ class="w-full h-40 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
274
289
  placeholder="Edit your message..."
275
290
  ></textarea>
276
291
  <div class="mt-4 flex gap-2 justify-end">
277
292
  <button type="button" @click="cancelEdit"
278
- class="px-4 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 transition-all">
293
+ class="px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all">
279
294
  Cancel
280
295
  </button>
281
296
  <button type="button" @click="saveEditedMessage"
282
- class="px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-all">
297
+ class="px-4 py-2 rounded-md bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600 transition-all">
283
298
  Save
284
299
  </button>
285
300
  </div>
@@ -288,7 +303,7 @@ export default {
288
303
  </div>
289
304
 
290
305
  <!-- Input Area - only show when thread is selected -->
291
- <div v-if="currentThread" class="flex-shrink-0 border-t border-gray-200 bg-white px-6 py-4">
306
+ <div v-if="currentThread" class="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-6 py-4">
292
307
  <ChatPrompt :model="selectedModel" :systemPrompt="currentSystemPrompt" />
293
308
  </div>
294
309
  </div>
@@ -411,7 +426,7 @@ export default {
411
426
  const exportData = {
412
427
  exportedAt: new Date().toISOString(),
413
428
  version: '1.0',
414
- source: 'ServiceStack.AI.Chat',
429
+ source: 'llmspy',
415
430
  threadCount: allThreads.length,
416
431
  threads: allThreads
417
432
  }
@@ -423,7 +438,7 @@ export default {
423
438
 
424
439
  const link = document.createElement('a')
425
440
  link.href = url
426
- link.download = `aichat-threads-export-${new Date().toISOString().split('T')[0]}.json`
441
+ link.download = `llmsthreads-export-${new Date().toISOString().split('T')[0]}.json`
427
442
  document.body.appendChild(link)
428
443
  link.click()
429
444
  document.body.removeChild(link)
@@ -437,6 +452,44 @@ export default {
437
452
  }
438
453
  }
439
454
 
455
+ async function exportRequests() {
456
+ if (isExporting.value) return
457
+
458
+ isExporting.value = true
459
+ try {
460
+ // Load all threads from IndexedDB
461
+ const allRequests = await threads.getAllRequests()
462
+
463
+ // Create export data with metadata
464
+ const exportData = {
465
+ exportedAt: new Date().toISOString(),
466
+ version: '1.0',
467
+ source: 'llmspy',
468
+ requestsCount: allRequests.length,
469
+ requests: allRequests
470
+ }
471
+
472
+ // Create and download JSON file
473
+ const jsonString = JSON.stringify(exportData, null, 2)
474
+ const blob = new Blob([jsonString], { type: 'application/json' })
475
+ const url = URL.createObjectURL(blob)
476
+
477
+ const link = document.createElement('a')
478
+ link.href = url
479
+ link.download = `llmsrequests-export-${new Date().toISOString().split('T')[0]}.json`
480
+ document.body.appendChild(link)
481
+ link.click()
482
+ document.body.removeChild(link)
483
+ URL.revokeObjectURL(url)
484
+
485
+ } catch (error) {
486
+ console.error('Failed to export requests:', error)
487
+ alert('Failed to export requests: ' + error.message)
488
+ } finally {
489
+ isExporting.value = false
490
+ }
491
+ }
492
+
440
493
  function triggerImport() {
441
494
  if (isImporting.value) return
442
495
  fileInput.value?.click()
@@ -447,71 +500,94 @@ export default {
447
500
  if (!file) return
448
501
 
449
502
  isImporting.value = true
503
+ var importType = 'threads'
450
504
  try {
451
505
  const text = await file.text()
452
506
  const importData = JSON.parse(text)
453
-
454
- // Validate import data structure
455
- if (!importData.threads || !Array.isArray(importData.threads)) {
456
- throw new Error('Invalid import file: missing or invalid threads array')
457
- }
507
+ importType = importData.threads
508
+ ? 'threads'
509
+ : importData.requests
510
+ ? 'requests'
511
+ : 'unknown'
458
512
 
459
513
  // Import threads one by one
460
514
  let importedCount = 0
461
- let updatedCount = 0
515
+ let existingCount = 0
462
516
 
463
- for (const threadData of importData.threads) {
464
- if (!threadData.id) {
465
- console.warn('Skipping thread without ID:', threadData)
466
- continue
517
+ const db = await threads.initDB()
518
+
519
+ if (importData.threads) {
520
+ if (!Array.isArray(importData.threads)) {
521
+ throw new Error('Invalid import file: missing or invalid threads array')
467
522
  }
468
523
 
469
- try {
470
- // Check if thread already exists
471
- const existingThread = await threads.getThread(threadData.id)
472
-
473
- if (existingThread) {
474
- // Update existing thread
475
- await threads.updateThread(threadData.id, {
476
- title: threadData.title,
477
- model: threadData.model,
478
- systemPrompt: threadData.systemPrompt,
479
- messages: threadData.messages || [],
480
- createdAt: threadData.createdAt,
481
- // Keep the existing updatedAt or use imported one
482
- updatedAt: threadData.updatedAt || existingThread.updatedAt
483
- })
484
- updatedCount++
485
- } else {
486
- // Add new thread directly to IndexedDB
487
- //await threads.initDB()
488
- const db = await threads.initDB()
489
- const tx = db.transaction(['threads'], 'readwrite')
490
- await tx.objectStore('threads').add({
491
- id: threadData.id,
492
- title: threadData.title || 'Imported Chat',
493
- model: threadData.model || '',
494
- systemPrompt: threadData.systemPrompt || '',
495
- messages: threadData.messages || [],
496
- createdAt: threadData.createdAt || new Date().toISOString(),
497
- updatedAt: threadData.updatedAt || new Date().toISOString()
498
- })
499
- await tx.complete
500
- importedCount++
524
+ const threadIds = new Set(await threads.getAllThreadIds())
525
+
526
+ for (const threadData of importData.threads) {
527
+ if (!threadData.id) {
528
+ console.warn('Skipping thread without ID:', threadData)
529
+ continue
530
+ }
531
+
532
+ try {
533
+ // Check if thread already exists
534
+ const existingThread = threadIds.has(threadData.id)
535
+ if (existingThread) {
536
+ existingCount++
537
+ } else {
538
+ // Add new thread directly to IndexedDB
539
+ const tx = db.transaction(['threads'], 'readwrite')
540
+ await tx.objectStore('threads').add(threadData)
541
+ await tx.complete
542
+ importedCount++
543
+ }
544
+ } catch (error) {
545
+ console.error('Failed to import thread:', threadData.id, error)
501
546
  }
502
- } catch (error) {
503
- console.error('Failed to import thread:', threadData.id, error)
504
547
  }
548
+
549
+ // Reload threads to reflect changes
550
+ await threads.loadThreads()
551
+
552
+ alert(`Import completed!\nNew threads: ${importedCount}\nExisting threads: ${existingCount}`)
505
553
  }
554
+ if (importData.requests) {
555
+ if (!Array.isArray(importData.requests)) {
556
+ throw new Error('Invalid import file: missing or invalid requests array')
557
+ }
506
558
 
507
- // Reload threads to reflect changes
508
- await threads.loadThreads()
559
+ const requestIds = new Set(await threads.getAllRequestIds())
509
560
 
510
- alert(`Import completed!\nNew threads: ${importedCount}\nUpdated threads: ${updatedCount}`)
561
+ for (const requestData of importData.requests) {
562
+ if (!requestData.id) {
563
+ console.warn('Skipping request without ID:', requestData)
564
+ continue
565
+ }
566
+
567
+ try {
568
+ // Check if request already exists
569
+ const existingRequest = requestIds.has(requestData.id)
570
+ if (existingRequest) {
571
+ existingCount++
572
+ } else {
573
+ // Add new request directly to IndexedDB
574
+ const db = await threads.initDB()
575
+ const tx = db.transaction(['requests'], 'readwrite')
576
+ await tx.objectStore('requests').add(requestData)
577
+ await tx.complete
578
+ importedCount++
579
+ }
580
+ } catch (error) {
581
+ console.error('Failed to import request:', requestData.id, error)
582
+ }
583
+ }
584
+
585
+ alert(`Import completed!\nNew requests: ${importedCount}\nExisting requests: ${existingCount}`)
586
+ }
511
587
 
512
588
  } catch (error) {
513
- console.error('Failed to import threads:', error)
514
- alert('Failed to import threads: ' + error.message)
589
+ console.error('Failed to import ' + importType + ':', error)
590
+ alert('Failed to import ' + importType + ': ' + error.message)
515
591
  } finally {
516
592
  isImporting.value = false
517
593
  // Clear the file input
@@ -674,6 +750,11 @@ export default {
674
750
  editingMessage.value = null
675
751
  }
676
752
 
753
+ // Cancel pending request
754
+ const cancelRequest = () => {
755
+ chatPrompt.cancel()
756
+ }
757
+
677
758
  function tokensTitle(usage) {
678
759
  let title = []
679
760
  if (usage.tokens && usage.price) {
@@ -723,8 +804,10 @@ export default {
723
804
  editMessage,
724
805
  saveEditedMessage,
725
806
  cancelEdit,
807
+ cancelRequest,
726
808
  configUpdated,
727
809
  exportThreads,
810
+ exportRequests,
728
811
  isExporting,
729
812
  triggerImport,
730
813
  handleFileImport,
llms/ui/ModelSelector.mjs CHANGED
@@ -1,6 +1,6 @@
1
+ import { ref, onMounted, onUnmounted } from "vue"
1
2
  import ProviderStatus from "./ProviderStatus.mjs"
2
3
  import ProviderIcon from "./ProviderIcon.mjs"
3
- import { useFormatters } from "@servicestack/vue"
4
4
 
5
5
  export default {
6
6
  components: {
@@ -10,28 +10,29 @@ export default {
10
10
  template:`
11
11
  <!-- Model Selector -->
12
12
  <div class="pl-1 flex space-x-2">
13
- <Autocomplete id="model" :options="models" label=""
13
+ <Autocomplete ref="refSelector" id="model" :options="models" label=""
14
14
  :modelValue="modelValue" @update:modelValue="$emit('update:modelValue', $event)"
15
15
  class="w-72 xl:w-84"
16
16
  :match="(x, value) => x.id.toLowerCase().includes(value.toLowerCase())"
17
17
  placeholder="Select Model...">
18
18
  <template #item="{ id, provider, provider_model, pricing }">
19
- <div :key="id + provider + provider_model" class="group truncate max-w-72 flex justify-between">
19
+ <div :key="id + provider + provider_model"
20
+ class="group truncate max-w-68 xl:max-w-72 flex justify-between">
20
21
  <span :title="id">{{id}}</span>
21
- <span class="flex items-center space-x-1">
22
+ <div class="hidden md:flex items-center space-x-1">
22
23
  <span v-if="pricing && (parseFloat(pricing.input) == 0 && parseFloat(pricing.input) == 0)">
23
- <span class="text-xs text-gray-500" title="Free to use">FREE</span>
24
+ <span class="text-xs text-gray-500 dark:text-gray-400" title="Free to use">FREE</span>
24
25
  </span>
25
- <span v-else-if="pricing" class="text-xs text-gray-500"
26
+ <span v-else-if="pricing" class="text-xs text-gray-500 dark:text-gray-400"
26
27
  :title="'Estimated Cost per token: ' + pricing.input + ' input | ' + pricing.output + ' output'">
27
28
  {{tokenPrice(pricing.input)}}
28
29
  &#183;
29
30
  {{tokenPrice(pricing.output)}} M
30
31
  </span>
31
- <span :title="provider_model + ' from ' + provider">
32
- <ProviderIcon :provider="provider" />
32
+ <span class="min-w-6" :title="provider_model + ' from ' + provider">
33
+ <ProviderIcon class="hidden xl:inline" :provider="provider" />
33
34
  </span>
34
- </span>
35
+ </div>
35
36
  </div>
36
37
  </template>
37
38
  </Autocomplete>
@@ -53,8 +54,25 @@ export default {
53
54
  return ret.endsWith('.00') ? ret.slice(0, -3) : ret
54
55
  }
55
56
 
57
+ const refSelector = ref()
58
+
59
+ function collapse(e) {
60
+ // call toggle when clicking outside of the Autocomplete component
61
+ if (refSelector.value && !refSelector.value.$el.contains(e.target)) {
62
+ refSelector.value.toggle(false)
63
+ }
64
+ }
65
+
66
+ onMounted(() => {
67
+ document.addEventListener('click', collapse)
68
+ })
69
+ onUnmounted(() => {
70
+ document.removeEventListener('click', collapse)
71
+ })
72
+
56
73
  return {
57
- tokenPrice
74
+ refSelector,
75
+ tokenPrice,
58
76
  }
59
77
  }
60
78
  }