llms-py 3.0.7__py3-none-any.whl → 3.0.8__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.
@@ -1,7 +1,252 @@
1
1
  import { ref, computed, nextTick, watch, onMounted, onUnmounted, inject } from 'vue'
2
2
  import { useRouter, useRoute } from 'vue-router'
3
3
 
4
- const MessageUsage = {
4
+ function tryParseJson(str) {
5
+ try {
6
+ return JSON.parse(str)
7
+ } catch (e) {
8
+ return null
9
+ }
10
+ }
11
+ function hasJsonStructure(str) {
12
+ return tryParseJson(str) != null
13
+ }
14
+
15
+ export const TypeText = {
16
+ template: `
17
+ <div v-if="text.type === 'text'">
18
+ <div v-html="html"></div>
19
+ </div>
20
+ `,
21
+ props: {
22
+ text: {
23
+ type: Object,
24
+ required: true
25
+ }
26
+ },
27
+ setup(props) {
28
+ const ctx = inject('ctx')
29
+ const html = computed(() => {
30
+ try {
31
+ return ctx.fmt.markdown(props.text.text)
32
+ } catch (e) {
33
+ console.error('TypeText: markdown', e)
34
+ return `<div class="whitespace-pre-wrap">${props.text.text}</div>`
35
+ }
36
+ })
37
+ return { html }
38
+ }
39
+ }
40
+
41
+ export const LightboxImage = {
42
+ template: `
43
+ <div>
44
+ <!-- Thumbnail -->
45
+ <div
46
+ class="cursor-zoom-in hover:opacity-90 transition-opacity"
47
+ @click="isOpen = true"
48
+ >
49
+ <img
50
+ :src="src"
51
+ :alt="alt"
52
+ :width="width"
53
+ :height="height"
54
+ :class="imageClass"
55
+ />
56
+ </div>
57
+
58
+ <!-- Lightbox Modal -->
59
+ <div v-if="isOpen"
60
+ class="fixed inset-0 z-100 flex items-center justify-center bg-black/90 p-4"
61
+ @click="isOpen = false"
62
+ >
63
+ <button type="button"
64
+ class="absolute top-4 right-4 p-2 text-white hover:bg-white/10 rounded-lg transition-colors"
65
+ @click="isOpen = false"
66
+ aria-label="Close lightbox"
67
+ >
68
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
69
+ </button>
70
+ <div class="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
71
+ <img
72
+ :src="src"
73
+ :alt="alt"
74
+ :width="width"
75
+ :height="height"
76
+ class="max-w-full max-h-full w-auto h-auto object-contain rounded"
77
+ @click.stop
78
+ />
79
+ </div>
80
+ </div>
81
+ </div>
82
+ `,
83
+ props: {
84
+ src: {
85
+ type: String,
86
+ required: true
87
+ },
88
+ alt: {
89
+ type: String,
90
+ default: ''
91
+ },
92
+ width: {
93
+ type: [Number, String],
94
+ default: undefined
95
+ },
96
+ height: {
97
+ type: [Number, String],
98
+ default: undefined
99
+ },
100
+ imageClass: {
101
+ type: String,
102
+ default: '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]'
103
+ }
104
+ },
105
+ setup(props) {
106
+ const ctx = inject('ctx')
107
+ const isOpen = ref(false)
108
+
109
+ let sub
110
+ onMounted(() => {
111
+ sub = ctx.events.subscribe(`keydown:Escape`, () => isOpen.value = false)
112
+ })
113
+ onUnmounted(() => sub?.unsubscribe())
114
+
115
+ return {
116
+ isOpen
117
+ }
118
+ }
119
+ }
120
+
121
+ export const TypeImage = {
122
+ template: `
123
+ <div v-if="image.type === 'image_url'">
124
+ <LightboxImage :src="$ctx.resolveUrl(image.image_url.url)" />
125
+ </div>
126
+ `,
127
+ props: {
128
+ image: {
129
+ type: Object,
130
+ required: true
131
+ }
132
+ }
133
+ }
134
+
135
+ export const TypeAudio = {
136
+ template: `
137
+ <div v-if="audio.type === 'audio_url'">
138
+ <slot></slot>
139
+ <audio controls :src="$ctx.resolveUrl(audio.audio_url.url)" class="h-8 w-64"></audio>
140
+ </div>
141
+ `,
142
+ props: {
143
+ audio: {
144
+ type: Object,
145
+ required: true
146
+ }
147
+ }
148
+ }
149
+
150
+ export const TypeFile = {
151
+ template: `
152
+ <a v-if="file.type === 'file'" :href="$ctx.resolveUrl(file.file.file_data)" target="_blank"
153
+ 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">
154
+ <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>
155
+ <span class="max-w-xs truncate">{{ file.file.filename || 'Attachment' }}</span>
156
+ </a>
157
+ `,
158
+ props: {
159
+ file: {
160
+ type: Object,
161
+ required: true
162
+ }
163
+ }
164
+ }
165
+
166
+ export const ViewType = {
167
+ template: `
168
+ <div 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">
169
+ <TypeText v-if="result.type === 'text'" :text="result" />
170
+ <TypeImage v-else-if="result.type === 'image_url'" :image="result" />
171
+ <TypeAudio v-else-if="result.type === 'audio_url'" :audio="result" />
172
+ <TypeFile v-else-if="result.type === 'file'" :file="result" />
173
+ <div v-else>
174
+ <HtmlFormat :value="result" :classes="$utils.htmlFormatClasses" />
175
+ </div>
176
+ </div>
177
+ `,
178
+ props: {
179
+ result: {
180
+ type: Object,
181
+ required: true
182
+ }
183
+ }
184
+ }
185
+ export const ViewTypes = {
186
+ template: `
187
+ <div v-if="results?.length" class="flex flex-col gap-2">
188
+ <div v-if="texts.length > 0" :class="cls">
189
+ <div v-if="hasResources" v-for="(text, i) in texts" :key="'raw-' + i" class="text-xs whitespace-pre-wrap">{{text.text}}</div>
190
+ <TypeText v-else v-for="(text, i) in texts" :key="'text-' + i" :text="text" />
191
+ </div>
192
+ <div v-if="images.length > 0" :class="cls">
193
+ <TypeImage v-for="(image, i) in images" :key="'image-' + i" :image="image" />
194
+ </div>
195
+ <div v-if="audios.length > 0" :class="cls">
196
+ <TypeAudio v-for="(audio, i) in audios" :key="'audio-' + i" :audio="audio" />
197
+ </div>
198
+ <div v-if="files.length > 0" :class="cls">
199
+ <TypeFile v-for="(file, i) in files" :key="'file-' + i" :file="file" />
200
+ </div>
201
+ <div v-if="others.length > 0" :class="cls">
202
+ <HtmlFormat v-for="(other, i) in others" :key="'other-' + i" :value="other" :classes="$utils.htmlFormatClasses" />
203
+ </div>
204
+ </div>
205
+ `,
206
+ props: {
207
+ results: {
208
+ type: Array,
209
+ required: true
210
+ }
211
+ },
212
+ setup(props) {
213
+ const cls = "flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
214
+ const texts = computed(() => props.results.filter(r => r.type === 'text'))
215
+ const images = computed(() => props.results.filter(r => r.type === 'image_url'))
216
+ const audios = computed(() => props.results.filter(r => r.type === 'audio_url'))
217
+ const files = computed(() => props.results.filter(r => r.type === 'file'))
218
+ const others = computed(() => props.results.filter(r => r.type !== 'text' && r.type !== 'image_url' && r.type !== 'audio_url' && r.type !== 'file'))
219
+ // If has resources, render as plain-text to avoid rendering resources multiple times
220
+ const hasResources = computed(() => images.value.length > 0 || audios.value.length > 0 || files.value.length > 0 || others.value.length > 0)
221
+ return { cls, texts, images, audios, files, others, hasResources }
222
+ }
223
+ }
224
+ export const ViewToolTypes = {
225
+ template: `<ViewTypes v-if="results?.length" :results="results" />`,
226
+ props: {
227
+ output: Object,
228
+ },
229
+ setup(props) {
230
+ const results = computed(() => {
231
+ const ret = []
232
+ if (!props.output) return ret
233
+ if (props.output.images) {
234
+ ret.push(...props.output.images)
235
+ }
236
+ if (props.output.audios) {
237
+ ret.push(...props.output.audios)
238
+ }
239
+ if (props.output.files) {
240
+ ret.push(...props.output.files)
241
+ }
242
+ return ret
243
+ })
244
+ return { results }
245
+ }
246
+ }
247
+
248
+
249
+ export const MessageUsage = {
5
250
  template: `
6
251
  <div class="mt-2 text-xs opacity-70">
7
252
  <span v-if="message.model" @click="$chat.setSelectedModel({ name: message.model })" title="Select model"><span class="cursor-pointer hover:underline">{{ message.model }}</span> &#8226; </span>
@@ -20,14 +265,14 @@ const MessageUsage = {
20
265
  }
21
266
  }
22
267
 
23
- const MessageReasoning = {
268
+ export const MessageReasoning = {
24
269
  template: `
25
270
  <div class="mt-2 mb-2">
26
- <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">
27
- <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>
28
- <span>{{ isReasoningExpanded(message.id) ? 'Hide reasoning' : 'Show reasoning' }}</span>
271
+ <button type="button" @click="toggleReasoning(message.timestamp)" 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">
272
+ <svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" :class="isReasoningExpanded(message.timestamp) ? 'transform rotate-90' : ''"><path fill="currentColor" d="M7 5l6 5l-6 5z"/></svg>
273
+ <span>{{ isReasoningExpanded(message.timestamp) ? 'Hide reasoning' : 'Show reasoning' }}</span>
29
274
  </button>
30
- <div v-if="isReasoningExpanded(message.id)" class="reasoning mt-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-2">
275
+ <div v-if="isReasoningExpanded(message.timestamp)" class="reasoning mt-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-2">
31
276
  <div v-if="typeof reasoning === 'string'" v-html="$fmt.markdown(reasoning)" class="prose prose-xs max-w-none dark:prose-invert"></div>
32
277
  <pre v-else class="text-xs whitespace-pre-wrap overflow-x-auto">{{ formatReasoning(reasoning) }}</pre>
33
278
  </div>
@@ -60,11 +305,125 @@ const MessageReasoning = {
60
305
  }
61
306
  }
62
307
 
63
- export default {
64
- components: {
65
- MessageUsage,
66
- MessageReasoning,
308
+ export const ToolArguments = {
309
+ template: `
310
+ <div ref="refArgs" v-if="dict" class="not-prose">
311
+ <div class="prose html-format">
312
+ <table class="table-object border-none">
313
+ <tr v-for="(v, k) in dict" :key="k">
314
+ <td class="align-top py-2 px-4 text-left text-sm font-medium tracking-wider whitespace-nowrap lowercase">{{ k }}</td>
315
+ <td v-if="$utils.isHtml(v)" style="margin:0;padding:0;width:100%">
316
+ <div v-html="embedHtml(v)" class="w-full h-full"></div>
317
+ </td>
318
+ <td v-else class="align-top py-2 px-4 text-sm whitespace-pre-wrap">
319
+ <HtmlFormat :value="v" :classes="$utils.htmlFormatClasses" />
320
+ </td>
321
+ </tr>
322
+ </table>
323
+ </div>
324
+ </div>
325
+ <div v-else-if="list" class="not-prose px-3 py-2">
326
+ <HtmlFormat :value="list" :classes="$utils.htmlFormatClasses" />
327
+ </div>
328
+ <pre v-else-if="!isEmpty(value)" class="tool-arguments">{{ value }}</pre>
329
+ `,
330
+ props: {
331
+ value: String,
67
332
  },
333
+ setup(props) {
334
+ const refArgs = ref()
335
+ function isEmpty(v) {
336
+ return !v || v === '{}' || v === '[]' || v === 'null' || v === 'undefined' || v === '""' || v === "''" || v === "``"
337
+ }
338
+ function embedHtml(html) {
339
+ const resizeScript = `<script>
340
+ let lastH = 0;
341
+ const sendHeight = () => {
342
+ const body = document.body;
343
+ if (!body) return;
344
+ // Force re-calc
345
+ const h = document.documentElement.getBoundingClientRect().height;
346
+ if (Math.abs(h - lastH) > 2) {
347
+ lastH = h;
348
+ window.parent.postMessage({ type: 'iframe-resize', height: h }, '*');
349
+ }
350
+ }
351
+ const ro = new ResizeObserver(sendHeight);
352
+ window.addEventListener('load', () => {
353
+ // Inject styles to prevent infinite loops
354
+ const style = document.createElement('style');
355
+ style.textContent = 'html, body { height: auto !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; }';
356
+ document.head.appendChild(style);
357
+
358
+ const body = document.body;
359
+ if (body) {
360
+ ro.observe(body);
361
+ ro.observe(document.documentElement);
362
+ sendHeight();
363
+ }
364
+ });
365
+ <\/script>`
366
+
367
+ const escaped = (html + resizeScript)
368
+ .replace(/&/g, '&amp;')
369
+ .replace(/</g, '&lt;')
370
+ .replace(/>/g, '&gt;')
371
+ .replace(/"/g, '&quot;')
372
+ .replace(/'/g, '&#39;')
373
+ return `<iframe srcdoc="${escaped}" sandbox="allow-scripts" style="width:100%;height:auto;border:none;"></iframe>`
374
+ }
375
+ const dict = computed(() => {
376
+ if (isEmpty(props.value)) return null
377
+ const ret = tryParseJson(props.value)
378
+ return typeof ret === 'object' && !Array.isArray(ret) ? ret : null
379
+ })
380
+ const list = computed(() => {
381
+ if (isEmpty(props.value)) return null
382
+ const ret = tryParseJson(props.value)
383
+ return Array.isArray(ret) && ret.length > 0 ? ret : null
384
+ })
385
+
386
+ const handleMessage = (event) => {
387
+ if (event.data?.type === 'iframe-resize' && typeof event.data.height === 'number') {
388
+ const iframes = refArgs.value?.querySelectorAll('iframe')
389
+ iframes?.forEach(iframe => {
390
+ if (iframe.contentWindow === event.source) {
391
+ iframe.style.height = (event.data.height + 30) + 'px'
392
+ }
393
+ })
394
+ }
395
+ }
396
+
397
+ onMounted(() => {
398
+ window.addEventListener('message', handleMessage)
399
+ const hasIframes = refArgs.value?.querySelector('iframe')
400
+ if (hasIframes) {
401
+ refArgs.value.classList.add('has-iframes')
402
+ }
403
+ })
404
+
405
+ onUnmounted(() => {
406
+ window.removeEventListener('message', handleMessage)
407
+ })
408
+
409
+ return {
410
+ refArgs,
411
+ dict,
412
+ list,
413
+ isEmpty,
414
+ embedHtml,
415
+ }
416
+ }
417
+ }
418
+
419
+ export const ToolOutput = {
420
+ template: ``,
421
+ setup(props) {
422
+
423
+ }
424
+ }
425
+
426
+ export const ChatBody = {
68
427
  template: `
69
428
  <div class="flex flex-col h-full">
70
429
  <!-- Messages Area -->
@@ -87,7 +446,7 @@ export default {
87
446
  <div class="space-y-2" v-if="currentThread?.messages?.length">
88
447
  <div
89
448
  v-for="message in currentThread.messages.filter(x => x.role !== 'system')"
90
- :key="message.id"
449
+ :key="message.timestamp"
91
450
  v-show="!(message.role === 'tool' && isToolLinked(message))"
92
451
  class="flex items-start space-x-3 group"
93
452
  :class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
@@ -109,7 +468,7 @@ export default {
109
468
  </div>
110
469
 
111
470
  <!-- Delete button (shown on hover) -->
112
- <button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.id)"
471
+ <button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.timestamp)"
113
472
  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"
114
473
  title="Delete message">
115
474
  <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -162,11 +521,7 @@ export default {
162
521
  <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Tool Call</span>
163
522
  </div>
164
523
 
165
- <!-- Arguments -->
166
- <div v-if="tool.function.arguments && tool.function.arguments != '{}'" class="not-prose px-3 py-2">
167
- <HtmlFormat v-if="hasJsonStructure(tool.function.arguments)" :value="tryParseJson(tool.function.arguments)" :classes="customHtmlClasses" />
168
- <pre v-else class="tool-arguments">{{ tool.function.arguments }}</pre>
169
- </div>
524
+ <ToolArguments :value="tool.function.arguments" />
170
525
 
171
526
  <!-- Tool Output (Nested) -->
172
527
  <div v-if="getToolOutput(tool.id)" class="border-t border-gray-200 dark:border-gray-700">
@@ -192,10 +547,11 @@ export default {
192
547
  <div class="not-prose px-3 py-2">
193
548
  <pre v-if="prefs.toolFormat !== 'preview' || !hasJsonStructure(getToolOutput(tool.id).content)" class="tool-output">{{ getToolOutput(tool.id).content }}</pre>
194
549
  <div v-else class="text-xs">
195
- <HtmlFormat v-if="tryParseJson(getToolOutput(tool.id).content)" :value="tryParseJson(getToolOutput(tool.id).content)" :classes="customHtmlClasses" />
550
+ <HtmlFormat v-if="tryParseJson(getToolOutput(tool.id).content)" :value="tryParseJson(getToolOutput(tool.id).content)" :classes="$utils.htmlFormatClasses" />
196
551
  <div v-else class="text-gray-500 italic p-2">Invalid JSON content</div>
197
552
  </div>
198
553
  </div>
554
+ <ViewToolTypes :output="getToolOutput(tool.id)" />
199
555
  </div>
200
556
  </div>
201
557
  </div>
@@ -222,45 +578,24 @@ export default {
222
578
  <!-- Assistant Images -->
223
579
  <div v-if="message.images && message.images.length > 0" class="mt-2 flex flex-wrap gap-2">
224
580
  <template v-for="(img, i) in message.images" :key="i">
225
- <div v-if="img.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(resolveUrl(img.image_url.url))">
226
- <img :src="resolveUrl(img.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]" />
227
- </div>
581
+ <TypeImage v-if="img.type === 'image_url'" :image="img" />
228
582
  </template>
229
583
  </div>
230
584
 
231
585
  <!-- Assistant Audios -->
232
586
  <div v-if="message.audios && message.audios.length > 0" class="mt-2 flex flex-wrap gap-2">
233
587
  <template v-for="(audio, i) in message.audios" :key="i">
234
- <div v-if="audio.type === 'audio_url'" 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">
235
- <audio controls :src="resolveUrl(audio.audio_url.url)" class="h-8 w-64"></audio>
236
- </div>
588
+ <TypeAudio v-if="audio.type === 'audio_url'" :audio="audio"
589
+ 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">
590
+ <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>
591
+ </TypeAudio>
237
592
  </template>
238
593
  </div>
239
594
 
240
595
  <!-- User Message with separate attachments -->
241
596
  <div v-else-if="message.role !== 'assistant' && message.role !== 'tool'">
242
- <div v-html="$fmt.markdown(message.content)" class="prose prose-sm max-w-none dark:prose-invert break-words"></div>
243
-
244
- <!-- Attachments Grid -->
245
- <div v-if="hasAttachments(message)" class="mt-2 flex flex-wrap gap-2">
246
- <template v-for="(part, i) in getAttachments(message)" :key="i">
247
- <!-- Image -->
248
- <div v-if="part.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(part.image_url.url)">
249
- <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]" />
250
- </div>
251
- <!-- Audio -->
252
- <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">
253
- <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>
254
- <audio controls :src="part.input_audio.data" class="h-8 w-48"></audio>
255
- </div>
256
- <!-- File -->
257
- <a v-else-if="part.type === 'file'" :href="part.file.file_data" target="_blank"
258
- 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">
259
- <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>
260
- <span class="max-w-xs truncate">{{ part.file.filename || 'Attachment' }}</span>
261
- </a>
262
- </template>
263
- </div>
597
+ <div v-html="$fmt.content(message.content)" class="prose prose-sm max-w-none dark:prose-invert break-words"></div>
598
+ <ViewTypes :results="getAttachments(message)" />
264
599
  </div>
265
600
 
266
601
  <MessageUsage :message="message" :usage="getMessageUsage(message)" />
@@ -377,21 +712,6 @@ export default {
377
712
  <div v-if="$ai.hasAccess" :class="$ctx.cls('chat-input', 'flex-shrink-0 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-6 py-4')">
378
713
  <ChatPrompt :model="$chat.getSelectedModel()" />
379
714
  </div>
380
-
381
- <!-- Lightbox -->
382
- <div v-if="lightboxUrl" class="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4 cursor-pointer"
383
- @click="closeLightbox">
384
- <button type="button" @click="closeLightbox"
385
- class="absolute top-4 right-4 text-white/70 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors z-[101]"
386
- title="Close">
387
- <svg class="size-8" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
388
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
389
- </svg>
390
- </button>
391
- <div class="relative max-w-full max-h-full">
392
- <img :src="lightboxUrl" class="max-w-full max-h-[90vh] object-contain rounded-sm shadow-2xl" @click.stop />
393
- </div>
394
- </div>
395
715
  </div>
396
716
  `,
397
717
  setup() {
@@ -414,14 +734,6 @@ export default {
414
734
  })
415
735
  const messagesContainer = ref(null)
416
736
  const copying = ref(null)
417
- const lightboxUrl = ref(null)
418
-
419
- const openLightbox = (url) => {
420
- lightboxUrl.value = url
421
- }
422
- const closeLightbox = () => {
423
- lightboxUrl.value = null
424
- }
425
737
 
426
738
  const resolveUrl = (url) => {
427
739
  if (url && url.startsWith('~')) {
@@ -595,10 +907,7 @@ export default {
595
907
  }
596
908
 
597
909
  let sub
598
- onMounted(() => {
599
- sub = ctx.events.subscribe(`keydown:Escape`, closeLightbox)
600
- setTimeout(ctx.chat.addCopyButtons, 1)
601
- })
910
+ onMounted(() => setTimeout(ctx.chat.addCopyButtons, 1))
602
911
  onUnmounted(() => sub?.unsubscribe())
603
912
 
604
913
  const getToolOutput = (toolCallId) => {
@@ -624,34 +933,6 @@ export default {
624
933
  return currentThread.value?.messages?.some(m => m.role === 'assistant' && m.tool_calls?.some(tc => tc.id === message.tool_call_id))
625
934
  }
626
935
 
627
- const tryParseJson = (str) => {
628
- try {
629
- return JSON.parse(str)
630
- } catch (e) {
631
- return null
632
- }
633
- }
634
- const hasJsonStructure = (str) => {
635
- return tryParseJson(str) != null
636
- }
637
- /**
638
- * @param {object|array} type
639
- * @param {'div'|'table'|'thead'|'th'|'tr'|'td'} tag
640
- * @param {number} depth
641
- * @param {string} cls
642
- * @param {number} index
643
- */
644
- const customHtmlClasses = (type, tag, depth, cls, index) => {
645
- cls = cls.replace('shadow ring-1 ring-black/5 md:rounded-lg', '')
646
- if (tag == 'th') {
647
- cls += ' lowercase'
648
- }
649
- if (tag == 'td') {
650
- cls += ' whitespace-pre-wrap'
651
- }
652
- return cls
653
- }
654
-
655
936
  function setPrefs(o) {
656
937
  Object.assign(prefs.value, o)
657
938
  ctx.setPrefs(prefs.value)
@@ -673,16 +954,12 @@ export default {
673
954
  configUpdated,
674
955
  getAttachments,
675
956
  hasAttachments,
676
- lightboxUrl,
677
- openLightbox,
678
- closeLightbox,
679
957
  resolveUrl,
680
958
  getMessageUsage,
681
959
  getToolOutput,
682
960
  isToolLinked,
683
961
  tryParseJson,
684
962
  hasJsonStructure,
685
- customHtmlClasses,
686
963
  }
687
964
  }
688
965
  }
@@ -1,8 +1,8 @@
1
1
 
2
2
  import { ref, watch, computed, nextTick, inject } from 'vue'
3
- import { $$, createElement, lastRightPart, ApiResult, createErrorStatus, pick } from "@servicestack/client"
3
+ import { $$, createElement, lastRightPart, ApiResult, createErrorStatus } from "@servicestack/client"
4
4
  import SettingsDialog, { useSettings } from './SettingsDialog.mjs'
5
- import ChatBody from './ChatBody.mjs'
5
+ import { ChatBody, LightboxImage, TypeText, TypeImage, TypeAudio, TypeFile, ViewType, ViewTypes, ViewToolTypes, ToolArguments, ToolOutput, MessageUsage, MessageReasoning } from './ChatBody.mjs'
6
6
  import { AppContext } from '../../ctx.mjs'
7
7
 
8
8
  const imageExts = 'png,webp,jpg,jpeg,gif,bmp,svg,tiff,ico'.split(',')
@@ -923,7 +923,21 @@ export default {
923
923
  ctx.components({
924
924
  SettingsDialog,
925
925
  ChatPrompt,
926
+
926
927
  ChatBody,
928
+ MessageUsage,
929
+ MessageReasoning,
930
+ LightboxImage,
931
+ TypeText,
932
+ TypeImage,
933
+ TypeAudio,
934
+ TypeFile,
935
+ ViewType,
936
+ ViewTypes,
937
+ ViewToolTypes,
938
+ ToolArguments,
939
+ ToolOutput,
940
+
927
941
  HomeTools,
928
942
  Home,
929
943
  ThreadHeader,