llms-py 2.0.35__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.
- llms/llms.json +306 -1184
- llms/main.py +885 -181
- llms/providers.json +1 -0
- llms/ui/ChatPrompt.mjs +138 -122
- llms/ui/Main.mjs +162 -112
- llms/ui/ModelSelector.mjs +662 -47
- llms/ui/ProviderIcon.mjs +20 -14
- llms/ui/ProviderStatus.mjs +2 -2
- llms/ui/ai.mjs +10 -10
- llms/ui/app.css +369 -83
- llms/ui/markdown.mjs +8 -5
- llms/ui/threadStore.mjs +54 -45
- llms/ui/utils.mjs +100 -28
- {llms_py-2.0.35.dist-info → llms_py-3.0.0b1.dist-info}/METADATA +1 -1
- {llms_py-2.0.35.dist-info → llms_py-3.0.0b1.dist-info}/RECORD +19 -18
- {llms_py-2.0.35.dist-info → llms_py-3.0.0b1.dist-info}/WHEEL +0 -0
- {llms_py-2.0.35.dist-info → llms_py-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {llms_py-2.0.35.dist-info → llms_py-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
- {llms_py-2.0.35.dist-info → llms_py-3.0.0b1.dist-info}/top_level.txt +0 -0
llms/ui/Main.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { ref, computed, nextTick, watch, onMounted, provide, inject } from 'vue'
|
|
|
2
2
|
import { useRouter, useRoute } from 'vue-router'
|
|
3
3
|
import { useFormatters } from '@servicestack/vue'
|
|
4
4
|
import { useThreadStore } from './threadStore.mjs'
|
|
5
|
-
import { storageObject, addCopyButtons, formatCost, statsTitle } from './utils.mjs'
|
|
5
|
+
import { storageObject, addCopyButtons, formatCost, statsTitle, fetchCacheInfos } from './utils.mjs'
|
|
6
6
|
import { renderMarkdown } from './markdown.mjs'
|
|
7
7
|
import ChatPrompt, { useChatPrompt } from './ChatPrompt.mjs'
|
|
8
8
|
import SignIn from './SignIn.mjs'
|
|
@@ -59,9 +59,8 @@ export default {
|
|
|
59
59
|
<Welcome />
|
|
60
60
|
|
|
61
61
|
<!-- Chat input for new conversation -->
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
</div>
|
|
62
|
+
<!-- Moved to bottom input area -->
|
|
63
|
+
<div class="h-2"></div>
|
|
65
64
|
|
|
66
65
|
<!-- Export/Import buttons -->
|
|
67
66
|
<div class="mt-2 flex space-x-3 justify-center items-center">
|
|
@@ -180,7 +179,31 @@ export default {
|
|
|
180
179
|
</div>
|
|
181
180
|
</div>
|
|
182
181
|
|
|
183
|
-
|
|
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>
|
|
184
207
|
<div class="mt-2 text-xs opacity-70">
|
|
185
208
|
<span>{{ formatTime(message.timestamp) }}</span>
|
|
186
209
|
<span v-if="message.usage" :title="tokensTitle(message.usage)">
|
|
@@ -278,33 +301,25 @@ export default {
|
|
|
278
301
|
</div>
|
|
279
302
|
</div>
|
|
280
303
|
|
|
281
|
-
<!-- Edit message modal -->
|
|
282
|
-
<div v-if="editingMessageId" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
283
|
-
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-2xl w-full mx-4">
|
|
284
|
-
<CloseButton @click="cancelEdit" class="" />
|
|
285
|
-
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Edit Message</h3>
|
|
286
|
-
<textarea
|
|
287
|
-
v-model="editingMessageContent"
|
|
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"
|
|
289
|
-
placeholder="Edit your message..."
|
|
290
|
-
></textarea>
|
|
291
|
-
<div class="mt-4 flex gap-2 justify-end">
|
|
292
|
-
<button type="button" @click="cancelEdit"
|
|
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">
|
|
294
|
-
Cancel
|
|
295
|
-
</button>
|
|
296
|
-
<button type="button" @click="saveEditedMessage"
|
|
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">
|
|
298
|
-
Save
|
|
299
|
-
</button>
|
|
300
|
-
</div>
|
|
301
|
-
</div>
|
|
302
|
-
</div>
|
|
303
304
|
</div>
|
|
304
305
|
|
|
305
|
-
<!-- Input Area
|
|
306
|
-
<div
|
|
307
|
-
<ChatPrompt :model="
|
|
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>
|
|
308
323
|
</div>
|
|
309
324
|
</div>
|
|
310
325
|
`,
|
|
@@ -318,7 +333,7 @@ export default {
|
|
|
318
333
|
const { currentThread } = threads
|
|
319
334
|
const chatPrompt = useChatPrompt()
|
|
320
335
|
const chatSettings = useSettings()
|
|
321
|
-
const {
|
|
336
|
+
const {
|
|
322
337
|
errorStatus,
|
|
323
338
|
isGenerating,
|
|
324
339
|
} = chatPrompt
|
|
@@ -340,6 +355,10 @@ export default {
|
|
|
340
355
|
const prompts = computed(() => [customPrompt, ...config.prompts])
|
|
341
356
|
|
|
342
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
|
+
})
|
|
343
362
|
const selectedPrompt = ref(prefs.systemPrompt || null)
|
|
344
363
|
const currentSystemPrompt = ref('')
|
|
345
364
|
const showSystemPrompt = ref(false)
|
|
@@ -348,9 +367,14 @@ export default {
|
|
|
348
367
|
const isImporting = ref(false)
|
|
349
368
|
const fileInput = ref(null)
|
|
350
369
|
const copying = ref(null)
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
const
|
|
370
|
+
const lightboxUrl = ref(null)
|
|
371
|
+
|
|
372
|
+
const openLightbox = (url) => {
|
|
373
|
+
lightboxUrl.value = url
|
|
374
|
+
}
|
|
375
|
+
const closeLightbox = () => {
|
|
376
|
+
lightboxUrl.value = null
|
|
377
|
+
}
|
|
354
378
|
|
|
355
379
|
// Auto-scroll to bottom when new messages arrive
|
|
356
380
|
const scrollToBottom = async () => {
|
|
@@ -403,7 +427,7 @@ export default {
|
|
|
403
427
|
// If using a custom prompt, keep whatever is already in currentSystemPrompt
|
|
404
428
|
if (newPrompt && newPrompt.id === '_custom_') return
|
|
405
429
|
const prompt = newPrompt && config.prompts.find(p => p.id === newPrompt.id)
|
|
406
|
-
currentSystemPrompt.value = prompt ? prompt.value.replace(/\n/g,' ') : ''
|
|
430
|
+
currentSystemPrompt.value = prompt ? prompt.value.replace(/\n/g, ' ') : ''
|
|
407
431
|
}, { immediate: true })
|
|
408
432
|
|
|
409
433
|
watch(() => [selectedModel.value, selectedPrompt.value], () => {
|
|
@@ -504,8 +528,8 @@ export default {
|
|
|
504
528
|
try {
|
|
505
529
|
const text = await file.text()
|
|
506
530
|
const importData = JSON.parse(text)
|
|
507
|
-
importType = importData.threads
|
|
508
|
-
? 'threads'
|
|
531
|
+
importType = importData.threads
|
|
532
|
+
? 'threads'
|
|
509
533
|
: importData.requests
|
|
510
534
|
? 'requests'
|
|
511
535
|
: 'unknown'
|
|
@@ -626,17 +650,38 @@ export default {
|
|
|
626
650
|
}
|
|
627
651
|
const formatReasoning = (r) => typeof r === 'string' ? r : JSON.stringify(r, null, 2)
|
|
628
652
|
|
|
629
|
-
// Copy message content to clipboard
|
|
630
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\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
|
+
|
|
631
676
|
try {
|
|
632
677
|
copying.value = message
|
|
633
|
-
await navigator.clipboard.writeText(
|
|
678
|
+
await navigator.clipboard.writeText(content)
|
|
634
679
|
// Could add a toast notification here if desired
|
|
635
680
|
} catch (err) {
|
|
636
681
|
console.error('Failed to copy message content:', err)
|
|
637
682
|
// Fallback for older browsers
|
|
638
683
|
const textArea = document.createElement('textarea')
|
|
639
|
-
textArea.value =
|
|
684
|
+
textArea.value = content
|
|
640
685
|
document.body.appendChild(textArea)
|
|
641
686
|
textArea.select()
|
|
642
687
|
document.execCommand('copy')
|
|
@@ -645,7 +690,57 @@ export default {
|
|
|
645
690
|
setTimeout(() => { copying.value = null }, 2000)
|
|
646
691
|
}
|
|
647
692
|
|
|
648
|
-
|
|
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)
|
|
649
744
|
const redoMessage = async (message) => {
|
|
650
745
|
if (!currentThread.value || message.role !== 'user') return
|
|
651
746
|
|
|
@@ -655,16 +750,13 @@ export default {
|
|
|
655
750
|
// Clear all messages after this one
|
|
656
751
|
await threads.redoMessageFromThread(threadId, message.id)
|
|
657
752
|
|
|
658
|
-
|
|
659
|
-
let messageContent = message.content
|
|
660
|
-
// Remove media indicators like [🖼️ filename] or [🔉 filename] or [📎 filename]
|
|
661
|
-
messageContent = messageContent.replace(/\n\n\[[🖼️🔉📎] [^\]]+\]$/, '')
|
|
753
|
+
const state = await extractMessageState(message)
|
|
662
754
|
|
|
663
755
|
// Set the message text in the chat prompt
|
|
664
|
-
chatPrompt.messageText.value =
|
|
756
|
+
chatPrompt.messageText.value = state.text
|
|
665
757
|
|
|
666
|
-
//
|
|
667
|
-
chatPrompt.attachedFiles.value =
|
|
758
|
+
// Restore attached files
|
|
759
|
+
chatPrompt.attachedFiles.value = state.files
|
|
668
760
|
|
|
669
761
|
// Trigger send by simulating the send action
|
|
670
762
|
// We'll use a small delay to ensure the UI updates
|
|
@@ -686,68 +778,24 @@ export default {
|
|
|
686
778
|
}
|
|
687
779
|
|
|
688
780
|
// Edit a user message
|
|
689
|
-
const editMessage = (message) => {
|
|
781
|
+
const editMessage = async (message) => {
|
|
690
782
|
if (!currentThread.value || message.role !== 'user') return
|
|
691
783
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const threadId = currentThread.value.id
|
|
706
|
-
const messageId = editingMessage.value.id
|
|
707
|
-
const updatedContent = editingMessageContent.value
|
|
708
|
-
|
|
709
|
-
// Update the message content
|
|
710
|
-
editingMessage.value.content = updatedContent
|
|
711
|
-
await threads.updateMessageInThread(threadId, messageId, { content: updatedContent })
|
|
712
|
-
|
|
713
|
-
// Clear editing state
|
|
714
|
-
editingMessageId.value = null
|
|
715
|
-
editingMessageContent.value = ''
|
|
716
|
-
editingMessage.value = null
|
|
717
|
-
|
|
718
|
-
// Now redo the message (clear all responses after it and re-run)
|
|
719
|
-
await nextTick()
|
|
720
|
-
await threads.redoMessageFromThread(threadId, messageId)
|
|
721
|
-
|
|
722
|
-
// Set the message text in the chat prompt
|
|
723
|
-
chatPrompt.messageText.value = updatedContent
|
|
724
|
-
|
|
725
|
-
// Clear any attached files since we're re-running
|
|
726
|
-
chatPrompt.attachedFiles.value = []
|
|
727
|
-
|
|
728
|
-
// Trigger send by simulating the send action
|
|
729
|
-
await nextTick()
|
|
730
|
-
|
|
731
|
-
// Find the send button and click it
|
|
732
|
-
const sendButton = document.querySelector('button[title*="Send"]')
|
|
733
|
-
if (sendButton && !sendButton.disabled) {
|
|
734
|
-
sendButton.click()
|
|
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
|
|
735
797
|
}
|
|
736
|
-
}
|
|
737
|
-
console.error('Failed to save edited message:', error)
|
|
738
|
-
errorStatus.value = {
|
|
739
|
-
errorCode: 'Error',
|
|
740
|
-
message: 'Failed to save edited message: ' + error.message,
|
|
741
|
-
stackTrace: null
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// Cancel editing
|
|
747
|
-
const cancelEdit = () => {
|
|
748
|
-
editingMessageId.value = null
|
|
749
|
-
editingMessageContent.value = ''
|
|
750
|
-
editingMessage.value = null
|
|
798
|
+
})
|
|
751
799
|
}
|
|
752
800
|
|
|
753
801
|
// Cancel pending request
|
|
@@ -785,15 +833,13 @@ export default {
|
|
|
785
833
|
customPromptValue,
|
|
786
834
|
currentThread,
|
|
787
835
|
selectedModel,
|
|
836
|
+
selectedModelObj,
|
|
788
837
|
selectedPrompt,
|
|
789
838
|
currentSystemPrompt,
|
|
790
839
|
showSystemPrompt,
|
|
791
840
|
messagesContainer,
|
|
792
841
|
errorStatus,
|
|
793
842
|
copying,
|
|
794
|
-
editingMessageId,
|
|
795
|
-
editingMessageContent,
|
|
796
|
-
editingMessage,
|
|
797
843
|
formatTime,
|
|
798
844
|
renderMarkdown,
|
|
799
845
|
isReasoningExpanded,
|
|
@@ -802,8 +848,6 @@ export default {
|
|
|
802
848
|
copyMessageContent,
|
|
803
849
|
redoMessage,
|
|
804
850
|
editMessage,
|
|
805
|
-
saveEditedMessage,
|
|
806
|
-
cancelEdit,
|
|
807
851
|
cancelRequest,
|
|
808
852
|
configUpdated,
|
|
809
853
|
exportThreads,
|
|
@@ -817,7 +861,13 @@ export default {
|
|
|
817
861
|
humanifyMs,
|
|
818
862
|
humanifyNumber,
|
|
819
863
|
formatCost,
|
|
864
|
+
formatCost,
|
|
820
865
|
statsTitle,
|
|
866
|
+
getAttachments,
|
|
867
|
+
hasAttachments,
|
|
868
|
+
lightboxUrl,
|
|
869
|
+
openLightbox,
|
|
870
|
+
closeLightbox,
|
|
821
871
|
}
|
|
822
872
|
}
|
|
823
873
|
}
|