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