llms-py 2.0.15__py3-none-any.whl → 2.0.17__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.
llms/ui/Brand.mjs CHANGED
@@ -9,15 +9,26 @@ export default {
9
9
  >
10
10
  History
11
11
  </button>
12
- <button type="button"
13
- @click="$emit('new')"
14
- class="text-gray-900 hover:text-blue-600 focus:outline-none transition-colors"
15
- title="New Chat"
16
- >
17
- <svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></g></svg>
18
- </button>
12
+
13
+ <div class="flex items-center space-x-2">
14
+ <button type="button"
15
+ @click="$emit('analytics')"
16
+ class="text-gray-900 hover:text-blue-600 focus:outline-none transition-colors"
17
+ title="Analytics"
18
+ >
19
+ <svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M5 22a1 1 0 0 1-1-1v-8a1 1 0 0 1 2 0v8a1 1 0 0 1-1 1m5 0a1 1 0 0 1-1-1V3a1 1 0 0 1 2 0v18a1 1 0 0 1-1 1m5 0a1 1 0 0 1-1-1V9a1 1 0 0 1 2 0v12a1 1 0 0 1-1 1m5 0a1 1 0 0 1-1-1v-4a1 1 0 0 1 2 0v4a1 1 0 0 1-1 1"/></svg>
20
+ </button>
21
+
22
+ <button type="button"
23
+ @click="$emit('new')"
24
+ class="text-gray-900 hover:text-blue-600 focus:outline-none transition-colors"
25
+ title="New Chat"
26
+ >
27
+ <svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></g></svg>
28
+ </button>
29
+ </div>
19
30
  </div>
20
31
  </div>
21
32
  `,
22
- emits:['home','new'],
33
+ emits:['home','new','analytics'],
23
34
  }
llms/ui/ChatPrompt.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { ref, nextTick, inject } from 'vue'
1
+ import { ref, nextTick, inject, unref } from 'vue'
2
2
  import { useRouter } from 'vue-router'
3
3
  import { lastRightPart } from '@servicestack/client'
4
- import { deepClone, fileToDataUri, fileToBase64, addCopyButtons } from './utils.mjs'
4
+ import { deepClone, fileToDataUri, fileToBase64, addCopyButtons, toModelInfo, tokenCost } from './utils.mjs'
5
5
 
6
6
  const imageExts = 'png,webp,jpg,jpeg,gif,bmp,svg,tiff,ico'.split(',')
7
7
  const audioExts = 'mp3,wav,ogg,flac,m4a,opus,webm'.split(',')
@@ -71,16 +71,28 @@ export default {
71
71
  </div>
72
72
 
73
73
  <div class="flex-1">
74
- <textarea
75
- ref="messageInput"
76
- v-model="messageText"
77
- @keydown.enter.exact.prevent="sendMessage"
78
- @keydown.enter.shift.exact="addNewLine"
79
- placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
80
- rows="3"
81
- class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
82
- :disabled="isGenerating || !model"
83
- ></textarea>
74
+ <div class="relative">
75
+ <textarea
76
+ ref="messageInput"
77
+ v-model="messageText"
78
+ @keydown.enter.exact.prevent="sendMessage"
79
+ @keydown.enter.shift.exact="addNewLine"
80
+ placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
81
+ rows="3"
82
+ class="block w-full rounded-md border border-gray-300 px-3 py-2 pr-12 text-sm placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
83
+ :disabled="isGenerating || !model"
84
+ ></textarea>
85
+ <button title="Send (Enter)" type="button"
86
+ @click="sendMessage"
87
+ :disabled="!messageText.trim() || isGenerating || !model"
88
+ class="absolute bottom-2 right-2 size-8 flex items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed disabled:border-gray-200 transition-colors">
89
+ <svg v-if="isGenerating" class="size-5 animate-spin" fill="none" viewBox="0 0 24 24">
90
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
91
+ <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>
92
+ </svg>
93
+ <svg v-else class="size-5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="20" stroke-dashoffset="20" d="M12 21l0 -17.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="20;0"/></path><path stroke-dasharray="12" stroke-dashoffset="12" d="M12 3l7 7M12 3l-7 7"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="12;0"/></path></g></svg>
94
+ </button>
95
+ </div>
84
96
 
85
97
  <!-- Attached files preview -->
86
98
  <div v-if="attachedFiles.length" class="mt-2 flex flex-wrap gap-2">
@@ -96,19 +108,6 @@ export default {
96
108
  Please select a model
97
109
  </div>
98
110
  </div>
99
-
100
- <div class="pt-3">
101
- <button title="Send (Enter)" type="button"
102
- @click="sendMessage"
103
- :disabled="!messageText.trim() || isGenerating || !model"
104
- class="p-2 flex items-center justify-center rounded-full bg-gray-700 text-white transition-colors hover:opacity-70 focus-visible:outline-none focus-visible:outline-black disabled:bg-[#D7D7D7] disabled:text-[#f4f4f4] disabled:hover:opacity-100 dark:bg-white dark:text-black dark:focus-visible:outline-white disabled:dark:bg-token-text-quaternary dark:disabled:text-token-main-surface-secondary">
105
- <svg v-if="isGenerating" class="size-8 animate-spin" fill="none" viewBox="0 0 24 24">
106
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
107
- <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>
108
- </svg>
109
- <svg v-else class="size-8" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="20" stroke-dashoffset="20" d="M12 21l0 -17.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="20;0"/></path><path stroke-dasharray="12" stroke-dashoffset="12" d="M12 3l7 7M12 3l-7 7"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="12;0"/></path></g></svg>
110
- </button>
111
- </div>
112
111
  </div>
113
112
  </div>
114
113
  `,
@@ -222,21 +221,28 @@ export default {
222
221
  threadId = currentThread.value.id
223
222
  // Update the existing thread's model and systemPrompt to match current selection
224
223
  await threads.updateThread(threadId, {
225
- model: props.model,
224
+ model: props.model.id,
225
+ info: toModelInfo(props.model),
226
226
  systemPrompt: props.systemPrompt
227
227
  })
228
228
  }
229
229
 
230
- // Add user message
231
- await threads.addMessageToThread(threadId, {
232
- role: 'user',
233
- content: message
234
- })
230
+ // Get the thread to check for duplicates
231
+ let thread = await threads.getThread(threadId)
232
+ const lastMessage = thread.messages[thread.messages.length - 1]
233
+ const isDuplicate = lastMessage && lastMessage.role === 'user' && lastMessage.content === message
235
234
 
236
- isGenerating.value = true
235
+ // Add user message only if it's not a duplicate
236
+ if (!isDuplicate) {
237
+ await threads.addMessageToThread(threadId, {
238
+ role: 'user',
239
+ content: message
240
+ })
241
+ // Reload thread after adding message
242
+ thread = await threads.getThread(threadId)
243
+ }
237
244
 
238
- // Get the updated thread to prepare chat request
239
- const thread = await threads.getThread(threadId)
245
+ isGenerating.value = true
240
246
  const messages = [...thread.messages]
241
247
 
242
248
  // Add system prompt if present
@@ -250,7 +256,7 @@ export default {
250
256
  }
251
257
 
252
258
  const chatRequest = createChatRequest()
253
- chatRequest.model = props.model
259
+ chatRequest.model = props.model.id
254
260
 
255
261
  // Apply user settings
256
262
  applySettings(chatRequest)
@@ -334,6 +340,7 @@ export default {
334
340
 
335
341
  // Send to API
336
342
  console.debug('chatRequest', chatRequest)
343
+ const startTime = Date.now()
337
344
  const response = await ai.post('/v1/chat/completions', {
338
345
  body: JSON.stringify(chatRequest)
339
346
  })
@@ -369,6 +376,7 @@ export default {
369
376
  } else {
370
377
  try {
371
378
  result = await response.json()
379
+ console.debug('chatResponse', JSON.stringify(result, null, 2))
372
380
  } catch (e) {
373
381
  errorStatus.value = {
374
382
  errorCode: 'Error',
@@ -388,7 +396,21 @@ export default {
388
396
  if (!errorStatus.value) {
389
397
  // Add assistant response (save entire message including reasoning)
390
398
  const assistantMessage = result.choices?.[0]?.message
391
- await threads.addMessageToThread(threadId, assistantMessage)
399
+
400
+ const usage = result.usage
401
+ if (usage) {
402
+ if (result.metadata?.pricing) {
403
+ const [ input, output ] = result.metadata.pricing.split('/')
404
+ usage.duration = result.metadata.duration ?? (Date.now() - startTime)
405
+ usage.input = input
406
+ usage.output = output
407
+ usage.tokens = usage.completion_tokens
408
+ usage.price = usage.output
409
+ usage.cost = tokenCost(usage.prompt_tokens * parseFloat(input) + usage.completion_tokens * parseFloat(output))
410
+ }
411
+ await threads.logRequest(threadId, props.model, chatRequest, result)
412
+ }
413
+ await threads.addMessageToThread(threadId, assistantMessage, usage)
392
414
 
393
415
  nextTick(addCopyButtons)
394
416
 
llms/ui/Main.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { ref, computed, nextTick, watch, onMounted, provide, inject } from 'vue'
2
2
  import { useRouter, useRoute } from 'vue-router'
3
+ import { useFormatters } from '@servicestack/vue'
3
4
  import { useThreadStore } from './threadStore.mjs'
4
- import { storageObject, addCopyButtons } from './utils.mjs'
5
+ import { storageObject, addCopyButtons, formatCost, statsTitle } from './utils.mjs'
5
6
  import { renderMarkdown } from './markdown.mjs'
6
7
  import ChatPrompt, { useChatPrompt } from './ChatPrompt.mjs'
7
8
  import SignIn from './SignIn.mjs'
@@ -12,6 +13,8 @@ import SystemPromptEditor from './SystemPromptEditor.mjs'
12
13
  import { useSettings } from "./SettingsDialog.mjs"
13
14
  import Welcome from './Welcome.mjs'
14
15
 
16
+ const { humanifyMs, humanifyNumber } = useFormatters()
17
+
15
18
  export default {
16
19
  components: {
17
20
  ModelSelector,
@@ -140,11 +143,12 @@ export default {
140
143
  <button
141
144
  type="button"
142
145
  @click="copyMessageContent(message)"
143
- 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-2 focus:ring-blue-500"
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"
144
147
  :class="message.role === 'user' ? 'text-white/70 hover:text-white hover:bg-white/20' : 'text-gray-500 hover:text-gray-700'"
145
148
  title="Copy message content"
146
149
  >
147
- <svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
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>
151
+ <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">
148
152
  <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
149
153
  <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
150
154
  </svg>
@@ -170,9 +174,41 @@ export default {
170
174
 
171
175
  <div v-if="message.role !== 'assistant'" class="whitespace-pre-wrap">{{ message.content }}</div>
172
176
  <div class="mt-2 text-xs opacity-70">
173
- {{ formatTime(message.timestamp) }}
177
+ <span>{{ formatTime(message.timestamp) }}</span>
178
+ <span v-if="message.usage" :title="tokensTitle(message.usage)">
179
+ &#8226;
180
+ {{ humanifyNumber(message.usage.tokens) }} tokens
181
+ <span v-if="message.usage.cost">&#183; {{ message.usage.cost }}</span>
182
+ <span v-if="message.usage.duration"> in {{ humanifyMs(message.usage.duration) }}</span>
183
+ </span>
174
184
  </div>
175
185
  </div>
186
+
187
+ <!-- Edit and Redo buttons (shown on hover for user messages, outside bubble) -->
188
+ <div v-if="message.role === 'user'" class="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-1">
189
+ <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"
191
+ title="Edit message">
192
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
193
+ <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>
194
+ </svg>
195
+ Edit
196
+ </button>
197
+ <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"
199
+ title="Redo message (clears all responses after this message and re-runs it)">
200
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
201
+ <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>
202
+ </svg>
203
+ Redo
204
+ </button>
205
+ </div>
206
+ </div>
207
+
208
+ <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 text-sm">
209
+ <span :title="statsTitle(currentThread.stats)">
210
+ {{ 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
+ </span>
176
212
  </div>
177
213
 
178
214
  <!-- Loading indicator -->
@@ -226,6 +262,29 @@ export default {
226
262
  </div>
227
263
  </div>
228
264
  </div>
265
+
266
+ <!-- Edit message modal -->
267
+ <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">
269
+ <CloseButton @click="cancelEdit" class="" />
270
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">Edit Message</h3>
271
+ <textarea
272
+ 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"
274
+ placeholder="Edit your message..."
275
+ ></textarea>
276
+ <div class="mt-4 flex gap-2 justify-end">
277
+ <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">
279
+ Cancel
280
+ </button>
281
+ <button type="button" @click="saveEditedMessage"
282
+ class="px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-all">
283
+ Save
284
+ </button>
285
+ </div>
286
+ </div>
287
+ </div>
229
288
  </div>
230
289
 
231
290
  <!-- Input Area - only show when thread is selected -->
@@ -273,6 +332,10 @@ export default {
273
332
  const isExporting = ref(false)
274
333
  const isImporting = ref(false)
275
334
  const fileInput = ref(null)
335
+ const copying = ref(null)
336
+ const editingMessageId = ref(null)
337
+ const editingMessageContent = ref('')
338
+ const editingMessage = ref(null)
276
339
 
277
340
  // Auto-scroll to bottom when new messages arrive
278
341
  const scrollToBottom = async () => {
@@ -421,7 +484,7 @@ export default {
421
484
  updatedCount++
422
485
  } else {
423
486
  // Add new thread directly to IndexedDB
424
- await threads.initDB()
487
+ //await threads.initDB()
425
488
  const db = await threads.initDB()
426
489
  const tx = db.transaction(['threads'], 'readwrite')
427
490
  await tx.objectStore('threads').add({
@@ -490,6 +553,7 @@ export default {
490
553
  // Copy message content to clipboard
491
554
  const copyMessageContent = async (message) => {
492
555
  try {
556
+ copying.value = message
493
557
  await navigator.clipboard.writeText(message.content)
494
558
  // Could add a toast notification here if desired
495
559
  } catch (err) {
@@ -502,6 +566,129 @@ export default {
502
566
  document.execCommand('copy')
503
567
  document.body.removeChild(textArea)
504
568
  }
569
+ setTimeout(() => { copying.value = null }, 2000)
570
+ }
571
+
572
+ // Redo a user message (clear all messages after it and re-run)
573
+ const redoMessage = async (message) => {
574
+ if (!currentThread.value || message.role !== 'user') return
575
+
576
+ try {
577
+ const threadId = currentThread.value.id
578
+
579
+ // Clear all messages after this one
580
+ await threads.redoMessageFromThread(threadId, message.id)
581
+
582
+ // Extract the actual message content (remove media indicators if present)
583
+ let messageContent = message.content
584
+ // Remove media indicators like [🖼️ filename] or [🔉 filename] or [📎 filename]
585
+ messageContent = messageContent.replace(/\n\n\[[🖼️🔉📎] [^\]]+\]$/, '')
586
+
587
+ // Set the message text in the chat prompt
588
+ chatPrompt.messageText.value = messageContent
589
+
590
+ // Clear any attached files since we're re-running
591
+ chatPrompt.attachedFiles.value = []
592
+
593
+ // Trigger send by simulating the send action
594
+ // We'll use a small delay to ensure the UI updates
595
+ await nextTick()
596
+
597
+ // Find the send button and click it
598
+ const sendButton = document.querySelector('button[title*="Send"]')
599
+ if (sendButton && !sendButton.disabled) {
600
+ sendButton.click()
601
+ }
602
+ } catch (error) {
603
+ console.error('Failed to redo message:', error)
604
+ errorStatus.value = {
605
+ errorCode: 'Error',
606
+ message: 'Failed to redo message: ' + error.message,
607
+ stackTrace: null
608
+ }
609
+ }
610
+ }
611
+
612
+ // Edit a user message
613
+ const editMessage = (message) => {
614
+ if (!currentThread.value || message.role !== 'user') return
615
+
616
+ editingMessage.value = message
617
+ editingMessageId.value = message.id
618
+ // Extract the actual message content (remove media indicators if present)
619
+ let messageContent = message.content
620
+ messageContent = messageContent.replace(/\n\n\[[🖼️🔉📎] [^\]]+\]$/, '')
621
+ editingMessageContent.value = messageContent
622
+ }
623
+
624
+ // Save edited message
625
+ const saveEditedMessage = async () => {
626
+ if (!currentThread.value || !editingMessage.value || !editingMessageContent.value.trim()) return
627
+
628
+ try {
629
+ const threadId = currentThread.value.id
630
+ const messageId = editingMessage.value.id
631
+ const updatedContent = editingMessageContent.value
632
+
633
+ // Update the message content
634
+ editingMessage.value.content = updatedContent
635
+ await threads.updateMessageInThread(threadId, messageId, { content: updatedContent })
636
+
637
+ // Clear editing state
638
+ editingMessageId.value = null
639
+ editingMessageContent.value = ''
640
+ editingMessage.value = null
641
+
642
+ // Now redo the message (clear all responses after it and re-run)
643
+ await nextTick()
644
+ await threads.redoMessageFromThread(threadId, messageId)
645
+
646
+ // Set the message text in the chat prompt
647
+ chatPrompt.messageText.value = updatedContent
648
+
649
+ // Clear any attached files since we're re-running
650
+ chatPrompt.attachedFiles.value = []
651
+
652
+ // Trigger send by simulating the send action
653
+ await nextTick()
654
+
655
+ // Find the send button and click it
656
+ const sendButton = document.querySelector('button[title*="Send"]')
657
+ if (sendButton && !sendButton.disabled) {
658
+ sendButton.click()
659
+ }
660
+ } catch (error) {
661
+ console.error('Failed to save edited message:', error)
662
+ errorStatus.value = {
663
+ errorCode: 'Error',
664
+ message: 'Failed to save edited message: ' + error.message,
665
+ stackTrace: null
666
+ }
667
+ }
668
+ }
669
+
670
+ // Cancel editing
671
+ const cancelEdit = () => {
672
+ editingMessageId.value = null
673
+ editingMessageContent.value = ''
674
+ editingMessage.value = null
675
+ }
676
+
677
+ function tokensTitle(usage) {
678
+ let title = []
679
+ if (usage.tokens && usage.price) {
680
+ const msg = parseFloat(usage.price) > 0
681
+ ? `${usage.tokens} tokens @ ${usage.price} = ${tokenCost(usage.price, usage.tokens)}`
682
+ : `${usage.tokens} tokens`
683
+ const duration = usage.duration ? ` in ${usage.duration}ms` : ''
684
+ title.push(msg + duration)
685
+ }
686
+ return title.join('\n')
687
+ }
688
+ const numFmt = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 6 })
689
+ function tokenCost(price, tokens) {
690
+ if (!price || !tokens) return ''
691
+ return numFmt.format(parseFloat(price) * tokens)
505
692
  }
506
693
 
507
694
  onMounted(() => {
@@ -522,12 +709,20 @@ export default {
522
709
  showSystemPrompt,
523
710
  messagesContainer,
524
711
  errorStatus,
712
+ copying,
713
+ editingMessageId,
714
+ editingMessageContent,
715
+ editingMessage,
525
716
  formatTime,
526
717
  renderMarkdown,
527
718
  isReasoningExpanded,
528
719
  toggleReasoning,
529
720
  formatReasoning,
530
721
  copyMessageContent,
722
+ redoMessage,
723
+ editMessage,
724
+ saveEditedMessage,
725
+ cancelEdit,
531
726
  configUpdated,
532
727
  exportThreads,
533
728
  isExporting,
@@ -535,6 +730,11 @@ export default {
535
730
  handleFileImport,
536
731
  isImporting,
537
732
  fileInput,
733
+ tokensTitle,
734
+ humanifyMs,
735
+ humanifyNumber,
736
+ formatCost,
737
+ statsTitle,
538
738
  }
539
739
  }
540
740
  }
llms/ui/ModelSelector.mjs CHANGED
@@ -1,8 +1,11 @@
1
- import ProviderStatus from "./ProviderStatus.mjs";
1
+ import ProviderStatus from "./ProviderStatus.mjs"
2
+ import ProviderIcon from "./ProviderIcon.mjs"
3
+ import { useFormatters } from "@servicestack/vue"
2
4
 
3
5
  export default {
4
6
  components: {
5
7
  ProviderStatus,
8
+ ProviderIcon,
6
9
  },
7
10
  template:`
8
11
  <!-- Model Selector -->
@@ -10,10 +13,26 @@ export default {
10
13
  <Autocomplete id="model" :options="models" label=""
11
14
  :modelValue="modelValue" @update:modelValue="$emit('update:modelValue', $event)"
12
15
  class="w-72 xl:w-84"
13
- :match="(x, value) => x.toLowerCase().includes(value.toLowerCase())"
16
+ :match="(x, value) => x.id.toLowerCase().includes(value.toLowerCase())"
14
17
  placeholder="Select Model...">
15
- <template #item="{ value }">
16
- <div class="truncate max-w-72" :title="value">{{value}}</div>
18
+ <template #item="{ id, provider, provider_model, pricing }">
19
+ <div :key="id + provider + provider_model" class="group truncate max-w-72 flex justify-between">
20
+ <span :title="id">{{id}}</span>
21
+ <span class="flex items-center space-x-1">
22
+ <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>
25
+ <span v-else-if="pricing" class="text-xs text-gray-500"
26
+ :title="'Estimated Cost per token: ' + pricing.input + ' input | ' + pricing.output + ' output'">
27
+ {{tokenPrice(pricing.input)}}
28
+ &#183;
29
+ {{tokenPrice(pricing.output)}} M
30
+ </span>
31
+ <span :title="provider_model + ' from ' + provider">
32
+ <ProviderIcon :provider="provider" />
33
+ </span>
34
+ </span>
35
+ </div>
17
36
  </template>
18
37
  </Autocomplete>
19
38
  <ProviderStatus @updated="$emit('updated', $event)" />
@@ -25,5 +44,17 @@ export default {
25
44
  modelValue: String,
26
45
  },
27
46
  setup() {
47
+
48
+ const numFmt = new Intl.NumberFormat(undefined,{style:'currency',currency:'USD'})
49
+
50
+ function tokenPrice(price) {
51
+ if (!price) return ''
52
+ var ret = numFmt.format(parseFloat(price) * 1_000_000)
53
+ return ret.endsWith('.00') ? ret.slice(0, -3) : ret
54
+ }
55
+
56
+ return {
57
+ tokenPrice
58
+ }
28
59
  }
29
60
  }
@@ -0,0 +1,29 @@
1
+ export default {
2
+ template:`
3
+ <svg v-if="matches(['openrouter'])" class="size-5" xmlns="http://www.w3.org/2000/svg" fill="#71717A" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>
4
+ <svg v-else-if="matches(['anthropic','claude','haiku'])" class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M16.765 5h-3.308l5.923 15h3.23zM7.226 5L1.38 20h3.308l1.307-3.154h6.154l1.23 3.077h3.309L10.688 5zm-.308 9.077l2-5.308l2.077 5.308z"/></svg>
5
+ <svg v-else-if="matches(['google','gemini'])" class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="M3 12a9 9 0 0 0 9-9a9 9 0 0 0 9 9a9 9 0 0 0-9 9a9 9 0 0 0-9-9Z"/></svg>
6
+ <svg v-else-if="matches(['grok','xai'])" class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"></path></svg>
7
+ <svg v-else-if="matches(['zai','glm'])" class="size-5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>
8
+ <svg v-else-if="matches(['qwen'])" class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M9 2h3.5L14 4.5h5.13L20.5 7m1.5 7.5l-1.645 2.663h-2.477L15 22h-3.217M5 20l-1.5-2.5l1-3l-2.5-5L4 7"/><path d="m19.19 9.662l1.31-2.661H10l1-2l-2-3l-2.251 5H4l5 10H6l-1 3h5.5l1.252 2l5.65-9.935L18.94 14.5H22z"/><path d="M12 15.5L9 10h6z"/></g></svg>
9
+ <svg v-else-if="matches(['mistral','codestral'])" class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 233"><path d="M186.182 0h46.545v46.545h-46.545z"/><path fill="#f7d046" d="M209.455 0H256v46.545h-46.545z"/><path d="M0 0h46.545v46.545H0zm0 46.545h46.545V93.09H0zm0 46.546h46.545v46.545H0zm0 46.545h46.545v46.545H0zm0 46.546h46.545v46.545H0z"/><path fill="#f7d046" d="M23.273 0h46.545v46.545H23.273z"/><path fill="#f2a73b" d="M209.455 46.545H256V93.09h-46.545zm-186.182 0h46.545V93.09H23.273z"/><path d="M139.636 46.545h46.545V93.09h-46.545z"/><path fill="#f2a73b" d="M162.909 46.545h46.545V93.09h-46.545zm-93.091 0h46.545V93.09H69.818z"/><path fill="#ee792f" d="M116.364 93.091h46.545v46.545h-46.545zm46.545 0h46.545v46.545h-46.545zm-93.091 0h46.545v46.545H69.818z"/><path d="M93.091 139.636h46.545v46.545H93.091z"/><path fill="#eb5829" d="M116.364 139.636h46.545v46.545h-46.545z"/><path fill="#ee792f" d="M209.455 93.091H256v46.545h-46.545zm-186.182 0h46.545v46.545H23.273z"/><path d="M186.182 139.636h46.545v46.545h-46.545z"/><path fill="#eb5829" d="M209.455 139.636H256v46.545h-46.545z"/><path d="M186.182 186.182h46.545v46.545h-46.545z"/><path fill="#eb5829" d="M23.273 139.636h46.545v46.545H23.273z"/><path fill="#ea3326" d="M209.455 186.182H256v46.545h-46.545zm-186.182 0h46.545v46.545H23.273z"/></svg>
10
+ <svg v-else-if="matches(['groq'])" class="size-5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>
11
+ <svg v-else-if="matches(['llama'])" class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">
12
+ <path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path>
13
+ </svg>
14
+ <svg v-else class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linejoin="round" d="M12.019 16.225L8.35 14.13m3.669 2.096l3.65-2.129m-3.65 2.13L9.183 17.88l-5.196-3a5 5 0 0 1-.714-.498m5.077-.252L5.5 12.5v-6q0-.444.075-.867m2.775 8.496l-.018-4.225m5.97-6.652a5.001 5.001 0 0 0-8.727 2.38m8.727-2.38a5 5 0 0 0-.789.369l-5.196 3l.015 3.283m5.97-6.652a5.001 5.001 0 0 1 6.425 6.367M5.575 5.633a5.001 5.001 0 0 0-2.302 8.748m8.708-6.606l3.669 2.096m-3.67-2.096L8.33 9.904m3.65-2.129l2.836-1.654l5.196 3q.384.223.714.498m-5.077.252L18.5 11.5v6q0 .444-.075.867M15.65 9.871l.018 4.225m-5.97 6.652a5.001 5.001 0 0 0 8.727-2.38m-8.727 2.38a5 5 0 0 0 .789-.369l5.196-3l-.015-3.283m-5.97 6.652a5.001 5.001 0 0 1-6.425-6.367m15.152 3.986a5.001 5.001 0 0 0 2.302-8.748" stroke-width="1"/></svg>
15
+ `,
16
+ props: {
17
+ provider: String,
18
+ },
19
+ setup(props) {
20
+ function matches(providers) {
21
+ if (!props.provider) return false
22
+ const providerLower = props.provider.toLowerCase().replace(/[\s-.]+/g, '')
23
+ return providers.some(provider => providerLower.includes(provider))
24
+ }
25
+ return {
26
+ matches
27
+ }
28
+ }
29
+ }