@8wave/ai-elements 0.119.0-beta.45 → 0.119.0-beta.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/_chunks/{Agent-Gdgpd3LA.js → Agent-DU38SnNd.js} +411 -247
  2. package/dist/_chunks/Agent-DU38SnNd.js.map +1 -0
  3. package/dist/_chunks/{PkChatbotFilePreview-3i9jZIdS.js → PkChatbotFilePreview-Bdi4_IFu.js} +2 -2
  4. package/dist/_chunks/PkChatbotFilePreview-Bdi4_IFu.js.map +1 -0
  5. package/dist/_chunks/{PkStreamingMarkdown-B1t1tBvW.js → PkStreamingMarkdown-xnhBbPf5.js} +3 -3
  6. package/dist/_chunks/{PkStreamingMarkdown-B1t1tBvW.js.map → PkStreamingMarkdown-xnhBbPf5.js.map} +1 -1
  7. package/dist/_chunks/{PkToolEditSpreadsheet-Xzum_9NH.js → PkToolEditSpreadsheet-Ct9pdQJ1.js} +3 -3
  8. package/dist/_chunks/{PkToolEditSpreadsheet-Xzum_9NH.js.map → PkToolEditSpreadsheet-Ct9pdQJ1.js.map} +1 -1
  9. package/dist/_chunks/{PkToolRequestGeolocation-PxhQxMP1.js → PkToolRequestGeolocation-DzhrY8bg.js} +2 -2
  10. package/dist/_chunks/{PkToolRequestGeolocation-PxhQxMP1.js.map → PkToolRequestGeolocation-DzhrY8bg.js.map} +1 -1
  11. package/dist/_chunks/{PkToolRequestOAuthConnection-W3ilzpr7.js → PkToolRequestOAuthConnection-CzRKRGoG.js} +2 -2
  12. package/dist/_chunks/{PkToolRequestOAuthConnection-W3ilzpr7.js.map → PkToolRequestOAuthConnection-CzRKRGoG.js.map} +1 -1
  13. package/dist/_chunks/{PkToolShowArtifact-DGGEb2LF.js → PkToolShowArtifact-DPt1qDUk.js} +2 -2
  14. package/dist/_chunks/{PkToolShowArtifact-DGGEb2LF.js.map → PkToolShowArtifact-DPt1qDUk.js.map} +1 -1
  15. package/dist/_chunks/{PkToolShowContactForm-Dn6CEXF_.js → PkToolShowContactForm-OdC7bsXd.js} +2 -2
  16. package/dist/_chunks/{PkToolShowContactForm-Dn6CEXF_.js.map → PkToolShowContactForm-OdC7bsXd.js.map} +1 -1
  17. package/dist/_chunks/{PkToolShowForm-CbuToc8O.js → PkToolShowForm-uFmaQoI1.js} +3 -3
  18. package/dist/_chunks/{PkToolShowForm-CbuToc8O.js.map → PkToolShowForm-uFmaQoI1.js.map} +1 -1
  19. package/dist/_chunks/{PkToolShowImageGallery-Dh0v2Df8.js → PkToolShowImageGallery-KmMO4UuQ.js} +2 -2
  20. package/dist/_chunks/{PkToolShowImageGallery-Dh0v2Df8.js.map → PkToolShowImageGallery-KmMO4UuQ.js.map} +1 -1
  21. package/dist/_chunks/{PkToolShowProductList-bAis8u1t.js → PkToolShowProductList-cld-FOFb.js} +2 -2
  22. package/dist/_chunks/{PkToolShowProductList-bAis8u1t.js.map → PkToolShowProductList-cld-FOFb.js.map} +1 -1
  23. package/dist/_chunks/{PkToolShowSources-B1zXilsH.js → PkToolShowSources-BlpCKc4N.js} +3 -3
  24. package/dist/_chunks/{PkToolShowSources-B1zXilsH.js.map → PkToolShowSources-BlpCKc4N.js.map} +1 -1
  25. package/dist/_chunks/{PkToolShowSuggestedReply-BISQY1ce.js → PkToolShowSuggestedReply-xUBuqStM.js} +2 -2
  26. package/dist/_chunks/{PkToolShowSuggestedReply-BISQY1ce.js.map → PkToolShowSuggestedReply-xUBuqStM.js.map} +1 -1
  27. package/dist/_chunks/{PkToolShowVideo-DcMzLibC.js → PkToolShowVideo-KDNxItWl.js} +2 -2
  28. package/dist/_chunks/{PkToolShowVideo-DcMzLibC.js.map → PkToolShowVideo-KDNxItWl.js.map} +1 -1
  29. package/dist/_chunks/{PkToolShowWebPages-RuwkZDMH.js → PkToolShowWebPages-D35B7GzS.js} +2 -2
  30. package/dist/_chunks/{PkToolShowWebPages-RuwkZDMH.js.map → PkToolShowWebPages-D35B7GzS.js.map} +1 -1
  31. package/dist/_chunks/{chatImagePolicy-BL4O14e9.js → chatImagePolicy-B_6Xg-r7.js} +2 -2
  32. package/dist/_chunks/{chatImagePolicy-BL4O14e9.js.map → chatImagePolicy-B_6Xg-r7.js.map} +1 -1
  33. package/dist/_chunks/{markdown-DtdG9Krq.js → markdown-BKLfFJPr.js} +2 -2
  34. package/dist/_chunks/{markdown-DtdG9Krq.js.map → markdown-BKLfFJPr.js.map} +1 -1
  35. package/dist/ai-elements.es.js +2067 -2064
  36. package/dist/ai-elements.es.js.map +1 -1
  37. package/dist-vue/PkChatbot.js +1 -1
  38. package/dist-vue/PkChatbotAuth.js +1 -1
  39. package/dist-vue/PkChatbotEmptyState.js +1 -1
  40. package/dist-vue/PkChatbotFilePreview.js +1 -1
  41. package/dist-vue/PkChatbotInput.js +1 -1
  42. package/dist-vue/PkChatbotMessages.js +1 -1
  43. package/dist-vue/PkChatbotStepPayload.js +1 -1
  44. package/dist-vue/PkChatbotStepToolDetail.js +1 -1
  45. package/dist-vue/PkChatbotSteps.js +1 -1
  46. package/dist-vue/PkChatbotViewChat.js +1 -1
  47. package/dist-vue/PkChatbotViewConversations.js +1 -1
  48. package/dist-vue/PkChatbotViewFiles.js +1 -1
  49. package/dist-vue/PkChatbotViewProfile.js +1 -1
  50. package/dist-vue/_chunks/{Agent-DGCZCx4Y.js → Agent-DDm9cCaN.js} +2 -2
  51. package/dist-vue/_chunks/{Agent-DGCZCx4Y.js.map → Agent-DDm9cCaN.js.map} +1 -1
  52. package/dist-vue/_chunks/{Document-BZea8ewV.js → Document-CZemm1AR.js} +11 -11
  53. package/dist-vue/_chunks/{Document-BZea8ewV.js.map → Document-CZemm1AR.js.map} +1 -1
  54. package/dist-vue/_chunks/Media-D-UGzdcW.js +301 -0
  55. package/dist-vue/_chunks/Media-D-UGzdcW.js.map +1 -0
  56. package/dist-vue/_chunks/{PkChatbot-BVWLvaXb.js → PkChatbot-DXwMfwN4.js} +8 -8
  57. package/dist-vue/_chunks/{PkChatbot-BVWLvaXb.js.map → PkChatbot-DXwMfwN4.js.map} +1 -1
  58. package/dist-vue/_chunks/{PkChatbotAuth-BVZzjbK9.js → PkChatbotAuth-CHoEXxmg.js} +8 -8
  59. package/dist-vue/_chunks/{PkChatbotAuth-BVZzjbK9.js.map → PkChatbotAuth-CHoEXxmg.js.map} +1 -1
  60. package/dist-vue/_chunks/{PkChatbotEmptyState-B7fZ51v1.js → PkChatbotEmptyState-Dve5Ci2Y.js} +2 -2
  61. package/dist-vue/_chunks/{PkChatbotEmptyState-B7fZ51v1.js.map → PkChatbotEmptyState-Dve5Ci2Y.js.map} +1 -1
  62. package/dist-vue/_chunks/{PkChatbotFilePreview-Lxgu89Hg.js → PkChatbotFilePreview-D8XyjDPJ.js} +2 -2
  63. package/dist-vue/_chunks/PkChatbotFilePreview-D8XyjDPJ.js.map +1 -0
  64. package/dist-vue/_chunks/PkChatbotInput-DiCxEime.js +392 -0
  65. package/dist-vue/_chunks/PkChatbotInput-DiCxEime.js.map +1 -0
  66. package/dist-vue/_chunks/{PkChatbotMessages-DHZNG6YM.js → PkChatbotMessages-CTp0NkY2.js} +19 -19
  67. package/dist-vue/_chunks/{PkChatbotMessages-DHZNG6YM.js.map → PkChatbotMessages-CTp0NkY2.js.map} +1 -1
  68. package/dist-vue/_chunks/{PkChatbotStepPayload-DuI9ZYqp.js → PkChatbotStepPayload-B1o2DbQz.js} +2 -2
  69. package/dist-vue/_chunks/{PkChatbotStepPayload-DuI9ZYqp.js.map → PkChatbotStepPayload-B1o2DbQz.js.map} +1 -1
  70. package/dist-vue/_chunks/{PkChatbotStepToolDetail-B9hBMl15.js → PkChatbotStepToolDetail-DpYV0KLn.js} +3 -3
  71. package/dist-vue/_chunks/{PkChatbotStepToolDetail-B9hBMl15.js.map → PkChatbotStepToolDetail-DpYV0KLn.js.map} +1 -1
  72. package/dist-vue/_chunks/{PkChatbotSteps-3qySSEYh.js → PkChatbotSteps-BPon4zjv.js} +5 -5
  73. package/dist-vue/_chunks/{PkChatbotSteps-3qySSEYh.js.map → PkChatbotSteps-BPon4zjv.js.map} +1 -1
  74. package/dist-vue/_chunks/{PkChatbotViewChat-CLyoZH3C.js → PkChatbotViewChat-dJE2iHPN.js} +237 -236
  75. package/dist-vue/_chunks/PkChatbotViewChat-dJE2iHPN.js.map +1 -0
  76. package/dist-vue/_chunks/{PkChatbotViewConversations-BbXFoEOv.js → PkChatbotViewConversations-DcEcJgwV.js} +3 -3
  77. package/dist-vue/_chunks/{PkChatbotViewConversations-BbXFoEOv.js.map → PkChatbotViewConversations-DcEcJgwV.js.map} +1 -1
  78. package/dist-vue/_chunks/{PkChatbotViewFiles-7vcERhnZ.js → PkChatbotViewFiles-DDEJqXct.js} +3 -3
  79. package/dist-vue/_chunks/{PkChatbotViewFiles-7vcERhnZ.js.map → PkChatbotViewFiles-DDEJqXct.js.map} +1 -1
  80. package/dist-vue/_chunks/{PkChatbotViewProfile-RgFp-LpV.js → PkChatbotViewProfile-B79UynQ8.js} +2 -2
  81. package/dist-vue/_chunks/{PkChatbotViewProfile-RgFp-LpV.js.map → PkChatbotViewProfile-B79UynQ8.js.map} +1 -1
  82. package/dist-vue/_chunks/{PkStreamingMarkdown-Bmm1kZpL.js → PkStreamingMarkdown-CqT_orLj.js} +3 -3
  83. package/dist-vue/_chunks/{PkStreamingMarkdown-Bmm1kZpL.js.map → PkStreamingMarkdown-CqT_orLj.js.map} +1 -1
  84. package/dist-vue/_chunks/{PkSvgViewer-DU8K_gAD.js → PkSvgViewer-CZASatr4.js} +2 -2
  85. package/dist-vue/_chunks/{PkSvgViewer-DU8K_gAD.js.map → PkSvgViewer-CZASatr4.js.map} +1 -1
  86. package/dist-vue/_chunks/{PkToolEditSpreadsheet-BIQky1RK.js → PkToolEditSpreadsheet-Dcp3VFzY.js} +3 -3
  87. package/dist-vue/_chunks/{PkToolEditSpreadsheet-BIQky1RK.js.map → PkToolEditSpreadsheet-Dcp3VFzY.js.map} +1 -1
  88. package/dist-vue/_chunks/{PkToolRequestGeolocation-CYEyXZSh.js → PkToolRequestGeolocation-DG4-qKRq.js} +2 -2
  89. package/dist-vue/_chunks/{PkToolRequestGeolocation-CYEyXZSh.js.map → PkToolRequestGeolocation-DG4-qKRq.js.map} +1 -1
  90. package/dist-vue/_chunks/{PkToolRequestOAuthConnection-B243GJ1-.js → PkToolRequestOAuthConnection-CZhNEM-o.js} +2 -2
  91. package/dist-vue/_chunks/{PkToolRequestOAuthConnection-B243GJ1-.js.map → PkToolRequestOAuthConnection-CZhNEM-o.js.map} +1 -1
  92. package/dist-vue/_chunks/{PkToolShowArtifact-CJG-B9mE.js → PkToolShowArtifact-Qfkz64NY.js} +2 -2
  93. package/dist-vue/_chunks/{PkToolShowArtifact-CJG-B9mE.js.map → PkToolShowArtifact-Qfkz64NY.js.map} +1 -1
  94. package/dist-vue/_chunks/{PkToolShowContactForm-CG1H7xBz.js → PkToolShowContactForm-CRLEkYSE.js} +2 -2
  95. package/dist-vue/_chunks/{PkToolShowContactForm-CG1H7xBz.js.map → PkToolShowContactForm-CRLEkYSE.js.map} +1 -1
  96. package/dist-vue/_chunks/{PkToolShowDiagram-Cq0457a3.js → PkToolShowDiagram-j4FJ34Ta.js} +3 -3
  97. package/dist-vue/_chunks/{PkToolShowDiagram-Cq0457a3.js.map → PkToolShowDiagram-j4FJ34Ta.js.map} +1 -1
  98. package/dist-vue/_chunks/{PkToolShowForm-IJ8B8_yT.js → PkToolShowForm-DdFBwLMf.js} +4 -4
  99. package/dist-vue/_chunks/{PkToolShowForm-IJ8B8_yT.js.map → PkToolShowForm-DdFBwLMf.js.map} +1 -1
  100. package/dist-vue/_chunks/{PkToolShowImageGallery-ala8nlAj.js → PkToolShowImageGallery-CxlOwuTJ.js} +3 -3
  101. package/dist-vue/_chunks/{PkToolShowImageGallery-ala8nlAj.js.map → PkToolShowImageGallery-CxlOwuTJ.js.map} +1 -1
  102. package/dist-vue/_chunks/{PkToolShowProductList-BDg84ZQN.js → PkToolShowProductList-dfd3vmhl.js} +2 -2
  103. package/dist-vue/_chunks/{PkToolShowProductList-BDg84ZQN.js.map → PkToolShowProductList-dfd3vmhl.js.map} +1 -1
  104. package/dist-vue/_chunks/{PkToolShowSources-DaSijN_L.js → PkToolShowSources-B8Euk3rp.js} +3 -3
  105. package/dist-vue/_chunks/{PkToolShowSources-DaSijN_L.js.map → PkToolShowSources-B8Euk3rp.js.map} +1 -1
  106. package/dist-vue/_chunks/{PkToolShowSuggestedReply-CLEFPrHP.js → PkToolShowSuggestedReply-Cl3Pswi4.js} +2 -2
  107. package/dist-vue/_chunks/{PkToolShowSuggestedReply-CLEFPrHP.js.map → PkToolShowSuggestedReply-Cl3Pswi4.js.map} +1 -1
  108. package/dist-vue/_chunks/{PkToolShowVideo-FmE-IRE7.js → PkToolShowVideo-C84UrvZ7.js} +2 -2
  109. package/dist-vue/_chunks/{PkToolShowVideo-FmE-IRE7.js.map → PkToolShowVideo-C84UrvZ7.js.map} +1 -1
  110. package/dist-vue/_chunks/{PkToolShowWebPages-B554-MlD.js → PkToolShowWebPages-CzzuVQqL.js} +2 -2
  111. package/dist-vue/_chunks/{PkToolShowWebPages-B554-MlD.js.map → PkToolShowWebPages-CzzuVQqL.js.map} +1 -1
  112. package/dist-vue/_chunks/{createChatbotApiClient-CDS3C4i6.js → createChatbotApiClient-DrUsRvCw.js} +3 -3
  113. package/dist-vue/_chunks/{createChatbotApiClient-CDS3C4i6.js.map → createChatbotApiClient-DrUsRvCw.js.map} +1 -1
  114. package/dist-vue/_chunks/{useChatbotStore-CBjBe7GI.js → useChatbotStore-DHmjHPR5.js} +3 -3
  115. package/dist-vue/_chunks/{useChatbotStore-CBjBe7GI.js.map → useChatbotStore-DHmjHPR5.js.map} +1 -1
  116. package/dist-vue/_chunks/{utils-Cvijhryt.js → utils-7oPvmwwu.js} +2 -2
  117. package/dist-vue/_chunks/{utils-Cvijhryt.js.map → utils-7oPvmwwu.js.map} +1 -1
  118. package/dist-vue/api.js +1 -1
  119. package/dist-vue/composables.js +5 -5
  120. package/dist-vue/composables.js.map +1 -1
  121. package/dist-vue/index.js +5192 -5187
  122. package/dist-vue/index.js.map +1 -1
  123. package/dist-vue/locales.js +2 -0
  124. package/dist-vue/packages/models/src/schema/Media.d.ts +34 -0
  125. package/package.json +1 -1
  126. package/dist/_chunks/Agent-Gdgpd3LA.js.map +0 -1
  127. package/dist/_chunks/PkChatbotFilePreview-3i9jZIdS.js.map +0 -1
  128. package/dist-vue/_chunks/Media-C6RS-s98.js +0 -139
  129. package/dist-vue/_chunks/Media-C6RS-s98.js.map +0 -1
  130. package/dist-vue/_chunks/PkChatbotFilePreview-Lxgu89Hg.js.map +0 -1
  131. package/dist-vue/_chunks/PkChatbotInput-Bo4Ewf6A.js +0 -388
  132. package/dist-vue/_chunks/PkChatbotInput-Bo4Ewf6A.js.map +0 -1
  133. package/dist-vue/_chunks/PkChatbotViewChat-CLyoZH3C.js.map +0 -1
@@ -1 +0,0 @@
1
- {"version":3,"file":"PkChatbotFilePreview-3i9jZIdS.js","names":["$t","$emit","__unplugin_components_0","_openBlock","_createElementBlock","_normalizeClass","_createElementVNode","_withDirectives","_vShow","_createVNode","_createCommentVNode","_toDisplayString","_createTextVNode","_Fragment","_withModifiers","_normalizeStyle","_createBlock","_sfc_main","_style_0","defineComponent","init_shared_esm_bundler","toDisplayString","normalizeStyle","onMounted","openBlock","init_runtime_core_esm_bundler","createCommentVNode","createTextVNode","vShow","Fragment","withDirectives","createBlock","createBaseVNode","init_reactivity_esm_bundler","computed","ref","init_runtime_dom_esm_bundler","withModifiers","createElementBlock","normalizeClass","Ae","_plugin_vue_export_helper_default","useLightbox","_hoisted_1","_hoisted_2","_hoisted_3","_hoisted_4","_hoisted_5","_hoisted_6","_hoisted_7","_hoisted_8","_hoisted_9","_hoisted_10","_hoisted_11","_hoisted_12","_hoisted_13","_hoisted_14","_hoisted_15","THUMB_CACHE_MAX","PkChatbotFilePreview_default","props","__props","isImage","extension","name","dot","subtype","KINDS","fileKind","haystack","test","kind","typeTag","formattedSize","bytes","units","value","unitIndex","thumbCache","setThumbCache","url","entry","cached","thumbStatus","dimensions","onImageLoad","img","event","onImageError","pswpSize","DEFAULT_PSWP_SIZE","imageMeta","docMeta","galleryRef","init","_component_VvIcon","_ctx","createVNode","_cache"],"sources":["../../../../packages/components/src/chat/PkChatbotFilePreview.vue","../../../../packages/components/src/chat/PkChatbotFilePreview.vue","../../../../packages/components/src/chat/PkChatbotFilePreview.vue"],"sourcesContent":["<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails: the chip less its padding and\n // its two 1px borders. Not `align-self: stretch` with `aspect-ratio`,\n // which Safari sizes from the chip's outer height, so the tile ran\n // past the chip's bottom edge.\n .pk-chatbot-file-preview__icon-tile {\n --chip-tile-size: calc(\n var(--chip-size) - 2 * var(--spacing-8) - 2px\n );\n width: var(--chip-tile-size);\n height: var(--chip-tile-size);\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails: the chip less its padding and\n // its two 1px borders. Not `align-self: stretch` with `aspect-ratio`,\n // which Safari sizes from the chip's outer height, so the tile ran\n // past the chip's bottom edge.\n .pk-chatbot-file-preview__icon-tile {\n --chip-tile-size: calc(\n var(--chip-size) - 2 * var(--spacing-8) - 2px\n );\n width: var(--chip-tile-size);\n height: var(--chip-tile-size);\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails: the chip less its padding and\n // its two 1px borders. Not `align-self: stretch` with `aspect-ratio`,\n // which Safari sizes from the chip's outer height, so the tile ran\n // past the chip's bottom edge.\n .pk-chatbot-file-preview__icon-tile {\n --chip-tile-size: calc(\n var(--chip-size) - 2 * var(--spacing-8) - 2px\n );\n width: var(--chip-tile-size);\n height: var(--chip-tile-size);\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n"],"mappings":"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"}
@@ -1,139 +0,0 @@
1
- import { S as e, T as t, _ as n, k as r, l as i, m as a, o, r as s, t as c, u as l, v as u, x as d } from "./schemas-Bu1lick6.js";
2
- //#region ../../node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/coerce.js
3
- function f(e) {
4
- return r(c, e);
5
- }
6
- //#endregion
7
- //#region ../../packages/models/src/schema/Media.ts
8
- var p = /^[a-z]+\/[a-z0-9\-+.]+$/i, m = [
9
- "audio/mpeg",
10
- "audio/mp4",
11
- "audio/x-m4a",
12
- "audio/aac",
13
- "audio/wav",
14
- "audio/x-wav",
15
- "audio/wave",
16
- "audio/ogg",
17
- "audio/flac",
18
- "audio/x-flac"
19
- ], h = new Set([
20
- [".mp3", "audio/mpeg"],
21
- [".m4a", "audio/mp4"],
22
- [".m4a", "audio/x-m4a"],
23
- [".aac", "audio/aac"],
24
- [".wav", "audio/wav"],
25
- [".wav", "audio/x-wav"],
26
- [".wav", "audio/wave"],
27
- [".ogg", "audio/ogg"],
28
- [".oga", "audio/ogg"],
29
- [".opus", "audio/ogg"],
30
- [".flac", "audio/flac"],
31
- [".flac", "audio/x-flac"]
32
- ].map(([e]) => e)), g = /* @__PURE__ */ new Set([
33
- ".amr",
34
- ".3ga",
35
- ".aif",
36
- ".aiff",
37
- ".caf",
38
- ".m4b",
39
- ".mka",
40
- ".weba",
41
- ".wma"
42
- ]), _ = (e) => e?.split(";")[0]?.trim().toLowerCase() ?? "", v = (e, t) => {
43
- let n = N(e ?? "");
44
- return _(t).startsWith("audio/") || h.has(n) || g.has(n);
45
- };
46
- u({
47
- status: s([
48
- "ready",
49
- "no-speech",
50
- "failed",
51
- "off"
52
- ]),
53
- text: e().optional(),
54
- language: e().optional(),
55
- durationSeconds: n().optional(),
56
- model: e().optional()
57
- });
58
- var y = /* @__PURE__ */ function(e) {
59
- return e.DEFAULT = "default", e.COLD = "cold", e.HOT = "hot", e.ARCHIVE = "archive", e;
60
- }({}), b = u({
61
- type: a("azure-blob"),
62
- blobUri: t(),
63
- containerName: e().min(1),
64
- pathRelativeToContainer: e().min(1)
65
- }), x = u({
66
- type: a("s3"),
67
- bucket: e().min(1),
68
- key: e().min(1),
69
- region: e().min(1)
70
- }), S = l("type", [b, x]), C = u({
71
- type: e().min(1),
72
- name: e().min(1),
73
- mimeType: e().regex(p),
74
- bytesCount: n().positive(),
75
- extension: e().regex(/^\.[a-zA-Z0-9]+$/).overwrite((e) => e.toLowerCase()),
76
- storedFileName: e().optional(),
77
- metadata: d(e(), e()).default({}),
78
- tags: d(e(), e()).default({})
79
- }).extend({
80
- storageHandle: S,
81
- storagePolicy: s(y),
82
- signData: e().optional(),
83
- storedFileName: e().min(1)
84
- }), w = u({
85
- name: e().min(1),
86
- src: C,
87
- srcSet: o(C).default([]),
88
- rawOptions: i().optional()
89
- });
90
- u({
91
- src: C,
92
- uploadHandle: u({
93
- uploadSasuri: t(),
94
- metadata: d(e(), e()).default({}),
95
- tags: d(e(), e()).default({})
96
- })
97
- }), u({
98
- src: C,
99
- downloadHandle: u({
100
- downloadSasuri: t(),
101
- viewSasuri: t(),
102
- previewSasuri: t().optional(),
103
- expiresOn: e().datetime(),
104
- type: e()
105
- })
106
- });
107
- var T = /* @__PURE__ */ function(e) {
108
- return e.picture = "picture", e.video = "video", e.audio = "audio", e.office = "office", e.archive = "archive", e.binary = "binary", e.executable = "executable", e;
109
- }({}), E = u({
110
- type: a(T.picture),
111
- width: n().positive(),
112
- height: n().positive(),
113
- blurhash: e().optional()
114
- }), D = u({
115
- type: a(T.video),
116
- width: n().positive(),
117
- height: n().positive(),
118
- duration: n().nonnegative()
119
- }), O = u({
120
- type: a(T.audio),
121
- duration: n().nonnegative()
122
- }), k = u({ type: a(T.office) }), A = u({ type: a(T.archive) }), j = u({ type: a(T.binary) }), M = u({ type: a(T.executable) });
123
- l("type", [
124
- E,
125
- D,
126
- O,
127
- k,
128
- A,
129
- j,
130
- M
131
- ]);
132
- var N = (e) => {
133
- let t = e.lastIndexOf(".");
134
- return t === -1 ? "" : e.slice(t).toLowerCase();
135
- };
136
- //#endregion
137
- export { v as a, m as i, w as n, f as o, T as r, p as t };
138
-
139
- //# sourceMappingURL=Media-C6RS-s98.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Media-C6RS-s98.js","names":["core._coercedDate","schemas.ZodDate","z.object","z.enum","z.string","z.number","z.literal","z.url","z.discriminatedUnion","z.record","z.array","z.custom","string","url","number","_coercedDate","custom","literal","_enum","ZodDate","discriminatedUnion","object","record","date","params","MIME_TYPE_REGEX","SUPPORTED_AUDIO_TYPES","AUDIO_EXTENSIONS","extension","OTHER_AUDIO_EXTENSIONS","bareMimeType","mimeType","isAudioFile","getExtensionFromFilename","name","StoragePolicy","\"DEFAULT\"","\"COLD\"","\"HOT\"","\"ARCHIVE\"","AzureBlobStorageHandleSchema","S3StorageHandleSchema","StorageHandleSchema","MediaSourceSchema","ext","MediaSchema","array","MediaType","\"picture\"","\"video\"","\"audio\"","\"office\"","\"archive\"","\"binary\"","\"executable\"","PictureRawOptionsSchema","VideoRawOptionsSchema","AudioRawOptionsSchema","OfficeRawOptionsSchema","ArchiveRawOptionsSchema","BinaryRawOptionsSchema","ExecutableRawOptionsSchema","lastDot","filename"],"sources":["../../../../node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/coerce.js","../../../../packages/models/src/schema/Media.ts"],"sourcesContent":["import * as core from \"../core/index.js\";\nimport * as schemas from \"./schemas.js\";\nexport function string(params) {\n return core._coercedString(schemas.ZodString, params);\n}\nexport function number(params) {\n return core._coercedNumber(schemas.ZodNumber, params);\n}\nexport function boolean(params) {\n return core._coercedBoolean(schemas.ZodBoolean, params);\n}\nexport function bigint(params) {\n return core._coercedBigint(schemas.ZodBigInt, params);\n}\nexport function date(params) {\n return core._coercedDate(schemas.ZodDate, params);\n}\n","import { z } from 'zod'\n\n// ===== Constants =====\n\nexport const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB\n\n/**\n * The largest audio that is transcribed (spec `audio-file-transcription.md`,\n * D2): about seven hours of m4a at 32 kbps, ten minutes of CD quality wav.\n * The SDK sends it as base64 inside a JSON body, so a job holds about four\n * times this in memory. The upload alert\n * (`problemReasons.file-upload-validation.audio-too-large`) states it in MB.\n */\nexport const MAX_AUDIO_FILE_SIZE = MAX_FILE_SIZE\n\n/**\n * The largest audio a chat accepts, whatever the agent's own cap (spec\n * `audio-file-transcription.md`, D4, decided 2026-10-14): the turn transcribes\n * it before the answer starts, and 25 MB (about an hour and forty minutes of\n * m4a at 32 kbps) keeps that wait within a minute or two, short of what a\n * proxy tolerates before the first byte.\n */\nexport const MAX_CHAT_AUDIO_FILE_SIZE = 25 * 1024 * 1024\n\nexport const MIME_TYPE_REGEX = /^[a-z]+\\/[a-z0-9\\-+.]+$/i\n\nexport const SUPPORTED_IMAGE_TYPES = [\n 'image/jpeg',\n 'image/png',\n 'image/gif',\n 'image/webp',\n 'image/svg+xml',\n] as const\n\nexport const SUPPORTED_VIDEO_TYPES = [\n 'video/mp4',\n 'video/webm',\n 'video/ogg',\n] as const\n\n/**\n * The audio a transcription model reads (spec `audio-file-transcription.md`,\n * D2), by every type a browser or a drive declares for it: an m4a arrives as\n * `audio/mp4` or `audio/x-m4a` depending on the system, a wav as any of three.\n * `audio/webm`, what a browser records, waits for a test.\n */\nexport const SUPPORTED_AUDIO_TYPES = [\n 'audio/mpeg',\n 'audio/mp4',\n 'audio/x-m4a',\n 'audio/aac',\n 'audio/wav',\n 'audio/x-wav',\n 'audio/wave',\n 'audio/ogg',\n 'audio/flac',\n 'audio/x-flac',\n] as const\n\n/**\n * Each extension of the audio in {@link SUPPORTED_AUDIO_TYPES} with a type it\n * is declared as: the one table both directions read (spec\n * `voice-notes-platform-channels.md`, D3). From a type to the extension its\n * stored file takes, the first pair with that type; from an extension to the\n * type of a file that came without one (Teams), the first pair with that\n * extension. `.oga` and `.opus` are ogg files no type names apart.\n */\nconst AUDIO_EXTENSION_TYPES: ReadonlyArray<\n readonly [\n extension: string,\n mimeType: (typeof SUPPORTED_AUDIO_TYPES)[number],\n ]\n> = [\n ['.mp3', 'audio/mpeg'],\n ['.m4a', 'audio/mp4'],\n ['.m4a', 'audio/x-m4a'],\n ['.aac', 'audio/aac'],\n ['.wav', 'audio/wav'],\n ['.wav', 'audio/x-wav'],\n ['.wav', 'audio/wave'],\n ['.ogg', 'audio/ogg'],\n ['.oga', 'audio/ogg'],\n ['.opus', 'audio/ogg'],\n ['.flac', 'audio/flac'],\n ['.flac', 'audio/x-flac'],\n]\n\n/** The extensions of {@link SUPPORTED_AUDIO_TYPES}, for a file whose type says nothing. */\nconst AUDIO_EXTENSIONS = new Set(\n AUDIO_EXTENSION_TYPES.map(([extension]) => extension),\n)\n\n/**\n * The extension a stored audio of this bare type takes (`.m4a` for\n * `audio/x-m4a`), or undefined for a type the transcription does not read.\n */\nexport const audioExtensionFor = (mimeType: string): string | undefined =>\n AUDIO_EXTENSION_TYPES.find(([, type]) => type === mimeType)?.[0]\n\n/**\n * The type of an audio named with this extension (`audio/ogg` for `.opus`),\n * or undefined for an extension the transcription does not read.\n */\nexport const audioMimeTypeFor = (extension: string): string | undefined =>\n AUDIO_EXTENSION_TYPES.find(([ext]) => ext === extension.toLowerCase())?.[1]\n\n/** Audio no transcription reads, still to keep away from a model. */\nconst OTHER_AUDIO_EXTENSIONS = new Set([\n '.amr',\n '.3ga',\n '.aif',\n '.aiff',\n '.caf',\n '.m4b',\n '.mka',\n '.weba',\n '.wma',\n])\n\n/** A MIME type without its parameters: `audio/ogg; codecs=opus` is `audio/ogg`. */\nconst bareMimeType = (mimeType?: string): string =>\n mimeType?.split(';')[0]?.trim().toLowerCase() ?? ''\n\nconst hasAudioExtension = (name = ''): boolean =>\n AUDIO_EXTENSIONS.has(getExtensionFromFilename(name))\n\n/**\n * Whether a file is audio of any kind, read or not: what a channel that does\n * not transcribe has to keep away from the model, since most models refuse an\n * audio part and the whole turn with it.\n */\nexport const isAudioFile = (name?: string, mimeType?: string): boolean => {\n const extension = getExtensionFromFilename(name ?? '')\n return (\n bareMimeType(mimeType).startsWith('audio/') ||\n AUDIO_EXTENSIONS.has(extension) ||\n OTHER_AUDIO_EXTENSIONS.has(extension)\n )\n}\n\n/**\n * Whether a file is audio the transcription reads: by its type, else by its\n * extension when the type says nothing (none, `application/octet-stream`) or\n * is another system's name for an audio of ours (`audio/mp3`,\n * `audio/vnd.wave`, `audio/opus`): the model reads the bytes, not the type.\n */\nexport const isTranscribableAudio = (\n name?: string,\n mimeType?: string,\n): boolean => {\n const bare = bareMimeType(mimeType)\n if ((SUPPORTED_AUDIO_TYPES as readonly string[]).includes(bare)) {\n return true\n }\n return (\n (bare === '' ||\n bare === 'application/octet-stream' ||\n bare.startsWith('audio/')) &&\n hasAudioExtension(name)\n )\n}\n\n/**\n * The type a file the transcription reads is handled as (spec\n * `voice-notes-platform-channels.md`, D1): its own without parameters\n * (`audio/ogg; codecs=opus` is `audio/ogg`), or the one its extension names\n * when {@link isTranscribableAudio} took it by the extension. Undefined for a\n * file the transcription does not read.\n */\nexport const transcribableAudioType = (\n name?: string,\n mimeType?: string,\n): string | undefined => {\n if (!isTranscribableAudio(name, mimeType)) {\n return undefined\n }\n const bare = bareMimeType(mimeType)\n return (SUPPORTED_AUDIO_TYPES as readonly string[]).includes(bare)\n ? bare\n : audioMimeTypeFor(getExtensionFromFilename(name ?? ''))\n}\n\n/**\n * What a chat turn made of an audio the user attached (spec\n * `audio-file-transcription.md`, D4), kept on its file part\n * (`providerMetadata['8bot'].transcript`) and on its `chat_attachments` row:\n * the transcript in paragraphs opened by their time, or why there is none.\n * `off` is an organization that does not use the platform gateway, where\n * nothing is transcribed and nothing is charged.\n */\nexport const ChatAudioTranscriptSchema = z.object({\n status: z.enum(['ready', 'no-speech', 'failed', 'off']),\n text: z.string().optional(),\n /** ISO 639-1, when the model detected it. */\n language: z.string().optional(),\n durationSeconds: z.number().optional(),\n model: z.string().optional(),\n})\nexport type ChatAudioTranscript = z.infer<typeof ChatAudioTranscriptSchema>\n\n// ===== Enums =====\n\nexport enum StoragePolicy {\n DEFAULT = 'default',\n COLD = 'cold',\n HOT = 'hot',\n ARCHIVE = 'archive',\n}\n\nexport enum PreviewStatus {\n Pending = 'pending',\n Done = 'done',\n Failed = 'failed',\n}\n\n// ===== Storage Handle Schemas =====\n\nconst AzureBlobStorageHandleSchema = z.object({\n type: z.literal('azure-blob'),\n blobUri: z.url(),\n containerName: z.string().min(1),\n pathRelativeToContainer: z.string().min(1),\n})\n\nconst S3StorageHandleSchema = z.object({\n type: z.literal('s3'),\n bucket: z.string().min(1),\n key: z.string().min(1),\n region: z.string().min(1),\n})\n\nconst StorageHandleSchema = z.discriminatedUnion('type', [\n AzureBlobStorageHandleSchema,\n S3StorageHandleSchema,\n])\n\n// ===== Media Upload Schema =====\n\n/**\n * Media upload information for preparing a file upload\n */\nexport const MediaUploadSchema = z.object({\n /** Media type category (picture, video, audio, etc.) */\n type: z.string().min(1),\n /** Original filename */\n name: z.string().min(1),\n /** MIME type (e.g., 'image/jpeg') */\n mimeType: z.string().regex(MIME_TYPE_REGEX),\n /** File size in bytes (must be positive) */\n bytesCount: z.number().positive(),\n /** File extension with dot (e.g., '.jpg') */\n extension: z\n .string()\n .regex(/^\\.[a-zA-Z0-9]+$/)\n .overwrite((ext) => ext.toLowerCase()),\n /** Optional custom stored filename */\n storedFileName: z.string().optional(),\n /** Additional metadata key-value pairs */\n metadata: z.record(z.string(), z.string()).default({}),\n /** Storage tags for categorization */\n tags: z.record(z.string(), z.string()).default({}),\n})\nexport type MediaUpload = z.infer<typeof MediaUploadSchema>\n\n// ===== Media Source Schema =====\n\n/**\n * Complete media source information including storage details\n */\nexport const MediaSourceSchema = MediaUploadSchema.extend({\n /** Storage backend configuration */\n storageHandle: StorageHandleSchema,\n /** Storage tier/policy */\n storagePolicy: z.enum(StoragePolicy),\n /** Optional signature data for verification */\n signData: z.string().optional(),\n /** Actual stored filename (required) */\n storedFileName: z.string().min(1),\n})\nexport type MediaSource = z.infer<typeof MediaSourceSchema>\n\n// ===== Media Schema =====\n\n/**\n * Complete media object with source and optional variants\n */\nexport const MediaSchema = z.object({\n /** Display name for the media */\n name: z.string().min(1),\n /** Primary media source */\n src: MediaSourceSchema,\n /** Optional additional sources (e.g., different resolutions) */\n srcSet: z.array(MediaSourceSchema).default([]),\n /** Type-specific metadata (dimensions, duration, etc.) */\n rawOptions: z.custom<RawOptions>().optional(),\n})\nexport type Media = z.infer<typeof MediaSchema>\n\n// ===== Upload Handle Schema =====\n\n/**\n * Upload handle containing SAS URI and metadata for client-side upload\n */\nexport const MediaUploadHandleSchema = z.object({\n /** Media source information */\n src: MediaSourceSchema,\n /** Upload credentials and metadata */\n uploadHandle: z.object({\n /** Shared Access Signature URI for upload */\n uploadSasuri: z.url(),\n /** Metadata to attach to the blob */\n metadata: z.record(z.string(), z.string()).default({}),\n /** Tags to attach to the blob */\n tags: z.record(z.string(), z.string()).default({}),\n }),\n})\nexport type MediaUploadHandle = z.infer<typeof MediaUploadHandleSchema>\n\n// ===== Download Handle Schema =====\n\n/**\n * Download handle containing SAS URIs for different access patterns\n */\nexport const MediaDownloadHandleSchema = z.object({\n /** Media source information */\n src: MediaSourceSchema,\n /** Download credentials and URIs */\n downloadHandle: z.object({\n /** SAS URI for file download */\n downloadSasuri: z.url(),\n /** SAS URI for inline viewing */\n viewSasuri: z.url(),\n /** Optional SAS URI for preview thumbnail */\n previewSasuri: z.url().optional(),\n /** Expiration timestamp for the SAS URIs */\n expiresOn: z.string().datetime(),\n /** Storage backend type */\n type: z.string(),\n }),\n})\nexport type MediaDownloadHandle = z.infer<typeof MediaDownloadHandleSchema>\n\n// ===== Media Types =====\n\nexport enum MediaType {\n picture = 'picture',\n video = 'video',\n audio = 'audio',\n office = 'office',\n archive = 'archive',\n binary = 'binary',\n executable = 'executable',\n}\n\n// ===== Raw Options Schemas =====\n\nconst PictureRawOptionsSchema = z.object({\n type: z.literal(MediaType.picture),\n width: z.number().positive(),\n height: z.number().positive(),\n blurhash: z.string().optional(),\n})\n\nconst VideoRawOptionsSchema = z.object({\n type: z.literal(MediaType.video),\n width: z.number().positive(),\n height: z.number().positive(),\n duration: z.number().nonnegative(),\n})\n\nconst AudioRawOptionsSchema = z.object({\n type: z.literal(MediaType.audio),\n duration: z.number().nonnegative(),\n})\n\nconst OfficeRawOptionsSchema = z.object({\n type: z.literal(MediaType.office),\n})\n\nconst ArchiveRawOptionsSchema = z.object({\n type: z.literal(MediaType.archive),\n})\n\nconst BinaryRawOptionsSchema = z.object({\n type: z.literal(MediaType.binary),\n})\n\nconst ExecutableRawOptionsSchema = z.object({\n type: z.literal(MediaType.executable),\n})\n\nexport const RawOptionsSchema = z.discriminatedUnion('type', [\n PictureRawOptionsSchema,\n VideoRawOptionsSchema,\n AudioRawOptionsSchema,\n OfficeRawOptionsSchema,\n ArchiveRawOptionsSchema,\n BinaryRawOptionsSchema,\n ExecutableRawOptionsSchema,\n])\n\n// ===== Raw Options Types =====\n\n// ===== Raw Options Types =====\n\nexport type PictureRawOptions = z.infer<typeof PictureRawOptionsSchema>\nexport type VideoRawOptions = z.infer<typeof VideoRawOptionsSchema>\nexport type AudioRawOptions = z.infer<typeof AudioRawOptionsSchema>\nexport type OfficeRawOptions = z.infer<typeof OfficeRawOptionsSchema>\nexport type ArchiveRawOptions = z.infer<typeof ArchiveRawOptionsSchema>\nexport type BinaryRawOptions = z.infer<typeof BinaryRawOptionsSchema>\nexport type ExecutableRawOptions = z.infer<typeof ExecutableRawOptionsSchema>\n\nexport type RawOptions = z.infer<typeof RawOptionsSchema>\n\n// ===== Uploaded File Type =====\n\nexport type UploadedFile<Source = undefined> = Source extends undefined\n ? {\n name: string\n size: number\n type: string\n url: string\n lastModified?: number\n }\n : {\n name: string\n size: number\n type: string\n url: string\n lastModified?: number\n source: Source\n }\n\n// ===== Helper Functions =====\n\n/**\n * Check if a MIME type is a supported image format\n */\nexport const isSupportedImageType = (mimeType: string): boolean => {\n return mimeType.startsWith('image/')\n}\n\n/**\n * Check if a MIME type supports preview generation\n */\nexport const isSupportedPreviewType = (mimeType: string): boolean => {\n return mimeType.startsWith('image/') || mimeType === 'application/pdf'\n}\n\n/**\n * Determine MediaType from MIME type\n */\nexport const getMediaTypeFromMime = (mimeType: string): MediaType => {\n if (mimeType.startsWith('image/')) {\n return MediaType.picture\n }\n if (mimeType.startsWith('video/')) {\n return MediaType.video\n }\n if (mimeType.startsWith('audio/')) {\n return MediaType.audio\n }\n if (\n mimeType.startsWith('application/pdf') ||\n mimeType.startsWith('application/msword') ||\n mimeType.startsWith('application/vnd.openxmlformats') ||\n mimeType.startsWith('application/vnd.ms-')\n ) {\n return MediaType.office\n }\n if (\n mimeType === 'application/zip' ||\n mimeType === 'application/x-rar-compressed' ||\n mimeType === 'application/x-7z-compressed'\n ) {\n return MediaType.archive\n }\n if (\n mimeType.startsWith('application/x-') &&\n mimeType.includes('executable')\n ) {\n return MediaType.executable\n }\n return MediaType.binary\n}\n\n/**\n * Extract file extension from filename\n */\nexport const getExtensionFromFilename = (filename: string): string => {\n const lastDot = filename.lastIndexOf('.')\n return lastDot === -1 ? '' : filename.slice(lastDot).toLowerCase()\n}\n\n/**\n * Format bytes to human-readable string\n */\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n if (bytes === 0) {\n return '0 Bytes'\n }\n const k = 1024\n const dm = decimals < 0 ? 0 : decimals\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`\n}\n\n// ===== Type Guards =====\n\nexport const isPictureOptions = (\n options: RawOptions,\n): options is PictureRawOptions => {\n return options.type === MediaType.picture\n}\n\nexport const isVideoOptions = (\n options: RawOptions,\n): options is VideoRawOptions => {\n return options.type === MediaType.video\n}\n\nexport const isAudioOptions = (\n options: RawOptions,\n): options is AudioRawOptions => {\n return options.type === MediaType.audio\n}\n\nexport const isOfficeOptions = (\n options: RawOptions,\n): options is OfficeRawOptions => {\n return options.type === MediaType.office\n}\n\nexport const isArchiveOptions = (\n options: RawOptions,\n): options is ArchiveRawOptions => {\n return options.type === MediaType.archive\n}\n\nexport const isBinaryOptions = (\n options: RawOptions,\n): options is BinaryRawOptions => {\n return options.type === MediaType.binary\n}\n\nexport const isExecutableOptions = (\n options: RawOptions,\n): options is ExecutableRawOptions => {\n return options.type === MediaType.executable\n}\n"],"ignoreList":[0],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"PkChatbotFilePreview-Lxgu89Hg.js","names":["$t","$emit","__unplugin_components_0","_openBlock","_createElementBlock","_normalizeClass","_createElementVNode","_withDirectives","_vShow","_createVNode","_createCommentVNode","_toDisplayString","_createTextVNode","_Fragment","_withModifiers","_normalizeStyle","_createBlock","_sfc_main","loadCdnCss","PHOTOSWIPE_CSS","DEFAULT_IMAGE_SIZE","useLightbox","options","lightbox","shallowRef","onBeforeUnmount","gallery","injectPhotoSwipeOverrides","PhotoSwipeLightbox","openInNewTab","downloadable","ui","url","getCurrentSlideUrl","pswp","downloadImage","useImageSizes","sizes","ref","image","images","img","downloadUrl","toDownloadUrl","anchor","getFilenameFromUrl","name","overridesInjected","style","_hoisted_1","_hoisted_2","_hoisted_3","_hoisted_4","_hoisted_5","_hoisted_6","_hoisted_7","_hoisted_8","_hoisted_9","_hoisted_10","_hoisted_11","_hoisted_12","_hoisted_13","_hoisted_14","_hoisted_15","THUMB_CACHE_MAX","PkChatbotFilePreview_default","defineComponent","props","__props","isImage","computed","extension","dot","subtype","KINDS","fileKind","haystack","test","kind","typeTag","formattedSize","bytes","units","value","unitIndex","thumbCache","setThumbCache","entry","cached","thumbStatus","dimensions","onImageLoad","event","onImageError","pswpSize","DEFAULT_PSWP_SIZE","imageMeta","docMeta","galleryRef","onMounted","init","_component_VvIcon","VvIcon","openBlock","createElementBlock","normalizeClass","createElementVNode","_ctx","withDirectives","vShow","createVNode","createCommentVNode","toDisplayString","createTextVNode","Fragment","_cache","withModifiers","normalizeStyle","createBlock"],"sources":["../../../../packages/components/src/composables/useLightbox.ts","../../../../packages/components/src/chat/PkChatbotFilePreview.vue","../../../../packages/components/src/chat/PkChatbotFilePreview.vue"],"sourcesContent":["import {\n onBeforeUnmount,\n ref,\n shallowRef,\n type Ref,\n type ShallowRef,\n} from 'vue'\nimport type PhotoSwipeLightbox from 'photoswipe/lightbox'\nimport type PhotoSwipe from 'photoswipe'\nimport { loadCdnCss } from 'utils'\n\nconst PHOTOSWIPE_CSS =\n 'https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.css'\n\nconst DEFAULT_IMAGE_SIZE = { width: 1200, height: 800 }\n\nexport interface LightboxImage {\n url: string\n caption?: string\n alt?: string\n /** Natural width in px — used by PhotoSwipe for proper sizing. */\n width?: number\n /** Natural height in px — used by PhotoSwipe for proper sizing. */\n height?: number\n}\n\nexport interface LightboxOptions {\n /** Container element whose child anchors are the gallery items */\n gallery: Ref<HTMLElement | undefined>\n /** CSS selector for gallery items inside the container (default: `'a[data-pswp-src]'`) */\n children?: string\n loop?: boolean\n /** Show a download button in the lightbox toolbar */\n downloadable?: boolean\n /** Show an open-in-new-tab button in the lightbox toolbar */\n openInNewTab?: boolean\n}\n\nexport interface UseLightboxReturn {\n lightbox: ShallowRef<PhotoSwipeLightbox | null>\n /** Initialises PhotoSwipe. Call this inside `onMounted`. */\n init: () => Promise<void>\n}\n\n/**\n * Encapsulates PhotoSwipe 5 initialisation, toolbar customisation, and lifecycle cleanup.\n * CSS is injected into `document.head` so the overlay works in both regular DOM and Shadow DOM.\n *\n * Usage:\n * ```ts\n * const { init } = useLightbox({ gallery: galleryRef, downloadable: true })\n * onMounted(init)\n * ```\n */\nexport function useLightbox(options: LightboxOptions): UseLightboxReturn {\n const {\n gallery,\n children = 'a[data-pswp-src]',\n loop = true,\n downloadable = true,\n openInNewTab = true,\n } = options\n\n const lightbox = shallowRef<PhotoSwipeLightbox | null>(null)\n\n const init = async () => {\n if (!gallery.value) {\n return\n }\n\n // PhotoSwipe renders its overlay into document.body, so CSS must be in\n // document.head — not inside a shadow root.\n loadCdnCss(PHOTOSWIPE_CSS)\n injectPhotoSwipeOverrides()\n\n const { default: PhotoSwipeLightbox } =\n await import('photoswipe/lightbox')\n\n lightbox.value = new PhotoSwipeLightbox({\n gallery: gallery.value,\n children,\n pswpModule: () => import('photoswipe'),\n loop,\n arrowPrevSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m10.828 12l4.95 4.95l-1.414 1.415L8 12l6.364-6.364l1.414 1.414z\"/></svg>',\n arrowNextSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m13.172 12l-4.95-4.95l1.414-1.413L16 12l-6.364 6.364l-1.414-1.415z\"/></svg>',\n closeSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m12 10.587l4.95-4.95l1.414 1.414l-4.95 4.95l4.95 4.95l-1.415 1.414l-4.95-4.95l-4.949 4.95l-1.414-1.415l4.95-4.95l-4.95-4.95L7.05 5.638z\"/></svg>',\n zoomSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g class=\"pswp__icn--zoom-in\"><path fill=\"#ffffff\" d=\"m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975zM10 10V7h2v3h3v2h-3v3h-2v-3H7v-2z\"/></g><g class=\"pswp__icn--zoom-out\"><path fill=\"#ffffff\" d=\"m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975zM7 10h8v2H7z\"/></g></svg>',\n })\n\n if (openInNewTab || downloadable) {\n lightbox.value.on('uiRegister', () => {\n const ui = lightbox.value!.pswp?.ui\n if (!ui) {\n return\n }\n\n if (openInNewTab) {\n ui.registerElement({\n name: 'open-in-new-tab-button',\n order: 8,\n isButton: true,\n html: '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm11-3v8h-2V6.413l-7.793 7.794l-1.414-1.414L17.585 5H13V3z\"/></svg>',\n title: 'Open in new tab',\n onClick: (_, __, pswp) => {\n const url = getCurrentSlideUrl(pswp)\n if (url) {\n window.open(\n url,\n '_blank',\n 'noopener,noreferrer',\n )\n }\n },\n })\n }\n\n if (downloadable) {\n ui.registerElement({\n name: 'download-button',\n order: 9,\n isButton: true,\n html: '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"M3 19h18v2H3zm10-5.828L19.071 7.1l1.414 1.414L12 17L3.515 8.515L4.929 7.1L11 13.173V2h2z\"/></svg>',\n title: 'Download',\n onClick: (_, __, pswp) => {\n const url = getCurrentSlideUrl(pswp)\n if (url) {\n downloadImage(url)\n }\n },\n })\n }\n })\n }\n\n lightbox.value.init()\n }\n\n onBeforeUnmount(() => {\n lightbox.value?.destroy()\n lightbox.value = null\n })\n\n return { lightbox, init }\n}\n\n// ---------------------------------------------------------------------------\n// Image size preloading\n// ---------------------------------------------------------------------------\n\nexport interface UseImageSizesReturn {\n preload: (\n images: Array<{ url: string; width?: number; height?: number }>,\n ) => void\n getSize: (url: string) => { width: number; height: number }\n}\n\n/**\n * Preloads natural image dimensions so PhotoSwipe can size its slides correctly.\n * When `width`/`height` are already provided they are used as-is;\n * otherwise the image is loaded in the background to read `naturalWidth`/`naturalHeight`.\n */\nexport function useImageSizes(): UseImageSizesReturn {\n const sizes = ref(new Map<string, { width: number; height: number }>())\n\n const preload = (\n images: Array<{ url: string; width?: number; height?: number }>,\n ) => {\n for (const image of images) {\n if (image.width && image.height) {\n sizes.value.set(image.url, {\n width: image.width,\n height: image.height,\n })\n continue\n }\n const img = new Image()\n img.onload = () => {\n sizes.value.set(image.url, {\n width: img.naturalWidth,\n height: img.naturalHeight,\n })\n }\n img.src = image.url\n }\n }\n\n const getSize = (url: string) => sizes.value.get(url) ?? DEFAULT_IMAGE_SIZE\n\n return { preload, getSize }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction getCurrentSlideUrl(pswp: PhotoSwipe): string | undefined {\n return (\n pswp.currSlide?.data?.src ??\n pswp.currSlide?.data?.element\n ?.querySelector('img')\n ?.getAttribute('src') ??\n pswp.currSlide?.data?.element\n ?.querySelector('source')\n ?.getAttribute('srcset') ??\n pswp.currSlide?.data?.element?.getAttribute('src') ??\n undefined\n )\n}\n\n/**\n * Triggers a client-side download for an image URL.\n *\n * For backend media URLs (.../media/:file/view), navigates to the\n * corresponding /download endpoint which redirects to an Azure SAS URL\n * with `Content-Disposition: attachment` — no CORS issues.\n *\n * For blob: URLs (in-session uploads), uses the fetch → blob approach.\n *\n * For data: URLs (inline SVGs, etc.), use the download attribute.\n *\n * Falls back to opening in a new tab for all other cases.\n */\nfunction downloadImage(url: string): void {\n const downloadUrl = toDownloadUrl(url)\n\n const anchor = document.createElement('a')\n anchor.href = downloadUrl\n anchor.target = '_blank'\n anchor.rel = 'noopener noreferrer'\n // `download` attribute works for same-origin, data: and blob: URLs; for\n // cross-origin it is ignored but the browser still follows Content-Disposition.\n anchor.download = getFilenameFromUrl(url)\n document.body.appendChild(anchor)\n anchor.click()\n document.body.removeChild(anchor)\n}\n\n/**\n * Converts a `/view` media URL to its `/download` counterpart.\n * Leaves blob:, data: and all other URLs untouched.\n */\nfunction toDownloadUrl(url: string): string {\n return url.replace(/\\/view(\\?|$)/, '/download$1')\n}\n\nfunction getFilenameFromUrl(url: string): string {\n if (url.startsWith('data:image/svg+xml')) {\n return 'diagram.svg'\n }\n if (url.startsWith('data:')) {\n const mime = url.split(':')[1].split(';')[0]\n const ext = mime.split('/')[1] || 'bin'\n return `image.${ext}`\n }\n try {\n const pathname = new URL(url).pathname\n const name = pathname.split('/').pop()\n return name && name.includes('.') ? name : 'image'\n } catch {\n return 'image'\n }\n}\n\nlet overridesInjected = false\n\n/**\n * Injects a one-time `<style>` into `document.head` that raises\n * PhotoSwipe's z-index above any host UI (e.g. floating chat widgets\n * that use `z-index: 2147483647`).\n */\nfunction injectPhotoSwipeOverrides(): void {\n if (overridesInjected) {\n return\n }\n overridesInjected = true\n const style = document.createElement('style')\n style.textContent = [\n '.pswp__counter { font-family: sans-serif; }',\n '.pswp { z-index: 2147483647 !important; }',\n '.pswp__icn { width: 20px; height: 20px; }',\n '.pswp__button { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }',\n '.pswp__icn--zoom-out { display: none; }',\n '.pswp--zoomed-in .pswp__icn--zoom-in { display: none; }',\n '.pswp--zoomed-in .pswp__icn--zoom-out { display: block; }',\n '.pswp__button--arrow .pswp__icn {left: auto; right: auto; width: 40px; height: 40px; transform: none; margin-top: -20px; }',\n ].join(' ')\n document.head.appendChild(style)\n}\n","<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails: the chip less its padding and\n // its two 1px borders. Not `align-self: stretch` with `aspect-ratio`,\n // which Safari sizes from the chip's outer height, so the tile ran\n // past the chip's bottom edge.\n .pk-chatbot-file-preview__icon-tile {\n --chip-tile-size: calc(\n var(--chip-size) - 2 * var(--spacing-8) - 2px\n );\n width: var(--chip-tile-size);\n height: var(--chip-tile-size);\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails: the chip less its padding and\n // its two 1px borders. Not `align-self: stretch` with `aspect-ratio`,\n // which Safari sizes from the chip's outer height, so the tile ran\n // past the chip's bottom edge.\n .pk-chatbot-file-preview__icon-tile {\n --chip-tile-size: calc(\n var(--chip-size) - 2 * var(--spacing-8) - 2px\n );\n width: var(--chip-tile-size);\n height: var(--chip-tile-size);\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n"],"mappings":"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"}