feat(widget): Widget 配置、loader 与右下角悬浮入口
生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
No files matched your search
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="60" cy="60" r="60" fill="#FFF3E0"/>
|
||||
<path d="M60 36C48.95 36 40 44.95 40 56C40 67.05 48.95 76 60 76C71.05 76 80 67.05 80 56C80 44.95 71.05 36 60 36ZM60 44C64.42 44 68 47.58 68 52C68 56.42 64.42 60 60 60C55.58 60 52 56.42 52 52C52 47.58 55.58 44 60 44ZM60 72C54.67 72 50 69.33 50 66C50 62.67 54.67 60 60 60C65.33 60 70 62.67 70 66C70 69.33 65.33 72 60 72Z" fill="#FF9800"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 500 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="60" cy="60" r="60" fill="#F5F5F5"/>
|
||||
<path d="M60 36C48.95 36 40 44.95 40 56C40 67.05 48.95 76 60 76C71.05 76 80 67.05 80 56C80 44.95 71.05 36 60 36ZM66 64H54V60H66V64ZM66 56H54V52H66V56Z" fill="#9E9E9E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="64px" height="64px" fill-rule="evenodd" clip-rule="evenodd"><circle cx="198.287" cy="104.366" r="32.265" fill="#D7E057" transform="rotate(-76.526 198.291 104.361)"/><path fill="#3A312A" d="M198.287,138.131c-18.618,0-33.765-15.147-33.765-33.765s15.147-33.765,33.765-33.765 c18.618,0,33.765,15.146,33.765,33.765S216.905,138.131,198.287,138.131z M198.287,73.602c-16.964,0-30.765,13.801-30.765,30.765 s13.801,30.765,30.765,30.765s30.765-13.801,30.765-30.765S215.251,73.602,198.287,73.602z"/><path fill="#87796F" d="M196.097,126.921v-5.252c-3.704-0.166-7.297-1.161-9.398-2.377l1.658-6.468 c2.322,1.271,5.583,2.432,9.177,2.432c3.151,0,5.307-1.216,5.307-3.428c0-2.1-1.769-3.427-5.86-4.809 c-5.915-1.99-9.951-4.755-9.951-10.117c0-4.865,3.428-8.68,9.343-9.84v-5.252h5.418v4.865c3.704,0.165,6.191,0.94,8.016,1.825 l-1.603,6.246c-1.437-0.608-3.981-1.88-7.96-1.88c-3.594,0-4.755,1.549-4.755,3.096c0,1.825,1.935,2.986,6.634,4.754 c6.578,2.322,9.232,5.363,9.232,10.338c0,4.92-3.483,9.122-9.841,10.227v5.639H196.097z"/><path fill="#3A312A" d="M201.515,128.421h-5.418c-0.829,0-1.5-0.672-1.5-1.5v-3.855c-3.287-0.323-6.504-1.233-8.649-2.475 c-0.582-0.336-0.869-1.02-0.702-1.67l1.658-6.468c0.115-0.449,0.431-0.819,0.856-1.004c0.424-0.185,0.911-0.162,1.317,0.06 c1.535,0.84,4.673,2.248,8.458,2.248c0.636,0,3.807-0.093,3.807-1.928c0-0.91-0.586-1.95-4.84-3.388 c-5.107-1.718-10.971-4.583-10.971-11.538c0-5.338,3.546-9.479,9.343-11.032v-4.061c0-0.828,0.671-1.5,1.5-1.5h5.418 c0.829,0,1.5,0.672,1.5,1.5v3.464c2.737,0.252,5.094,0.87,7.17,1.877c0.642,0.312,0.976,1.031,0.798,1.723l-1.603,6.246 c-0.109,0.423-0.396,0.777-0.788,0.972c-0.392,0.194-0.848,0.207-1.25,0.037l-0.418-0.181c-1.45-0.63-3.64-1.581-6.958-1.581 c-1.216,0-3.255,0.207-3.255,1.597c0,0.866,1.641,1.836,5.663,3.35c7.144,2.521,10.204,6.035,10.204,11.742 c0,5.566-3.82,9.95-9.84,11.443v4.422C203.015,127.749,202.343,128.421,201.515,128.421z M197.597,125.421h2.418v-4.139 c0-0.73,0.525-1.353,1.243-1.478c5.303-0.922,8.597-4.273,8.597-8.749c0-3.612-1.4-6.513-8.231-8.924 c-4.339-1.632-7.635-3.115-7.635-6.168c0-2.223,1.643-4.597,6.255-4.597c3.063,0,5.339,0.692,6.94,1.321l0.852-3.321 c-1.807-0.692-3.885-1.085-6.311-1.193c-0.802-0.035-1.433-0.695-1.433-1.498v-3.365h-2.418v3.752c0,0.717-0.507,1.334-1.211,1.472 c-5.092,1-8.132,4.128-8.132,8.369c0,3.989,2.67,6.59,8.93,8.695c3.575,1.208,6.881,2.798,6.881,6.23 c0,2.993-2.672,4.928-6.807,4.928c-3.315,0-6.191-0.909-8.181-1.776l-0.902,3.52c2.052,0.911,4.94,1.547,7.713,1.672 c0.801,0.035,1.433,0.695,1.433,1.498V125.421z"/><path fill="#D7E057" d="M162.221,16.576c0,0,2.411,8.954,9.352,9.369c0,0-9.266,3.201-9.422,10.045c0,0-1.824-9.6-9.142-9.944 C153.009,26.046,161.532,24.755,162.221,16.576z"/><path fill="#3A312A" d="M162.15,37.49c-0.714,0-1.337-0.507-1.473-1.22c-0.016-0.082-1.704-8.441-7.739-8.726 c-0.77-0.036-1.388-0.65-1.428-1.421c-0.04-0.77,0.511-1.444,1.273-1.561c0.295-0.047,7.363-1.243,7.942-8.113 c0.061-0.726,0.635-1.303,1.36-1.368c0.732-0.068,1.392,0.399,1.582,1.102l0,0c0.021,0.079,2.228,7.92,7.994,8.265 c0.715,0.042,1.299,0.584,1.396,1.293c0.098,0.709-0.319,1.388-0.996,1.622c-0.081,0.028-8.282,2.953-8.412,8.661 c-0.018,0.768-0.611,1.398-1.376,1.461C162.233,37.488,162.191,37.49,162.15,37.49z M157.631,25.963 c2.107,1.333,3.523,3.395,4.447,5.279c1.376-2.232,3.515-3.871,5.364-4.972c-2.287-1.27-3.884-3.35-4.941-5.224 C161.328,23.334,159.486,24.922,157.631,25.963z"/><path fill="#D7E057" d="M183.919,26.253c0,0,3.408,12.659,13.222,13.246c0,0-13.1,4.525-13.321,14.202 c0,0-2.578-13.572-12.925-14.059C170.895,39.642,182.946,37.817,183.919,26.253z"/><path fill="#3A312A" d="M183.819,55.2c-0.712,0-1.335-0.506-1.472-1.217c-0.024-0.125-2.495-12.418-11.522-12.843 c-0.771-0.036-1.388-0.65-1.428-1.421c-0.04-0.77,0.511-1.444,1.273-1.561c0.441-0.068,10.894-1.817,11.754-12.032 c0.061-0.726,0.634-1.301,1.359-1.367c0.734-0.057,1.393,0.399,1.583,1.101c0.032,0.116,3.259,11.626,11.864,12.141 c0.714,0.042,1.298,0.583,1.396,1.292c0.098,0.708-0.319,1.388-0.994,1.622c-0.121,0.042-12.118,4.311-12.313,12.819 c-0.018,0.767-0.61,1.396-1.374,1.46C183.903,55.198,183.861,55.2,183.819,55.2z M176.078,39.471 c3.878,1.97,6.23,5.746,7.587,8.877c2.026-4.021,5.942-6.787,8.949-8.427c-4.134-1.843-6.783-5.651-8.334-8.723 C182.487,35.559,179.009,38.066,176.078,39.471z"/><path fill="#D7E057" d="M162.221,43.233c0,0,2.411,8.954,9.352,9.369c0,0-9.266,3.201-9.422,10.045c0,0-1.824-9.6-9.142-9.944 C153.009,52.704,161.532,51.412,162.221,43.233z"/><path fill="#3A312A" d="M162.149,64.148c-0.713,0-1.337-0.507-1.472-1.22c-0.016-0.082-1.704-8.442-7.739-8.727 c-0.77-0.036-1.388-0.65-1.428-1.421c-0.04-0.77,0.511-1.444,1.273-1.561c0.295-0.047,7.363-1.243,7.942-8.113 c0.061-0.726,0.635-1.303,1.36-1.368c0.732-0.067,1.392,0.399,1.582,1.102l0,0c0.021,0.079,2.228,7.92,7.994,8.265 c0.715,0.042,1.299,0.584,1.396,1.293c0.098,0.709-0.319,1.388-0.996,1.622c-0.081,0.028-8.282,2.953-8.412,8.662 c-0.018,0.768-0.611,1.398-1.376,1.461C162.233,64.146,162.191,64.148,162.149Line truncated
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="64px" height="64px"><path fill="#fff" d="M98.2,116.8l-22.2-9c-1.5,5.3-7.7,8.3-12.2,8.9v0.1c-0.7,0-1.9-0.1-2-0.1c-4.3-0.8-8.3-3.7-9.7-8.8 l-22.2,9l0,0C52,123.9,76,123.9,98.2,116.8L98.2,116.8z"/><path fill="#f7ebde" d="M76,102v6.5l0,0c-3.5,12-20.5,12-23.9,0l0,0V102"/><path fill="#f7ebde" d="M96.3,59.8c0-26.4-13.1-39.9-32.3-39.9S31.7,33.5,31.7,59.8c-6.2,0.5-4.8,14.6,2.2,13.8 c2,5.7,4.2,10.4,6.5,14.4c7.9,13.5,16.9,17.5,23.5,17.5c8.5,0,21.1-6.6,30-31.8C101.1,74.4,102.5,60.3,96.3,59.8z"/><path fill="#454b54" d="M30 120.5c-1.2 0-2.3-.7-2.8-1.9-.6-1.5.1-3.3 1.6-3.9l22.1-9c1.5-.6 3.3.1 3.9 1.6.6 1.5-.1 3.3-1.6 3.9l-22.1 9C30.7 120.4 30.3 120.5 30 120.5zM98.1 120.5c-.4 0-.8-.1-1.1-.2l-22.1-9c-1.5-.6-2.3-2.4-1.6-3.9.6-1.5 2.4-2.3 3.9-1.6l22.1 9c1.5.6 2.3 2.4 1.6 3.9C100.4 119.8 99.2 120.5 98.1 120.5z"/><path fill="#454b54" d="M64,120.5c-7.1,0-12.9-4.4-14.8-11.2c-0.5-1.6,0.5-3.3,2.1-3.7c1.6-0.5,3.3,0.5,3.7,2.1 c1.5,5.1,5.6,6.9,9.1,6.9s7.6-1.8,9.1-6.9c0.5-1.6,2.1-2.5,3.7-2.1c1.6,0.5,2.5,2.1,2.1,3.7C76.9,116.1,71.1,120.5,64,120.5z"/><path fill="#454b54" d="M64,108.4c-5.4,0-16-2.3-25.5-18c-0.9-1.4-0.4-3.3,1-4.1c1.4-0.9,3.3-0.4,4.1,1 c8,13.2,16.2,15.1,20.4,15.1c5,0,17.6-2.9,27.2-29.8c0.6-1.6,2.3-2.4,3.8-1.8c1.6,0.6,2.4,2.3,1.8,3.8C86,105.1,70.3,108.4,64,108.4 z"/><path fill="#c88b79" d="M96.7,59.9c-0.101,0-0.2,0-0.4,0C96.3,33.5,83.2,20,64,20c-14,0-24.8,7.2-29.6,21.4 c-1.7,4.9-2.6,10.6-2.7,17.1c7.712-18.368,17.85-11.435,31.374-4.503C72.521,58.84,83.621,63.684,96.7,59.9"/><path fill="#454b54" d="M87.1,64.3c-10.899,0-20.3-4.9-28.2-9c-6.8-3.6-12.7-6.6-16.9-5.1c-2.8,1-5.3,4.1-7.5,9.4 c-0.6,1.5-2.4,2.2-3.9,1.6c-1.5-0.6-2.2-2.4-1.6-3.9c2.9-7,6.5-11.1,11-12.8c6.7-2.4,13.7,1.2,21.7,5.4C71.5,55,82.6,60.8,95.8,57 c1.601-0.5,3.3,0.4,3.7,2c0.5,1.6-0.4,3.3-2,3.7C93.9,63.8,90.4,64.2,87.1,64.3"/><path fill="#fff" d="M59.864,55.8H36.34c-0.635,1.109-1.248,2.375-1.84,3.8c-0.455,1.137-1.599,1.814-2.78,1.814 c-0.179,0-0.358-0.015-0.536-0.047c0.204,5.363,0.505,13.239,0.515,13.239c0,0,0-0.002,0-0.006c0,2.601,2.1,4.7,4.7,4.7h17 c2.1,0,4-1.5,4.5-3.6l1-5.9c0.2-1.3,1.4-2.3,2.8-2.3c1.4,0,2.5,1,2.8,2.3l1,5.9c0.5,2.1,2.4,3.6,4.5,3.6h17c2.6,0,4.7-2.1,4.7-4.7 l0.414-10.714c-1.712,0.243-3.388,0.365-5.015,0.414C76.64,64.3,67.561,59.788,59.864,55.8"/><path fill="#faf4f2" d="M90.7,55.8H74.419c4.027,1.448,8.273,2.424,12.777,2.424c1.682,0,3.399-0.136,5.154-0.435L92.4,56.5 C92,56,91.4,55.8,90.7,55.8"/><path fill="#ededee" d="M74.419,55.8H59.864c7.697,3.988,16.776,8.5,27.236,8.5c1.627-0.049,3.303-0.171,5.015-0.414 l0.236-6.096c-1.756,0.299-3.473,0.435-5.154,0.435C82.693,58.224,78.447,57.247,74.419,55.8"/><path fill="#fff" d="M98.9,81.3l-8.1-1.4L96.3,48l8.1,1.4c3.5,0.6,5.8,4,5.2,7.5L106.3,76 C105.7,79.5,102.4,81.9,98.9,81.3z"/><path fill="#454b54" d="M100,84.4c-0.5,0-1.1,0-1.6-0.1L90.8,83c-1.6-0.3-2.7-1.8-2.5-3.4l5-32c0.1-0.8,0.6-1.5,1.2-2 s1.5-0.6,2.3-0.5l8.1,1.4c5.1,0.9,8.6,5.8,7.6,11l-3.4,19.1C108.5,81.1,104.6,84.4,100,84.4z M94.8,77.5l4.6,0.8 c0.2,0,0.4,0.1,0.6,0.1c1.7,0,3.1-1.2,3.4-2.9l3.4-19.1c0.3-1.9-0.9-3.7-2.8-4l-5.1-0.9L94.8,77.5z"/><path fill="#fff" d="M29.1,81.3l8.1-1.4L31.7,48l-8.1,1.4c-3.5,0.6-5.8,4-5.2,7.5L21.7,76C22.3,79.5,25.6,81.9,29.1,81.3z"/><path fill="#454b54" d="M28,84.4L28,84.4c-4.6,0-8.5-3.3-9.3-7.8l-3.4-19.1c-0.9-5.1,2.5-10,7.6-11l8.1-1.4 c0.8-0.1,1.6,0,2.2,0.5c0.7,0.5,1.1,1.2,1.2,1.9L40,79.3c0.3,1.6-0.8,3.2-2.4,3.5l-8.1,1.4C29.1,84.3,28.6,84.4,28,84.4z M29.2,51.5 L24,52.4c-1.9,0.3-3.1,2.1-2.8,4l3.4,19.1c0.3,1.7,1.7,2.9,3.4,2.9l0,0c0.2,0,0.4,0,0.6-0.1l5.2-0.9L29.2,51.5z"/><path fill="#454b54" d="M25.4,50.8h-0.1c-1.7-0.1-2.9-1.5-2.9-3.1C23.3,25.1,41.5,7.5,64,7.5c22.1,0,40.3,17.3,41.5,39.4 c0.1,1.7-1.2,3.1-2.8,3.2c-1.7,0.1-3.1-1.2-3.2-2.8c-1-18.9-16.6-33.7-35.5-33.7c-19.2,0-34.8,15.1-35.6,34.4 C28.4,49.5,27,50.8,25.4,50.8z"/><path fill="#454b54" d="M96.3,62.8c-1.7,0-3-1.3-3-3c0-23.4-10.7-36.9-29.3-36.9c-12.8,0-21.9,6.3-26.3,18.2 c-0.6,1.6-2.3,2.3-3.9,1.8c-1.6-0.6-2.3-2.3-1.8-3.9c5.4-14.2,16.7-22.1,32-22.1c22.1,0,35.3,16,35.3,42.9 C99.3,61.5,97.9,62.8,96.3,62.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="64px" height="64px" fill-rule="nonzero"><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><path d="M202.5,241.644h-149c-1.657,0 -3,-1.343 -3,-3v-8c0,-1.657 1.343,-3 3,-3h149c1.657,0 3,1.343 3,3v8c0,1.657 -1.343,3 -3,3z" fill="#4671c6"></path><path d="M202.5,243.644h-149c-2.757,0 -5,-2.243 -5,-5v-8c0,-2.757 2.243,-5 5,-5h149c2.757,0 5,2.243 5,5v8c0,2.757 -2.243,5 -5,5zM53.5,229.644c-0.551,0 -1,0.449 -1,1v8c0,0.551 0.449,1 1,1h149c0.552,0 1,-0.449 1,-1v-8c0,-0.551 -0.448,-1 -1,-1z" fill="#3762cc"></path><path d="M35.331,143.801c-1.657,0 -3,-1.343 -3,-3v-4c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v4c0,1.656 -1.344,3 -3,3z" fill="#a4c9ff"></path><path d="M215.615,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM198.879,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM182.143,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM165.406,199.536h-7.875c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.875c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM148.671,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM131.935,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM115.198,199.536h-7.875c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.875c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM98.462,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM81.727,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.344,3 -3,3zM64.99,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM48.254,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM220.686,194.032c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.343,3 -3,3zM35.331,194.009c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.344,3 -3,3zM220.686,177.296c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.343,3 -3,3zM35.331,177.273c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.344,3 -3,3zM220.686,160.56c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.343,3 -3,3zM35.331,160.537c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.344,3 -3,3z" fill="#a4c9ff"></path><path d="M220.686,143.801c-1.657,0 -3,-1.343 -3,-3v-4c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v4c0,1.656 -1.343,3 -3,3z" fill="#a4c9ff"></path><ellipse cx="220.686" cy="112.489" rx="32.314" ry="32.095" fill="#6bdddd"></ellipse><path d="M169.779,136.801h-23.373v0.671c0,9.976 -8.087,18.062 -18.062,18.062c-9.975,0 -18.062,-8.087 -18.062,-18.062v-0.671h-23.373c-8.97,0 -16.242,7.272 -16.242,16.242v51.889h115.355v-51.889c0,-8.97 -7.272,-16.242 -16.243,-16.242z" fill="#e0ebfc"></path><path d="M128.344,128.869c-6.43,0 -12.538,-1.352 -18.062,-3.772v12.375c0,9.976 8.087,18.062 18.062,18.062c9.975,0 18.062,-8.087 18.062,-18.062v-12.375c-5.524,2.421 -11.632,3.772 -18.062,3.772z" fill="#f9cfcf"></path><path d="M186.021,206.932h-115.354c-1.104,0 -2,-0.896 -2,-2v-51.889c0,-10.059 8.184,-18.242 18.242,-18.242h23.373c1.104,0 2,0.896 2,2v0.67c0,8.857 7.206,16.063 16.062,16.063c8.856,0 16.063,-7.206 16.063,-16.063v-0.67c0,-1.104 0.896,-2 2,-2h23.373c10.059,0 18.242,8.184 18.242,18.242v51.889c-0.001,1.105 -0.896,2 -2.001,2zM72.667,202.932h111.355v-49.889c0,-7.853 -6.389,-14.242 -14.242,-14.242h-21.417c-0.687,10.445 -9.403,18.733 -20.019,18.733c-10.616,0 -19.332,-8.288 -20.019,-18.733h-21.416c-7.853,0 -14.242,6.389 -14.242,14.242z" fill="#3762cc"></path><path d="M128.344,157.534c-11.062,0 -20.062,-9 -20.062,-20.063v-12.375c0,-0.675 0.341,-1.305 0.906,-1.674c0.565,-0.37 1.279,-0.428 1.897,-0.158c5.458,2.392 11.265,3.604 17.259,3.604c5.995,0 11.802,-1.213 17.26,-3.604c0.618,-0.27 1.332,-0.211 1.896,0.158c0.565,0.37 0.906,0.999 0.906,1.674v12.375c0,11.063 -9,20.063 -20.062,20.063zM112.282,128.052v9.419c0,8.857 7.206,16.063 16.062,16.063c8.856,0 16.063,-7.206 16.063,-16.063v-9.419c-5.138,1.871 -10.527,2.817 -16.063,2.817c-5.535,0 -10.925,-0.947 -16.062,-2.817z" fill="#3762cc"></path><path d="M173.087,73.675h-5.851v-5.907c0,-5.827 -2.859,-10.974 -7.239,-14.155c0.018,-0.027 0.032,-0.055 0.05,-0.082c-4.52,6.895 -12.313,11.45 -21.Line truncated
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="1.5" y="1.5" width="29" height="29" rx="8.5" fill="#C9F24E"/>
|
||||
<circle cx="10.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
<circle cx="16" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
<circle cx="21.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 298 B |
@@ -0,0 +1,18 @@
|
||||
<svg width="32" height="32" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Chat bubble background -->
|
||||
<path
|
||||
d="M95 20H25C22.2386 20 20 22.2386 20 25V75C20 77.7614 22.2386 80 25 80H45L55 95L65 80H95C97.7614 80 100 77.7614 100 75V25C100 22.2386 97.7614 20 95 20Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
|
||||
<!-- Letter C -->
|
||||
<path
|
||||
d="M75 40C75 40 65 35 60 35C50 35 45 42 45 50C45 58 50 65 60 65C65 65 75 60 75 60V52C75 52 68 55 63 55C57 55 53 53 53 50C53 47 57 45 63 45C68 45 75 48 75 48V40Z"
|
||||
fill="#f34611"
|
||||
/>
|
||||
|
||||
<!-- Dots -->
|
||||
<!-- <circle cx="45" cy="50" r="3" fill="#BFEEAA"/>
|
||||
<circle cx="60" cy="50" r="3" fill="#BFEEAA"/>
|
||||
<circle cx="75" cy="50" r="3" fill="#BFEEAA"/> -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 728 B |
@@ -0,0 +1,20 @@
|
||||
<svg width="1200" height="1200" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Chat bubble background with subtle outline -->
|
||||
<path
|
||||
d="M95 20H25C22.2386 20 20 22.2386 20 25V75C20 77.7614 22.2386 80 25 80H45L55 95L65 80H95C97.7614 80 100 77.7614 100 75V25C100 22.2386 97.7614 20 95 20Z"
|
||||
fill="#f34611"
|
||||
stroke="#EEEEEE"
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Letter C -->
|
||||
<path
|
||||
d="M75 40C75 40 65 35 60 35C50 35 45 42 45 50C45 58 50 65 60 65C65 65 75 60 75 60V52C75 52 68 55 63 55C57 55 53 53 53 50C53 47 57 45 63 45C68 45 75 48 75 48V40Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
|
||||
<!-- Dots -->
|
||||
<!-- <circle cx="45" cy="50" r="3" fill="#BFEEAA"/>
|
||||
<circle cx="60" cy="50" r="3" fill="#BFEEAA"/>
|
||||
<circle cx="75" cy="50" r="3" fill="#BFEEAA"/> -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,32 @@
|
||||
// Runtime configuration - this file gets replaced at container startup
|
||||
// Assigned to `self` so the service worker can importScripts() it too;
|
||||
// in a page self === window, so window.APP_CONFIG still works.
|
||||
self.APP_CONFIG = {
|
||||
// API URLs
|
||||
API_URL: "https://devapi.chattermate.chat/api/v1",
|
||||
WS_URL: "wss://devapi.chattermate.chat",
|
||||
WIDGET_URL: "https://devapp.chattermate.chat",
|
||||
|
||||
// Firebase Configuration
|
||||
FIREBASE_API_KEY: "AIzaSyDTwDgANnBItYx39qm-6shq4cUws6QY4wI",
|
||||
FIREBASE_AUTH_DOMAIN: "chattermate-13e0a.firebaseapp.com",
|
||||
FIREBASE_PROJECT_ID: "chattermate-13e0a",
|
||||
FIREBASE_MESSAGING_SENDER_ID: "680125690355",
|
||||
FIREBASE_APP_ID: "1:680125690355:web:466f50999a6c91935ae286",
|
||||
FIREBASE_STORAGE_BUCKET: "chattermate-13e0a.firebasestorage.app",
|
||||
FIREBASE_MEASUREMENT_ID: "G-7LGB58R2Q7",
|
||||
FIREBASE_VAPID_KEY: "BJKnggErez2qK3AxBhHyEV-EoS9lfcWO1GDXhRwUKIHbT_PH23vhY_8HRIz_KyuC-0gbuViSrtSMiFp7zgyYY4g",
|
||||
|
||||
// Razorpay Checkout
|
||||
RAZORPAY_KEY_ID: "rzp_test_T92OR9gqPBht0N",
|
||||
GTM_ID: "",
|
||||
|
||||
// Google Fonts API
|
||||
GOOGLE_FONTS_API_KEY: "AIzaSyCTQCSK_ybLEglabeusbyekQGEyvFr2C5o",
|
||||
|
||||
DEMO_WIDGET_ID: "a6ee36e9-b70e-4b12-8f94-cc5a5b147f26",
|
||||
// Node Environment
|
||||
NODE_ENV: "development",
|
||||
HOST: "0.0.0.0"
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Runtime configuration - this file gets replaced at container startup
|
||||
// Assigned to `self` so the service worker can importScripts() it too;
|
||||
// in a page self === window, so window.APP_CONFIG still works.
|
||||
self.APP_CONFIG = {
|
||||
// API URLs
|
||||
API_URL: "http://localhost:8000/api/v1",
|
||||
WS_URL: "ws://localhost:8000",
|
||||
WIDGET_URL: "http://localhost:8000",
|
||||
|
||||
// Firebase Configuration
|
||||
FIREBASE_API_KEY: "",
|
||||
FIREBASE_AUTH_DOMAIN: "",
|
||||
FIREBASE_PROJECT_ID: "",
|
||||
FIREBASE_MESSAGING_SENDER_ID: "680125690355",
|
||||
FIREBASE_APP_ID: "",
|
||||
FIREBASE_STORAGE_BUCKET: "",
|
||||
FIREBASE_MEASUREMENT_ID: "",
|
||||
FIREBASE_VAPID_KEY: "",
|
||||
|
||||
// Razorpay Checkout
|
||||
RAZORPAY_KEY_ID: "rzp_test_T92OR9gqPBht0N",
|
||||
GTM_ID: "",
|
||||
|
||||
// Google Fonts API
|
||||
GOOGLE_FONTS_API_KEY: "",
|
||||
|
||||
// Node Environment
|
||||
NODE_ENV: "development",
|
||||
HOST: "0.0.0.0"
|
||||
};
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="1.5" y="1.5" width="29" height="29" rx="8.5" fill="#C9F24E"/>
|
||||
<circle cx="10.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
<circle cx="16" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
<circle cx="21.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 298 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="20" y="20" width="80" height="80" rx="22" fill="#C9F24E"/>
|
||||
<circle cx="42" cy="60" r="6" fill="#0B0C10"/>
|
||||
<circle cx="60" cy="60" r="6" fill="#0B0C10"/>
|
||||
<circle cx="78" cy="60" r="6" fill="#0B0C10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
OAuth redirect target for Facebook Login for Business (Messenger/Instagram).
|
||||
|
||||
Meta redirects the login popup here with ?code&state; this relays them to the
|
||||
opener and closes. It's a static file (not the SPA) so the popup stays light,
|
||||
and it must be registered as a Valid OAuth Redirect URI on the Meta app.
|
||||
-->
|
||||
<html>
|
||||
<head><meta charset="utf-8" /><title>Connecting…</title></head>
|
||||
<body>
|
||||
Connecting…
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search)
|
||||
var message = {
|
||||
source: 'meta-oauth',
|
||||
code: params.get('code'),
|
||||
state: params.get('state'),
|
||||
error: params.get('error_description') || params.get('error'),
|
||||
}
|
||||
if (window.opener) {
|
||||
window.opener.postMessage(message, window.location.origin)
|
||||
}
|
||||
window.close()
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -0,0 +1,888 @@
|
||||
(()=>{var wt=(at,ot,e)=>new Promise((M,d)=>{var j=x=>{try{B(e.next(x))}catch(N){d(N)}},V=x=>{try{B(e.throw(x))}catch(N){d(N)}},B=x=>x.done?M(x.value):Promise.resolve(x.value).then(j,V);B((e=e.apply(at,ot)).next())});window.chattermateId;window.chattermateBaseUrl;window.ChatterMate;window.chattermateConfig;(function(){function at(t){return/^#[0-9A-F]{6}$/i.test(t)}function ot(){return typeof window!="undefined"&&window.chattermateBaseUrl?window.chattermateBaseUrl:typeof window!="undefined"&&window.APP_CONFIG&&window.APP_CONFIG.API_URL?window.APP_CONFIG.API_URL:"https://api.chattermate.chat/api/v1"}let e={baseUrl:ot(),containerId:"chattermate-container",backdropId:"chattermate-backdrop",buttonId:"chattermate-button",chatBubbleColor:"#f34611",loadingContainerId:"chattermate-loading",tokenKey:"ctid",tokenProvider:null,containerBottom:100,containerRight:20,containerWidth:400,containerHeight:560,launcherBottom:20,launcherRight:20,side:"right",displayMode:"floating",launcher:!0,sidebarWidth:420,searchBarWidth:320,searchPlaceholder:"Ask anything...",zIndex:999999,trigger:null,hotkey:!0,surfaceIsPalette:null,chatStyle:null,chatInitiationMessages:[],initiationMessageId:"chattermate-initiation",initiationShownKey:"ctim_shown",unreadCount:0,hasOpened:!1},M={};function d(t,n){e[t]=n,M[t]=!0}let j=["floating","sidebar-left","sidebar-right","search-bar"],V=["ASK_ANYTHING","AURORA"],B=680,x=620,N=140,S={width:!1,height:!1},P=0;function q(){return e.surfaceIsPalette===!1?!1:(V.indexOf(e.chatStyle)!==-1||e.displayMode==="search-bar")&&e.displayMode!=="sidebar-left"&&e.displayMode!=="sidebar-right"}function Z(){let t=document.getElementById(e.containerId);if(!t)return;if(!q()||!P){t.style.height="";return}let n=Math.min(rt().height,Math.round(window.innerHeight*.76));t.style.height=Math.max(N,Math.min(n,P))+"px"}function rt(){return{width:S.width?e.containerWidth:B,height:S.height?e.containerHeight:x}}function _t(t){if(!t||typeof t!="object")return;let n=function(i,o){M[i]||(e[i]=o)};j.indexOf(t.mode)!==-1&&n("displayMode",t.mode),(t.side==="left"||t.side==="right")&&n("side",t.side),typeof t.launcher=="boolean"&&n("launcher",t.launcher),m(t.width)&&(n("containerWidth",Math.max(280,t.width)),M.containerWidth||(S.width=!0)),m(t.height)&&(n("containerHeight",Math.max(400,t.height)),M.containerHeight||(S.height=!0)),m(t.sidebar_width)&&n("sidebarWidth",Math.max(280,t.sidebar_width)),typeof t.search_placeholder=="string"&&t.search_placeholder&&n("searchPlaceholder",t.search_placeholder.slice(0,80)),m(t.offset_bottom)&&n("launcherBottom",Math.max(0,t.offset_bottom)),m(t.offset_side)&&n("launcherRight",Math.max(0,t.offset_side)),m(t.z_index)&&n("zIndex",Math.max(1,Math.floor(t.z_index)))}function It(){let t=q(),n=t?rt():{width:e.containerWidth,height:e.containerHeight};return{type:"WIDGET_DISPLAY",mode:t?"ask-ai":e.displayMode,width:n.width,height:n.height,hotkey:e.hotkey!==!1}}let c=null,R=[],X=!1,g=null,T=null,y=!1;function J(t){c?t():y||R.push(t)}let Q={},st=!1;function _(t,n){let i=Q[t];!i||!i.length||i.slice().forEach(function(o){try{o(n)}catch(h){}})}function Lt(t){let n=/^#?([0-9a-f]{6})$/i.exec((t||"").trim());if(!n)return"#0B0C10";let i=parseInt(n[1],16);return(.299*(i>>16&255)+.587*(i>>8&255)+.114*(i&255))/255>.6?"#0B0C10":"#FFFFFF"}let ct=!1,E=null,lt=[];function F(){if(ct)return;ct=!0,E&&(clearTimeout(E),E=null);let t=document.getElementById(e.buttonId);t&&t.classList.remove("chattermate-pending"),lt.splice(0).forEach(n=>{try{n()}catch(i){}})}function O(){let t=document.getElementById(e.buttonId);if(!t)return;let n=t.querySelector(".cm-badge");if(!n)return;let i=e.hasOpened?e.unreadCount:(e.chatInitiationMessages||[]).length;n.textContent=i>99?"99+":String(i),n.style.display=i>0&&!t.classList.contains("active")?"flex":"none"}function W(){let t=e.displayMode,n=t==="sidebar-left"||t==="sidebar-right",i=t==="sidebar-left"?"left":t==="sidebar-right"?"right":e.side,o=t==="search-bar"?48:64,h=V.indexOf(e.chatStyle)===-1,r=q(),l=rt(),w=document.createElement("style");w.id="chattermate-styles",w.textContent=`
|
||||
/* ===== Rounded-square launcher (design comp) ===== */
|
||||
#${e.buttonId} {
|
||||
position: fixed;
|
||||
bottom: ${e.launcherBottom}px;
|
||||
${i}: ${e.launcherRight}px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 20px 20px 20px 6px;
|
||||
background: ${e.chatBubbleColor};
|
||||
color: ${Lt(e.chatBubbleColor)};
|
||||
box-shadow: 0 16px 40px -8px ${e.chatBubbleColor}cc, inset 0 0 0 1px rgba(255,255,255,0.08);
|
||||
cursor: pointer;
|
||||
z-index: ${e.zIndex};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.3s cubic-bezier(.34,1.3,.5,1), opacity 320ms ease;
|
||||
animation: chattermate-float 4s ease-in-out infinite;
|
||||
}
|
||||
#${e.buttonId}.active { animation: none; }
|
||||
/* Stay fully invisible until the org's real brand color is known (or the
|
||||
widget errors out / times out) \u2014 avoids ever flashing the wrong color. */
|
||||
#${e.buttonId}.chattermate-pending {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#${e.buttonId}:hover { transform: scale(1.06); }
|
||||
|
||||
/* Expanding rings (closed state only) */
|
||||
#${e.buttonId} .cm-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 20px 20px 20px 6px;
|
||||
border: 1.5px solid ${e.chatBubbleColor};
|
||||
animation: chattermate-ring 2.4s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
#${e.buttonId} .cm-ring.r2 { animation-delay: 1.2s; }
|
||||
#${e.buttonId}.active .cm-ring { display: none; }
|
||||
|
||||
/* Pulsing 3-dot mark (closed). Absolutely centered so it stays pinned to the
|
||||
button's center regardless of the button's display mode (flex on desktop,
|
||||
block when .mobile-closed) or viewport. */
|
||||
#${e.buttonId} .cm-dots {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
#${e.buttonId} .cm-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: chattermate-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
#${e.buttonId} .cm-dot:nth-child(2) { animation-delay: .18s; }
|
||||
#${e.buttonId} .cm-dot:nth-child(3) { animation-delay: .36s; }
|
||||
#${e.buttonId}.active .cm-dots { display: none; }
|
||||
|
||||
/* Chevron (open) \u2014 absolutely centered, pinned to the button center. */
|
||||
#${e.buttonId} .cm-chevron {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: currentColor;
|
||||
}
|
||||
#${e.buttonId}.active .cm-chevron { display: block; }
|
||||
|
||||
/* Unread badge = number of nudges */
|
||||
#${e.buttonId} .cm-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: #FF8A73;
|
||||
color: #0B0C10;
|
||||
font: 700 11.5px/1 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #F5F3EE;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
display: none;
|
||||
}
|
||||
#${e.buttonId}.active .cm-badge { display: none; }
|
||||
|
||||
#${e.buttonId}.loading .cm-dots,
|
||||
#${e.buttonId}.loading .cm-chevron { visibility: hidden; }
|
||||
#${e.buttonId}.loading:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -8px 0 0 -8px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: chattermate-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
/* Search-bar trigger content: hidden unless search-bar mode (below) shows it. */
|
||||
#${e.buttonId} .cm-search { display: none; }
|
||||
|
||||
${t==="search-bar"?`
|
||||
@media (min-width: 769px) {
|
||||
/* ===== Search-bar mode: the launcher becomes a search-input-like bar.
|
||||
Same element, same click/toggle/badge logic. Mobile keeps the bubble. ===== */
|
||||
#${e.buttonId} {
|
||||
width: ${e.searchBarWidth}px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
color: #374151;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14), inset 0 0 0 1.5px ${e.chatBubbleColor}55;
|
||||
animation: none;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#${e.buttonId}:hover { transform: scale(1.02); }
|
||||
#${e.buttonId} .cm-ring,
|
||||
#${e.buttonId} .cm-dots,
|
||||
#${e.buttonId} .cm-chevron,
|
||||
#${e.buttonId}.active .cm-chevron { display: none; }
|
||||
/* Dark spinner \u2014 the stock white one vanishes on the white bar. */
|
||||
#${e.buttonId}.loading .cm-search { visibility: hidden; }
|
||||
#${e.buttonId}.loading:after {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
border-top-color: ${e.chatBubbleColor};
|
||||
}
|
||||
#${e.buttonId} .cm-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
#${e.buttonId} .cm-search svg {
|
||||
flex-shrink: 0;
|
||||
color: ${e.chatBubbleColor};
|
||||
}
|
||||
#${e.buttonId} .cm-search-placeholder {
|
||||
font: 400 14px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
`:""}
|
||||
|
||||
@keyframes chattermate-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes chattermate-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
||||
@keyframes chattermate-ring { 0% { transform: scale(.85); opacity: .5; } 100% { transform: scale(1.6); opacity: 0; } }
|
||||
@keyframes chattermate-pulse { 0%, 100% { opacity: .45; } 50% { opacity: 1; } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#${e.buttonId},
|
||||
#${e.buttonId} .cm-ring,
|
||||
#${e.buttonId} .cm-dot,
|
||||
.initiation-orb { animation: none !important; }
|
||||
/* Open and close in place: no slide, scale or fade for people who asked for less motion. */
|
||||
#${e.buttonId},
|
||||
#${e.containerId},
|
||||
#${e.backdropId} { transition: none !important; }
|
||||
}
|
||||
|
||||
#${e.containerId} {
|
||||
position: fixed;
|
||||
/* The window sits above the launcher; when the launcher is moved (via
|
||||
ChatterMate.init position / setPosition) the window shifts by the same
|
||||
delta so the two stay together. Mobile is full-screen (media query below)
|
||||
and is unaffected. */
|
||||
bottom: ${(e.containerBottom||100)+((e.launcherBottom||20)-20)}px;
|
||||
${i}: ${(e.containerRight||20)+((e.launcherRight||20)-20)}px;
|
||||
width: ${e.containerWidth||384}px;
|
||||
max-width: calc(100vw - 48px);
|
||||
height: ${e.containerHeight}px;
|
||||
max-height: calc(100vh - 132px);
|
||||
background: transparent;
|
||||
z-index: ${e.zIndex};
|
||||
overflow: hidden;
|
||||
/* Animated open/close with slide + fade */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1), transform 360ms cubic-bezier(0.22, 1, 0.36, 1), visibility 0s linear 360ms;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#${e.containerId}.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1), transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Backdrop element: always in the DOM, so keep it out of the host page's flow
|
||||
even when it is not in use \u2014 an unstyled div in <body> adds a phantom row to
|
||||
a flex/grid body layout. */
|
||||
#${e.backdropId} {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
${r?`
|
||||
/* ===== Ask AI: centered command palette (\u2318K), the pattern people expect from
|
||||
an "Ask anything" surface \u2014 dimmed page, top-anchored so the eye lands on
|
||||
the input rather than the middle of the screen. ===== */
|
||||
#${e.containerId} {
|
||||
top: 12vh;
|
||||
bottom: auto;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: min(${l.width}px, calc(100vw - 32px));
|
||||
max-width: none;
|
||||
/* A cross-document iframe can't size its embedder, so this is a starting
|
||||
height only: the palette measures its content and posts WIDGET_RESIZE,
|
||||
which sets an inline height (clamped to this ceiling). height:100% against
|
||||
an auto-height parent would collapse to the 150px replaced default. */
|
||||
height: min(${l.height}px, 76vh);
|
||||
max-height: 76vh;
|
||||
transform: translate(-50%, -8px) scale(0.98);
|
||||
z-index: ${e.zIndex+1};
|
||||
transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 360ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
height 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
visibility 0s linear 360ms;
|
||||
}
|
||||
#${e.containerId}.active {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 420ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
height 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
#${e.containerId} .chattermate-iframe {
|
||||
border-radius: 16px;
|
||||
}
|
||||
#${e.backdropId} {
|
||||
display: block;
|
||||
background: rgba(9, 9, 14, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: ${e.zIndex};
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 220ms ease, visibility 0s linear 220ms;
|
||||
}
|
||||
#${e.backdropId}.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
/* The palette dims the page and closes on backdrop click / Esc, so the launcher
|
||||
would just be a stray button floating over the dim. */
|
||||
#${e.buttonId}.active {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
/* Full-screen on phones like every other mode. The main mobile block below
|
||||
pins the edges but never resets transform \u2014 without this the centering
|
||||
translate would shove the panel half a screen to the left. */
|
||||
#${e.containerId},
|
||||
#${e.containerId}.active { transform: none !important; }
|
||||
#${e.backdropId} { display: none; }
|
||||
}
|
||||
`:""}
|
||||
|
||||
${n?`
|
||||
/* ===== Sidebar mode: full-height drawer hugging the ${i} edge ===== */
|
||||
#${e.containerId} {
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
${i}: 0;
|
||||
${i==="left"?"right: auto;":"left: auto;"}
|
||||
width: ${e.sidebarWidth}px;
|
||||
max-width: 100vw;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
transform: translateX(${i==="left"?"-100%":"100%"});
|
||||
box-shadow: ${i==="left"?"":"-"}12px 0 40px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
#${e.containerId}.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
#${e.containerId} .chattermate-iframe {
|
||||
border-radius: 0;
|
||||
}
|
||||
${h?`
|
||||
/* The drawer's own header chevron closes it, so keeping the launcher on screen
|
||||
would just park a duplicate button beside the panel. Hide it while open. */
|
||||
#${e.buttonId}.active {
|
||||
display: none;
|
||||
}
|
||||
`:`
|
||||
/* ASK_ANYTHING / AURORA draw no in-panel chevron, and the drawer would paint over
|
||||
the launcher (later sibling, equal z-index) \u2014 leaving nothing to click. Keep it
|
||||
reachable: slide it out beside the drawer edge, one layer up. */
|
||||
#${e.buttonId} {
|
||||
z-index: ${e.zIndex+1};
|
||||
transition: transform 0.3s cubic-bezier(.34,1.3,.5,1), opacity 320ms ease,
|
||||
left 360ms cubic-bezier(0.22, 1, 0.36, 1), right 360ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
#${e.buttonId}.active {
|
||||
${i}: ${e.sidebarWidth+20}px;
|
||||
}
|
||||
`}
|
||||
`:""}
|
||||
|
||||
/* Clean border around the widget container */
|
||||
/* The widget panel (inside the iframe) draws its own theme-aware border, so the
|
||||
container no longer adds one \u2014 avoids a double border (and a white line on dark themes). */
|
||||
|
||||
.chattermate-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: block;
|
||||
border-radius: 24px;
|
||||
/* Rely on corner shadows from container */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#chattermate-mobile-close {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: ${e.zIndex+1};
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#chattermate-mobile-topbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
z-index: ${e.zIndex};
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#chattermate-mobile-topbar.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#chattermate-mobile-topbar .topbar-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Only ASK_ANYTHING (which has no in-panel header chevron) uses the floating
|
||||
mobile-close; other styles close via the header chevron, so don't double up. */
|
||||
.ask-anything-mobile #chattermate-mobile-close.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#${e.containerId} {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
height: 100dvh !important; /* Dynamic viewport height for mobile browsers */
|
||||
/* Reset the desktop max-width/height clamp \u2014 without this the desktop rule's
|
||||
max-width: calc(100vw - 48px) / max-height: calc(100vh - 132px) keeps the
|
||||
window a floating panel instead of going full-screen on mobile. */
|
||||
max-width: 100vw !important;
|
||||
max-height: 100vh !important;
|
||||
max-height: 100dvh !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
bottom: 0 !important;
|
||||
right: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
position: fixed !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#${e.containerId}.active {
|
||||
bottom: 0 !important;
|
||||
}
|
||||
|
||||
.chattermate-iframe {
|
||||
border-radius: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
height: 100dvh !important; /* Dynamic viewport height for mobile browsers */
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#${e.buttonId} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#${e.buttonId}.mobile-closed {
|
||||
/* flex (not block) keeps the launcher's centering context; the dots/chevron
|
||||
are absolutely centered regardless, but this keeps it consistent. */
|
||||
display: flex !important;
|
||||
width: 56px !important;
|
||||
height: 56px !important;
|
||||
/* Respect the configured position on mobile too, so the closed launcher can
|
||||
clear a fixed bottom nav bar (the main reason to move it up). */
|
||||
bottom: ${e.launcherBottom}px !important;
|
||||
${i}: ${e.launcherRight}px !important;
|
||||
}
|
||||
|
||||
.ask-anything-mobile #chattermate-mobile-close.active {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
#chattermate-mobile-close:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ASK_ANYTHING style specific mobile topbar */
|
||||
.ask-anything-mobile #chattermate-mobile-topbar.active {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.ask-anything-mobile #chattermate-mobile-close.active {
|
||||
top: 15px !important;
|
||||
right: 15px !important;
|
||||
z-index: ${e.zIndex+2} !important;
|
||||
}
|
||||
|
||||
/* When topbar is visible, push iframe down to avoid overlap */
|
||||
.ask-anything-mobile .chattermate-iframe {
|
||||
top: 60px !important;
|
||||
height: calc(100vh - 60px) !important;
|
||||
height: calc(100dvh - 60px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
#chattermate-mobile-close {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat Initiation Message Styles */
|
||||
#${e.initiationMessageId} {
|
||||
position: fixed !important;
|
||||
/* Sit above the launcher (+12px gap) and align to its edge on the active side,
|
||||
so it never overlaps the icon regardless of the configured position/mode. */
|
||||
bottom: ${(e.launcherBottom||20)+o+12}px !important;
|
||||
${i}: ${e.launcherRight||20}px !important;
|
||||
max-width: 260px !important;
|
||||
background: white !important;
|
||||
padding: 12px 36px 12px 14px !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.1) !important;
|
||||
z-index: ${e.zIndex-1} !important;
|
||||
cursor: pointer !important;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(10px) scale(0.95);
|
||||
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
box-sizing: border-box !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
line-height: 1.4 !important;
|
||||
text-align: left !important;
|
||||
vertical-align: baseline !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
/* Clamp the nudge text to 4 lines with an ellipsis so a long welcome/initiation
|
||||
message can never balloon the bubble over the page or into the launcher.
|
||||
Applied to the text node only, so the orb (left:-34px) is never clipped. */
|
||||
#${e.initiationMessageId} .initiation-message-text {
|
||||
margin: 0 !important;
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 4 !important;
|
||||
-webkit-box-orient: vertical !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Siri-style orb avatar beside the nudge (design comp) */
|
||||
.initiation-orb {
|
||||
position: absolute !important;
|
||||
left: -34px !important;
|
||||
bottom: 2px !important;
|
||||
width: 26px !important;
|
||||
height: 26px !important;
|
||||
border-radius: 50% !important;
|
||||
background: conic-gradient(from 0deg, #C9F24E, #9D8CFF, #5FE3D6, #FF8A73, #C9F24E) !important;
|
||||
box-shadow: 0 4px 14px rgba(157, 140, 255, 0.4) !important;
|
||||
animation: chattermate-orb-spin 6s linear infinite !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
@keyframes chattermate-orb-spin { to { transform: rotate(360deg); } }
|
||||
${i==="left"?`
|
||||
/* On the left edge the orb (which protrudes 34px past the bubble) would clip
|
||||
off-screen \u2014 mirror it to the bubble's page-facing side. */
|
||||
.initiation-orb {
|
||||
left: auto !important;
|
||||
right: -34px !important;
|
||||
}
|
||||
`:""}
|
||||
|
||||
#${e.initiationMessageId}.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
animation: chattermate-bounce-in 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
@keyframes chattermate-bounce-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
#${e.initiationMessageId}:hover {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
#${e.initiationMessageId}::after {
|
||||
content: '' !important;
|
||||
position: absolute !important;
|
||||
bottom: -7px !important;
|
||||
${i}: 30px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
min-width: 14px !important;
|
||||
min-height: 14px !important;
|
||||
max-width: 14px !important;
|
||||
max-height: 14px !important;
|
||||
background: white !important;
|
||||
transform: rotate(45deg) !important;
|
||||
box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.06) !important;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%) !important;
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
z-index: -1 !important;
|
||||
}
|
||||
|
||||
.initiation-message-text {
|
||||
font-size: 13px !important;
|
||||
line-height: 1.4 !important;
|
||||
color: #374151 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 4px 0 0 !important;
|
||||
position: relative !important;
|
||||
z-index: 1 !important;
|
||||
min-height: 18px !important;
|
||||
box-sizing: border-box !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
text-align: left !important;
|
||||
vertical-align: baseline !important;
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
text-decoration: none !important;
|
||||
text-transform: none !important;
|
||||
letter-spacing: normal !important;
|
||||
word-spacing: normal !important;
|
||||
white-space: normal !important;
|
||||
overflow-wrap: break-word !important;
|
||||
word-wrap: break-word !important;
|
||||
}
|
||||
|
||||
.initiation-message-text::after {
|
||||
content: '|';
|
||||
animation: blink 1s step-end infinite;
|
||||
margin-left: 2px;
|
||||
color: ${e.chatBubbleColor};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.initiation-message-text.typing-complete::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.initiation-close {
|
||||
position: absolute !important;
|
||||
top: 10px !important;
|
||||
right: 10px !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
min-width: 20px !important;
|
||||
min-height: 20px !important;
|
||||
max-width: 20px !important;
|
||||
max-height: 20px !important;
|
||||
background: rgba(0, 0, 0, 0.04) !important;
|
||||
border: none !important;
|
||||
border-radius: 5px !important;
|
||||
cursor: pointer !important;
|
||||
opacity: 0.5 !important;
|
||||
transition: all 0.2s ease !important;
|
||||
z-index: 2 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
text-decoration: none !important;
|
||||
font-size: 0 !important;
|
||||
line-height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.initiation-close:hover {
|
||||
opacity: 1 !important;
|
||||
background: rgba(0, 0, 0, 0.08) !important;
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
|
||||
.initiation-close::before,
|
||||
.initiation-close::after {
|
||||
content: '' !important;
|
||||
position: absolute !important;
|
||||
width: 9px !important;
|
||||
height: 1.5px !important;
|
||||
min-width: 9px !important;
|
||||
min-height: 1.5px !important;
|
||||
max-width: 9px !important;
|
||||
max-height: 1.5px !important;
|
||||
background: #4a5568 !important;
|
||||
border-radius: 1px !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
margin: -0.75px 0 0 -4.5px !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.initiation-close::before {
|
||||
transform: rotate(45deg) !important;
|
||||
}
|
||||
|
||||
.initiation-close::after {
|
||||
transform: rotate(-45deg) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#${e.initiationMessageId} {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error UI Styles */
|
||||
.chattermate-error-ui {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chattermate-error-ui::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, rgba(168, 85, 247, 0.06) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chattermate-error-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.chattermate-error-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(120, 120, 140, 0.12);
|
||||
color: #4b5563;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.chattermate-error-close:hover {
|
||||
background: rgba(120, 120, 140, 0.22);
|
||||
}
|
||||
|
||||
.chattermate-error-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
|
||||
border-radius: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.chattermate-error-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.chattermate-error-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.chattermate-error-message {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
margin: 0 0 24px 0;
|
||||
max-width: 280px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.chattermate-error-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
opacity: 0.8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
.chattermate-error-footer-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chattermate-error-footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chattermate-error-ui {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chattermate-error-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.chattermate-error-icon-wrapper {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.chattermate-error-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.chattermate-error-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chattermate-error-message {
|
||||
font-size: 13px;
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
${e.launcher?"":`
|
||||
/* Launcher hidden by config \u2014 the site opens the chat with its own trigger or
|
||||
the JS API. Last in the sheet so it also beats the mobile .mobile-closed
|
||||
re-show (display: flex !important at equal specificity). */
|
||||
#${e.buttonId}, #${e.buttonId}.mobile-closed { display: none !important; }
|
||||
`}
|
||||
`;let f=document.getElementById("chattermate-styles");f&&f.remove(),document.head.appendChild(w),vt()}function vt(){let t=document.getElementById(e.buttonId);if(!t)return;let n=t.querySelector(".cm-search-placeholder");n&&n.textContent!==e.searchPlaceholder&&(n.textContent=e.searchPlaceholder)}function C(t){if(typeof t!="string")return null;let n=t.trim();return!n||n==="undefined"||n==="null"?null:n}function dt(){return C(window.chattermateToken)||C(localStorage.getItem(e.tokenKey))}function ht(t){let n=C(t);if(!n)return null;let i=n.split(".")[1];if(!i)return null;try{let o=JSON.parse(atob(i.replace(/-/g,"+").replace(/_/g,"/")));return o.sub||o.customer_id||null}catch(o){return null}}function tt(t){t&&localStorage.setItem(e.tokenKey,t)}function H(){localStorage.removeItem(e.tokenKey)}function U(){return window.innerWidth<=768}function zt(t){return t?t.replace(/\uFFFD/g,"").replace(/[��]/g,"").replace(/[\x00-\x1F\x7F-\x9F]/g,"").trim():""}function Bt(){if(!e.chatInitiationMessages||e.chatInitiationMessages.length===0)return null;let t=Math.floor(Math.random()*e.chatInitiationMessages.length),n=e.chatInitiationMessages[t];return n?zt(n):null}function Nt(){return U()||!e.launcher||!e.chatInitiationMessages||e.chatInitiationMessages.length===0?!1:!sessionStorage.getItem(e.initiationShownKey)}function pt(){sessionStorage.setItem(e.initiationShownKey,"true")}function Pt(){let t=Bt();if(!t)return null;let n=document.createElement("div");return n.id=e.initiationMessageId,n.innerHTML=`
|
||||
<span class="initiation-orb"></span>
|
||||
<button class="initiation-close" aria-label="Close"></button>
|
||||
<p class="initiation-message-text"></p>
|
||||
`,n.dataset.fullMessage=t,n}function Rt(t,n,i=50){let o=t.querySelector(".initiation-message-text");if(!o)return;let h=0,r=setInterval(()=>{h<n.length?(o.textContent+=n.charAt(h),h++):(clearInterval(r),o.classList.add("typing-complete"),t.typeInterval=null)},i);t.typeInterval=r}function Ft(t,n){if(!t||!Nt())return;document.body.appendChild(t),setTimeout(()=>{t.classList.add("show"),setTimeout(()=>{let o=t.dataset.fullMessage;o&&Rt(t,o,40)},300)},1500),t.addEventListener("click",o=>{o.target.classList.contains("initiation-close")||(t.typeInterval&&clearInterval(t.typeInterval),mt(t),n(),pt())});let i=t.querySelector(".initiation-close");i&&i.addEventListener("click",o=>{o.stopPropagation(),t.typeInterval&&clearInterval(t.typeInterval),mt(t),pt()})}function mt(t){t&&(t.classList.remove("show"),setTimeout(()=>{t.parentNode&&t.parentNode.removeChild(t)},400))}function ut(){y=!1,W();let t=document.createElement("div");t.id=e.buttonId,t.classList.add("chattermate-pending"),t.innerHTML=`
|
||||
<span class="cm-ring"></span>
|
||||
<span class="cm-ring r2"></span>
|
||||
<div class="cm-dots"><span class="cm-dot"></span><span class="cm-dot"></span><span class="cm-dot"></span></div>
|
||||
<span class="cm-chevron">⌄</span>
|
||||
<span class="cm-search">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.35-4.35"></path></svg>
|
||||
<span class="cm-search-placeholder"></span>
|
||||
</span>
|
||||
<span class="cm-badge"></span>
|
||||
`,E=setTimeout(F,8e3);let n=document.createElement("div");n.id=e.containerId;let i=document.createElement("div");i.id="chattermate-mobile-close",i.innerHTML=`
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12H19" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;let o=document.createElement("div");o.id="chattermate-mobile-topbar",o.innerHTML=`
|
||||
<h3 class="topbar-title">Chat</h3>
|
||||
<div style="width: 44px;"></div>
|
||||
`;let h=document.createElement("div");h.id=e.backdropId,document.body.appendChild(t),document.body.appendChild(h),document.body.appendChild(n),document.body.appendChild(i),document.body.appendChild(o),O(),vt();let r=!1,l=null,w=!1,f=null;function b(){r=!r,n.classList.toggle("active"),h.classList.toggle("active",r),t.classList.toggle("active"),i.classList.toggle("active"),r&&(e.hasOpened=!0,e.unreadCount=0),l&&l.contentWindow&&l.contentWindow.postMessage({type:"WIDGET_VISIBILITY",open:r},"*"),O(),r&&f&&(mt(f),f=null,pt()),U()?r?(t.classList.remove("mobile-closed"),t.style.animation=""):(t.classList.add("mobile-closed"),t.style.animation="chattermate-float 4s ease-in-out infinite",o.classList.remove("active"),document.body.classList.remove("ask-anything-mobile")):r&&i.classList.remove("active"),r&&l&&l.contentWindow.postMessage({type:"SCROLL_TO_BOTTOM"},"*"),_(r?"open":"close")}let K=null,bt=!1;function Yt(a){let s=String(a||"").slice(0,2e3);s&&(bt&&l&&l.contentWindow?l.contentWindow.postMessage({type:"PREFILL_MESSAGE",text:s},"*"):K=s)}let D=0;function Ct(){for(D++;n.firstChild;)n.removeChild(n.firstChild);l=null,g=null,T=null,bt=!1,st=!1,K=null,w=!1,t.classList.remove("loading"),P=0,Z()}function Gt(){r&&b(),Ct(),e.unreadCount=0,e.hasOpened=!1,O(),et()}function jt(){r&&b(),Ct(),window.removeEventListener("resize",Tt),window.removeEventListener("message",St),f&&(f.typeInterval&&clearInterval(f.typeInterval),ft(f),f=null),[t,h,n,i,o].forEach(ft),ft(document.getElementById("chattermate-styles")),document.body.classList.remove("ask-anything-mobile"),ct=!1,E&&(clearTimeout(E),E=null),lt.length=0,e.unreadCount=0,e.hasOpened=!1,c=null,R.length=0,X=!1,y=!0}function At(a){let s=document.createElement("div");s.className="chattermate-error-ui";let I=document.createElement("div");I.className="chattermate-error-card";let v=document.createElement("button");v.type="button",v.className="chattermate-error-close",v.setAttribute("aria-label","Close chat"),v.textContent="\xD7",v.addEventListener("click",function(){r&&b()}),s.appendChild(v);let u=document.createElement("div");u.className="chattermate-error-icon-wrapper";let p=document.createElementNS("http://www.w3.org/2000/svg","svg");p.setAttribute("class","chattermate-error-icon"),p.setAttribute("viewBox","0 0 24 24"),p.setAttribute("fill","none"),p.setAttribute("stroke","currentColor"),p.setAttribute("stroke-width","1.5");let k=document.createElementNS("http://www.w3.org/2000/svg","path");k.setAttribute("d","M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z");let $=document.createElementNS("http://www.w3.org/2000/svg","path");$.setAttribute("d","M9 12l2 2 4-4"),p.appendChild(k),p.appendChild($),u.appendChild(p);let A=document.createElement("h2");A.className="chattermate-error-title",A.textContent="Chat Unavailable";let yt=document.createElement("p");yt.className="chattermate-error-message",yt.textContent=a;let it=document.createElement("div");it.className="chattermate-error-footer";let L=document.createElementNS("http://www.w3.org/2000/svg","svg");L.setAttribute("width","14"),L.setAttribute("height","14"),L.setAttribute("viewBox","0 0 60 60"),L.setAttribute("fill","none");let G=document.createElementNS("http://www.w3.org/2000/svg","path");G.setAttribute("d","M18 12 H42 A6 6 0 0 1 48 18 V42 A6 6 0 0 1 42 48 H18 A6 6 0 0 1 12 42 V18 A6 6 0 0 1 18 12 Z M17 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M26 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M35 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z"),G.setAttribute("fill-rule","evenodd"),G.setAttribute("fill","currentColor"),G.setAttribute("opacity","0.6"),L.appendChild(G);let z=document.createElement("a");return z.href="https://chattermate.chat",z.target="_blank",z.rel="noopener noreferrer",z.className="chattermate-error-footer-link",z.textContent="Powered by ChatterMate",it.appendChild(L),it.appendChild(z),I.appendChild(u),I.appendChild(A),I.appendChild(yt),I.appendChild(it),s.appendChild(I),s}function et(){return wt(this,null,function*(){if(w||l)return;let a=D;try{w=!0,t.classList.add("loading");let s=dt(),I=`${e.baseUrl}/widgets/${window.chattermateId}/data?widget_id=${window.chattermateId}`,v={method:"GET",mode:"cors",headers:s?{Authorization:`Bearer ${s}`}:{}};fetch(I,v).then(u=>wt(null,null,function*(){if(a!==D)return null;if(u.status===401)try{let k=(yield u.json()).detail;if(k&&k.code==="identity_expired")return t.classList.remove("loading"),_("identityExpired"),Vt(),null;let $=typeof k=="string"?k:"";if($.includes("generate-token")||$.includes("API key")||$.includes("Token must be obtained")){t.classList.remove("loading"),H();let A=At("This chat widget is not currently configured. Please contact the website administrator to enable chat support.");return n.appendChild(A),F(),null}}catch(p){}if(!u.ok)throw new Error("HTTP error! status: "+u.status);return u.text()})).then(u=>{u!==null&&a===D&&(l=document.createElement("iframe"),l.className="chattermate-iframe",l.title="Chat",l.srcdoc=u,l.addEventListener("load",function(){bt=!0,l.contentWindow.postM
Line truncated
|
||||
@@ -0,0 +1,208 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ClipboardIcon, CheckIcon } from '@heroicons/vue/24/outline'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps<{
|
||||
apiKey: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.apiKey)
|
||||
copied.value = true
|
||||
toast.success('已复制!', {
|
||||
description: 'API 密钥已复制到剪贴板',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
toast.error('复制失败', {
|
||||
description: '请手动复制',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal @close="emit('close')">
|
||||
<template #title>API 密钥已创建</template>
|
||||
<template #content>
|
||||
<div class="api-key-modal">
|
||||
<div class="warning-banner">
|
||||
<strong>⚠️ 请立即保存此密钥!</strong>
|
||||
<p>这是唯一一次显示该 API 密钥,之后将无法再次查看。</p>
|
||||
</div>
|
||||
|
||||
<div class="key-container">
|
||||
<label>API 密钥:</label>
|
||||
<div class="key-display">
|
||||
<code class="api-key">{{ apiKey }}</code>
|
||||
<button
|
||||
class="copy-button"
|
||||
@click="copyToClipboard"
|
||||
:title="copied ? '已复制!' : '复制到剪贴板'"
|
||||
>
|
||||
<CheckIcon v-if="copied" class="icon success" />
|
||||
<ClipboardIcon v-else class="icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h4>后续步骤:</h4>
|
||||
<ol>
|
||||
<li>复制该 API 密钥并妥善保管</li>
|
||||
<li>用它来验证组件 Token 生成请求</li>
|
||||
<li>将其放入 Authorization 请求头:<code>Bearer {{ apiKey.substring(0, 20) }}...</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="emit('close')">
|
||||
我已保存密钥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-key-modal {
|
||||
padding: var(--space-md);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.warning-banner strong {
|
||||
display: block;
|
||||
color: var(--warning-color);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.warning-banner p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.key-container {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.key-container label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.api-key {
|
||||
flex: 1;
|
||||
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||
font-size: var(--text-sm);
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
padding: var(--space-xs);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.icon.success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: var(--info-bg);
|
||||
border: 1px solid var(--info-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.instructions h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
.instructions li {
|
||||
margin-bottom: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.instructions code {
|
||||
background: var(--code-bg);
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { WidgetApp, WidgetAppCreate } from '@/types/widget-app'
|
||||
|
||||
const props = defineProps<{
|
||||
app?: WidgetApp | null
|
||||
}>()
|
||||
|
||||
// Always emit WidgetAppCreate since the form requires name
|
||||
// The parent component uses this for both create and update operations
|
||||
const emit = defineEmits<{
|
||||
submit: [data: WidgetAppCreate]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const name = ref(props.app?.name || '')
|
||||
const description = ref(props.app?.description || '')
|
||||
const error = ref('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
error.value = ''
|
||||
|
||||
if (!name.value.trim()) {
|
||||
error.value = '请输入名称'
|
||||
return
|
||||
}
|
||||
|
||||
if (name.value.length > 100) {
|
||||
error.value = '名称不能超过 100 个字符'
|
||||
return
|
||||
}
|
||||
|
||||
if (description.value && description.value.length > 500) {
|
||||
error.value = '描述不能超过 500 个字符'
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || undefined
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="widget-app-form">
|
||||
<div class="form-group">
|
||||
<label for="name">名称 <span class="required">*</span></label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
placeholder="例如:市场官网"
|
||||
maxlength="100"
|
||||
required
|
||||
/>
|
||||
<span class="hint">便于识别的网站组件名称</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
placeholder="该组件将部署在哪里?"
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
/>
|
||||
<span class="hint">可选:说明该组件的用途</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ app ? '更新组件' : '创建组件' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-app-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 13px 15px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--error-bg);
|
||||
color: var(--error-color);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 22px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,859 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useWidgetApps } from '@/composables/useWidgetApps'
|
||||
import { PlusIcon } from '@heroicons/vue/24/outline'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import WidgetAppForm from './WidgetAppForm.vue'
|
||||
import ApiKeyModal from './ApiKeyModal.vue'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
import { useSubscriptionStorage } from '@/utils/storage'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
const subscriptionStorage = useSubscriptionStorage()
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
|
||||
const {
|
||||
apps,
|
||||
loading,
|
||||
error,
|
||||
showCreateModal,
|
||||
showEditModal,
|
||||
showDeleteModal,
|
||||
showApiKeyModal,
|
||||
selectedApp,
|
||||
newApiKey,
|
||||
showInactive,
|
||||
hasAnyApps,
|
||||
hasInactiveApps,
|
||||
fetchApps,
|
||||
handleCreateApp,
|
||||
handleEditApp,
|
||||
handleUpdateApp,
|
||||
handleDeleteApp,
|
||||
confirmDeleteApp,
|
||||
handleRegenerateKey,
|
||||
closeApiKeyModal
|
||||
} = useWidgetApps()
|
||||
|
||||
const canManage = computed(() => permissionChecks.canManageOrganization())
|
||||
|
||||
// Subscription and widget apps feature checking
|
||||
const currentSubscription = computed(() => subscriptionStorage.getCurrentSubscription())
|
||||
const isSubscriptionActive = computed(() => subscriptionStorage.isSubscriptionActive())
|
||||
|
||||
// Check if widget apps is locked (only if enterprise module exists)
|
||||
const isWidgetAppsLocked = computed(() => {
|
||||
// Only lock if enterprise module exists
|
||||
if (!hasEnterpriseModule) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!currentSubscription.value || !isSubscriptionActive.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if api_access feature exists in subscription (required for widget apps)
|
||||
const hasApiAccessFeature = subscriptionStorage.hasFeature('api_access')
|
||||
if (!hasApiAccessFeature) {
|
||||
return true // Lock widget apps if feature doesn't exist
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Upgrade modal functions
|
||||
const handleUpgrade = () => {
|
||||
// Only redirect to subscription page if enterprise module exists
|
||||
if (hasEnterpriseModule) {
|
||||
window.location.href = '/settings/subscription'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (canManage.value && !isWidgetAppsLocked.value) {
|
||||
await fetchApps()
|
||||
}
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!canManage" class="no-permission">
|
||||
<p>你没有管理网站组件的权限。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="widget-app-list">
|
||||
<!-- Widget Apps Locked Overlay -->
|
||||
<div v-if="isWidgetAppsLocked" class="widget-apps-locked-overlay">
|
||||
<div class="locked-content">
|
||||
<div class="locked-header">
|
||||
<div class="locked-icon-wrapper">
|
||||
<div class="locked-icon-bg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="locked-icon">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h2>网站组件</h2>
|
||||
<div class="locked-badge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="badge-icon">
|
||||
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>付费功能</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="locked-description">
|
||||
解锁网站组件,即可创建安全的 API 密钥,用于在多个平台与应用中验证聊天组件。
|
||||
</p>
|
||||
|
||||
<div class="locked-features">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<span class="feature-title">安全 API 密钥</span>
|
||||
<span class="feature-desc">为每个集成生成独立密钥</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<span class="feature-title">访问控制</span>
|
||||
<span class="feature-desc">随时管理与吊销访问权限</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||
<path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<span class="feature-title">多平台</span>
|
||||
<span class="feature-desc">在多个站点部署组件</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="upgrade-btn" @click="handleUpgrade">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="upgrade-icon">
|
||||
<path fill-rule="evenodd" d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
升级到 Pro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal content when not locked -->
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<header class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1>网站组件</h1>
|
||||
<p class="subtitle">
|
||||
生成安全的 API 密钥,用于验证你的聊天组件。
|
||||
<span v-if="hasAnyApps" class="subtitle-count">{{ apps.length }} 个组件</span>
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="hasAnyApps" class="btn btn-primary" @click="showCreateModal = true">
|
||||
<PlusIcon class="icon" />
|
||||
创建组件
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Include inactive toggle (only show when there are inactive apps) -->
|
||||
<div v-if="hasInactiveApps" class="filters">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showInactive"
|
||||
/>
|
||||
显示已停用组件
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>正在加载网站组件…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<p>{{ error }}</p>
|
||||
<button class="btn btn-secondary" @click="fetchApps">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (only when no apps at all) -->
|
||||
<div v-else-if="!hasAnyApps" class="empty-state">
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon-bg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" class="empty-icon">
|
||||
<rect x="4" y="4" width="7" height="7" rx="2"/>
|
||||
<rect x="13" y="4" width="7" height="7" rx="2"/>
|
||||
<rect x="4" y="13" width="7" height="7" rx="2"/>
|
||||
<rect x="13" y="13" width="7" height="7" rx="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>暂无网站组件</h2>
|
||||
<p class="empty-description">
|
||||
网站组件可用于生成安全的 API 密钥,以验证你的聊天组件。
|
||||
创建第一个组件即可开始。
|
||||
</p>
|
||||
<button class="btn btn-primary create-btn" @click="showCreateModal = true">
|
||||
<PlusIcon class="icon" />
|
||||
创建第一个组件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else-if="apps.length > 0" class="table-container">
|
||||
<div class="table-head">
|
||||
<span class="col-name">名称</span>
|
||||
<span class="col-desc">描述</span>
|
||||
<span class="col-created">创建时间</span>
|
||||
<span class="col-updated">更新时间</span>
|
||||
<span class="col-actions">操作</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:class="['table-row', { inactive: !app.is_active }]"
|
||||
>
|
||||
<div class="cell-name">
|
||||
<div class="app-name">{{ app.name }}</div>
|
||||
<span :class="['status-badge', app.is_active ? 'active' : 'inactive']">
|
||||
<span class="status-dot"></span>
|
||||
{{ app.is_active ? '启用中' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cell-desc">{{ app.description || '—' }}</div>
|
||||
<div class="cell-date">{{ formatDate(app.created_at) }}</div>
|
||||
<div class="cell-date">{{ formatDate(app.updated_at) }}</div>
|
||||
<div class="cell-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
title="编辑"
|
||||
@click="handleEditApp(app)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
title="重新生成密钥"
|
||||
@click="handleRegenerateKey(app)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-btn-danger"
|
||||
title="吊销"
|
||||
@click="handleDeleteApp(app)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M6 6l1 14a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All apps filtered state (all inactive and showInactive is false) -->
|
||||
<div v-else-if="hasAnyApps && apps.length === 0" class="filtered-state">
|
||||
<p>所有网站组件当前均已停用。</p>
|
||||
<button class="btn btn-secondary" @click="showInactive = true">
|
||||
显示已停用组件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<Modal v-if="showCreateModal" @close="showCreateModal = false">
|
||||
<template #title>创建网站组件</template>
|
||||
<template #content>
|
||||
<WidgetAppForm
|
||||
@submit="handleCreateApp"
|
||||
@cancel="showCreateModal = false"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<Modal v-if="showEditModal && selectedApp" @close="showEditModal = false">
|
||||
<template #title>编辑网站组件</template>
|
||||
<template #content>
|
||||
<WidgetAppForm
|
||||
:app="selectedApp"
|
||||
@submit="handleUpdateApp"
|
||||
@cancel="showEditModal = false"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal v-if="showDeleteModal && selectedApp" @close="showDeleteModal = false">
|
||||
<template #title>删除网站组件</template>
|
||||
<template #content>
|
||||
<div class="confirm-delete">
|
||||
<p>确定要删除 <strong>{{ selectedApp.name }}</strong> 吗?</p>
|
||||
<p class="warning">此操作将立即使该 API 密钥失效。</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-danger" @click="confirmDeleteApp">
|
||||
删除
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- API Key Display Modal -->
|
||||
<ApiKeyModal
|
||||
v-if="showApiKeyModal && newApiKey"
|
||||
:apiKey="newApiKey"
|
||||
@close="closeApiKeyModal"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-app-list {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle-count {
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-header .btn-primary {
|
||||
padding: 12px 20px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: filter var(--transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.page-header .btn-primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.page-header .btn-primary .icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 18px;
|
||||
padding: 18px 0 6px;
|
||||
}
|
||||
|
||||
.table-head,
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1.3fr) minmax(160px, 1.6fr) 130px 130px 130px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
padding: 0 22px 14px;
|
||||
}
|
||||
|
||||
.table-head span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.table-head .col-actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
padding: 15px 22px;
|
||||
border-top: 1px solid var(--o06);
|
||||
}
|
||||
|
||||
.table-row.inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-date {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
color: var(--c-online);
|
||||
}
|
||||
|
||||
.status-badge.active .status-dot {
|
||||
background: var(--c-teal);
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-badge.inactive .status-dot {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background var(--transition-fast, 0.15s), color var(--transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.action-btn-danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.confirm-delete .warning {
|
||||
color: var(--error-color);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.no-permission,
|
||||
.filtered-state {
|
||||
text-align: center;
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
|
||||
.filtered-state {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.filtered-state p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto var(--space-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State Styles */
|
||||
.empty-state {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 20px;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-icon-bg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 16px;
|
||||
background: var(--accent-bg-12);
|
||||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.empty-content h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 0 auto 26px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 13px 24px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter var(--transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.create-btn .icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
/* Widget Apps Locked Overlay Styles */
|
||||
.widget-apps-locked-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(201, 242, 78, 0.04) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(59, 130, 246, 0.05) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
padding: var(--space-xl);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-icon-wrapper {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-icon-bg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, var(--accent-solid), var(--primary-dark));
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-header h2 {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .badge-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-description {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--info-bg);
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .upgrade-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .upgrade-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .upgrade-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import type { QueueItem } from '@/types/knowledge'
|
||||
|
||||
const normalizeSource = (source: string) => source.trim().toLowerCase().replace(/\/+$/, '')
|
||||
const sameSource = (a: string, b: string) => normalizeSource(a) === normalizeSource(b)
|
||||
|
||||
/** The newest row for each source: a retry must not be judged by the old failure.
|
||||
* The queue API lists newest first. */
|
||||
export const newestPerSource = (items: QueueItem[]) => {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
const key = normalizeSource(item.source)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any just-sent source has already failed - a domain that doesn't
|
||||
* exist is refused the moment it is added. One look, no waiting: every other
|
||||
* failure only shows once the crawl has run (often minutes), so holding the
|
||||
* wizard for it would delay every site that reads fine. Later failures are
|
||||
* reported on the Test step.
|
||||
*/
|
||||
export function useCrawlCheck(fetchQueueItems: () => Promise<unknown>, queueItems: Ref<QueueItem[]>) {
|
||||
let cancelled = false
|
||||
|
||||
/** The first of `sources` that has already failed, or null. */
|
||||
const firstFailure = async (sources: string[]): Promise<QueueItem | null> => {
|
||||
cancelled = false
|
||||
try {
|
||||
await fetchQueueItems()
|
||||
} catch (err) {
|
||||
// Not knowing is not a failure: move on, the Test step will tell.
|
||||
console.error('Failed to check the knowledge queue:', err)
|
||||
return null
|
||||
}
|
||||
if (cancelled) return null
|
||||
const mine = newestPerSource(queueItems.value).filter((item) => sources.some((s) => sameSource(s, item.source)))
|
||||
return mine.find((item) => item.status === 'failed') ?? null
|
||||
}
|
||||
|
||||
/** Ignore the answer (the user went Back, or the step unmounted). */
|
||||
const cancel = () => {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
return { firstFailure, cancel }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { widgetAppService } from '@/services/widget-app'
|
||||
import type { WidgetApp, WidgetAppCreate, WidgetAppUpdate } from '@/types/widget-app'
|
||||
|
||||
export function useWidgetApps() {
|
||||
const allApps = ref<WidgetApp[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Modal states
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const showApiKeyModal = ref(false)
|
||||
|
||||
// Selected app state
|
||||
const selectedApp = ref<WidgetApp | null>(null)
|
||||
const newApiKey = ref<string | null>(null) // For showing key after create/regenerate
|
||||
|
||||
// Include inactive toggle
|
||||
const showInactive = ref(false)
|
||||
|
||||
// Computed: Check if there are any apps at all
|
||||
const hasAnyApps = computed(() => allApps.value.length > 0)
|
||||
|
||||
// Computed: Check if there are any inactive apps
|
||||
const hasInactiveApps = computed(() => allApps.value.some(app => !app.is_active))
|
||||
|
||||
// Computed: Filter apps based on showInactive toggle
|
||||
const apps = computed(() => {
|
||||
if (showInactive.value) {
|
||||
return allApps.value
|
||||
}
|
||||
return allApps.value.filter(app => app.is_active)
|
||||
})
|
||||
|
||||
// Fetch all apps (always include inactive to know if filter should show)
|
||||
const fetchApps = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const response = await widgetAppService.listApps(true) // Always fetch all
|
||||
allApps.value = response.apps
|
||||
} catch (err: any) {
|
||||
error.value = '加载组件应用失败'
|
||||
console.error('Error loading widget apps:', err)
|
||||
toast.error('出错了', {
|
||||
description: '加载组件应用失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Create app
|
||||
const handleCreateApp = async (appData: WidgetAppCreate) => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const result = await widgetAppService.createApp(appData)
|
||||
|
||||
// Add new app to list
|
||||
allApps.value.unshift({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
organization_id: result.organization_id,
|
||||
created_by: result.created_by,
|
||||
is_active: result.is_active,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at
|
||||
})
|
||||
|
||||
// Store API key for display
|
||||
newApiKey.value = result.api_key
|
||||
|
||||
// Close create modal, open API key modal
|
||||
showCreateModal.value = false
|
||||
showApiKeyModal.value = true
|
||||
|
||||
toast.success('成功', {
|
||||
description: '组件应用已创建。请立即保存 API Key!',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = '创建组件应用失败'
|
||||
toast.error('出错了', {
|
||||
description: err.response?.data?.detail || '创建组件应用失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Edit app
|
||||
const handleEditApp = (app: WidgetApp) => {
|
||||
selectedApp.value = app
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
// Update app (accepts WidgetAppCreate since form always provides required fields)
|
||||
const handleUpdateApp = async (appData: WidgetAppCreate) => {
|
||||
if (!selectedApp.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const updatedApp = await widgetAppService.updateApp(selectedApp.value.id, appData)
|
||||
|
||||
// Update in list
|
||||
const index = allApps.value.findIndex(a => a.id === updatedApp.id)
|
||||
if (index !== -1) {
|
||||
allApps.value[index] = updatedApp
|
||||
}
|
||||
|
||||
showEditModal.value = false
|
||||
selectedApp.value = null
|
||||
|
||||
toast.success('成功', {
|
||||
description: '组件应用已更新',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = '更新组件应用失败'
|
||||
toast.error('出错了', {
|
||||
description: err.response?.data?.detail || '更新组件应用失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Delete app
|
||||
const handleDeleteApp = (app: WidgetApp) => {
|
||||
selectedApp.value = app
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
const confirmDeleteApp = async () => {
|
||||
if (!selectedApp.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
await widgetAppService.deleteApp(selectedApp.value.id)
|
||||
|
||||
// Remove from list
|
||||
allApps.value = allApps.value.filter(a => a.id !== selectedApp.value?.id)
|
||||
|
||||
showDeleteModal.value = false
|
||||
selectedApp.value = null
|
||||
|
||||
toast.success('成功', {
|
||||
description: '组件应用已删除',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = '删除组件应用失败'
|
||||
toast.error('出错了', {
|
||||
description: err.response?.data?.detail || '删除组件应用失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate API key
|
||||
const handleRegenerateKey = async (app: WidgetApp) => {
|
||||
if (!confirm('确定吗?这会立即使当前 API Key 失效。')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const result = await widgetAppService.regenerateApiKey(app.id)
|
||||
|
||||
// Update app in list
|
||||
const index = allApps.value.findIndex(a => a.id === result.id)
|
||||
if (index !== -1) {
|
||||
allApps.value[index] = {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
organization_id: result.organization_id,
|
||||
created_by: result.created_by,
|
||||
is_active: result.is_active,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
// Store new API key for display
|
||||
newApiKey.value = result.api_key
|
||||
showApiKeyModal.value = true
|
||||
|
||||
toast.success('成功', {
|
||||
description: 'API Key 已重新生成。请立即保存新的 Key!',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = '重新生成 API Key 失败'
|
||||
toast.error('出错了', {
|
||||
description: err.response?.data?.detail || '重新生成 API Key 失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Close API key modal
|
||||
const closeApiKeyModal = () => {
|
||||
showApiKeyModal.value = false
|
||||
newApiKey.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
apps,
|
||||
loading,
|
||||
error,
|
||||
showCreateModal,
|
||||
showEditModal,
|
||||
showDeleteModal,
|
||||
showApiKeyModal,
|
||||
selectedApp,
|
||||
newApiKey,
|
||||
showInactive,
|
||||
hasAnyApps,
|
||||
hasInactiveApps,
|
||||
fetchApps,
|
||||
handleCreateApp,
|
||||
handleEditApp,
|
||||
handleUpdateApp,
|
||||
handleDeleteApp,
|
||||
confirmDeleteApp,
|
||||
handleRegenerateKey,
|
||||
closeApiKeyModal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import type { AgentCustomization } from '../types/agent'
|
||||
import WebFont from 'webfontloader'
|
||||
import { postToHost } from '../webclient/host-bridge'
|
||||
|
||||
// Design fonts the redesigned widget always needs (display / body / mono), with
|
||||
// the weights it actually uses. The user's custom font_family is layered on top.
|
||||
const DESIGN_FONT_FAMILIES = [
|
||||
'Space Grotesk:400,500,600,700',
|
||||
'Instrument Sans:400,500,600',
|
||||
'JetBrains Mono:400,500,600',
|
||||
]
|
||||
|
||||
// Load the design fonts plus any custom body font, and apply the custom font to
|
||||
// the chat container once available.
|
||||
const loadFonts = (customFontFamily?: string) => {
|
||||
const families = [...DESIGN_FONT_FAMILIES]
|
||||
|
||||
const primary = customFontFamily?.split(',')[0].trim().replace(/['"]/g, '') || ''
|
||||
const alreadyLoaded = DESIGN_FONT_FAMILIES.some(
|
||||
f => f.toLowerCase().startsWith(primary.toLowerCase())
|
||||
)
|
||||
if (primary && !alreadyLoaded) {
|
||||
families.push(primary)
|
||||
}
|
||||
|
||||
WebFont.load({
|
||||
google: { families },
|
||||
active: () => {
|
||||
if (!customFontFamily) return
|
||||
const chatContainer = document.querySelector('.chat-container') as HTMLElement
|
||||
if (chatContainer) {
|
||||
chatContainer.style.fontFamily = customFontFamily.includes(',')
|
||||
? customFontFamily
|
||||
: `"${customFontFamily}", system-ui, sans-serif`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useWidgetCustomization() {
|
||||
const customization = ref<Partial<AgentCustomization>>({})
|
||||
const agentName = ref('')
|
||||
|
||||
const applyCustomization = (newCustomization: Partial<AgentCustomization>) => {
|
||||
customization.value = newCustomization
|
||||
|
||||
|
||||
if (newCustomization.photo_url) {
|
||||
customization.value.photo_url = newCustomization.photo_url
|
||||
}
|
||||
|
||||
// Always load the design fonts; layer the custom font_family on top.
|
||||
loadFonts(newCustomization.font_family)
|
||||
|
||||
// Send customization update to parent
|
||||
postToHost({
|
||||
type: 'CUSTOMIZATION_UPDATE',
|
||||
data: {
|
||||
chat_bubble_color: newCustomization.chat_bubble_color || '#C9F24E',
|
||||
chat_style: newCustomization.chat_style,
|
||||
chat_initiation_messages: newCustomization.chat_initiation_messages || [],
|
||||
// Dashboard "Widget placement" defaults — the embed loader merges these
|
||||
// under any options the installing developer set.
|
||||
widget_display: newCustomization.customization_metadata?.widget_display
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize from window.__INITIAL_DATA__
|
||||
const initializeFromData = () => {
|
||||
const initialData = window.__INITIAL_DATA__
|
||||
if (initialData) {
|
||||
applyCustomization(initialData.customization || {})
|
||||
agentName.value = initialData.agentName || ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customization,
|
||||
agentName,
|
||||
applyCustomization,
|
||||
initializeFromData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { widgetEnv, resolveWidgetUploadUrl } from '../webclient/widget-env'
|
||||
import { isAbsoluteUrl } from '../utils/avatars'
|
||||
|
||||
// Allowed file types configuration (matching backend)
|
||||
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'])
|
||||
const ALLOWED_DOCUMENT_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
])
|
||||
const ALLOWED_FILE_TYPES = new Set([...ALLOWED_IMAGE_TYPES, ...ALLOWED_DOCUMENT_TYPES])
|
||||
|
||||
// Extension to MIME type mapping
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.pdf': 'application/pdf',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.txt': 'text/plain',
|
||||
'.csv': 'text/csv',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
}
|
||||
|
||||
// Magic byte signatures for client-side validation
|
||||
const MAGIC_BYTES: Record<string, { bytes: number[]; offset: number }[]> = {
|
||||
'image/jpeg': [
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe0], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe1], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe2], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xdb], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xee], offset: 0 }
|
||||
],
|
||||
'image/png': [{ bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], offset: 0 }],
|
||||
'image/gif': [
|
||||
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], offset: 0 }, // GIF87a
|
||||
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], offset: 0 } // GIF89a
|
||||
],
|
||||
'image/webp': [{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }], // RIFF
|
||||
'application/pdf': [{ bytes: [0x25, 0x50, 0x44, 0x46, 0x2d], offset: 0 }] // %PDF-
|
||||
}
|
||||
|
||||
// Validate file magic bytes
|
||||
async function validateMagicBytes(file: File): Promise<{ valid: boolean; error?: string }> {
|
||||
const mimeType = file.type
|
||||
|
||||
// Text files don't have magic bytes
|
||||
if (mimeType === 'text/plain' || mimeType === 'text/csv') {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Office files (ZIP-based) start with PK
|
||||
if (mimeType.includes('openxmlformats') || mimeType === 'application/msword') {
|
||||
const buffer = await file.slice(0, 4).arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
// PK signature for ZIP-based or OLE for old Office
|
||||
if ((bytes[0] === 0x50 && bytes[1] === 0x4b) ||
|
||||
(bytes[0] === 0xd0 && bytes[1] === 0xcf)) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, error: 'File content does not match Office document format' }
|
||||
}
|
||||
|
||||
const signatures = MAGIC_BYTES[mimeType]
|
||||
if (!signatures) {
|
||||
// Unknown type, will be validated server-side
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Read first 16 bytes
|
||||
const buffer = await file.slice(0, 16).arrayBuffer()
|
||||
const fileBytes = new Uint8Array(buffer)
|
||||
|
||||
// WebP special check
|
||||
if (mimeType === 'image/webp') {
|
||||
if (fileBytes[0] === 0x52 && fileBytes[1] === 0x49 &&
|
||||
fileBytes[2] === 0x46 && fileBytes[3] === 0x46 &&
|
||||
fileBytes[8] === 0x57 && fileBytes[9] === 0x45 &&
|
||||
fileBytes[10] === 0x42 && fileBytes[11] === 0x50) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, error: 'File content does not match WebP format' }
|
||||
}
|
||||
|
||||
// Check signatures
|
||||
for (const sig of signatures) {
|
||||
let match = true
|
||||
for (let i = 0; i < sig.bytes.length; i++) {
|
||||
if (fileBytes[sig.offset + i] !== sig.bytes[i]) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (match) return { valid: true }
|
||||
}
|
||||
|
||||
return { valid: false, error: `File content does not match ${mimeType} format` }
|
||||
}
|
||||
|
||||
// Validate file extension matches MIME type
|
||||
function validateExtension(filename: string, mimeType: string): { valid: boolean; error?: string } {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
|
||||
|
||||
if (!ext || ext === '.') {
|
||||
return { valid: false, error: 'File must have an extension' }
|
||||
}
|
||||
|
||||
if (!(ext in EXTENSION_TO_MIME)) {
|
||||
const allowed = Object.keys(EXTENSION_TO_MIME).map(e => e.toUpperCase().replace('.', '')).join(', ')
|
||||
return { valid: false, error: `File type not allowed. Allowed: ${allowed}` }
|
||||
}
|
||||
|
||||
const expectedMime = EXTENSION_TO_MIME[ext]
|
||||
|
||||
// Handle jpg/jpeg equivalence
|
||||
if ((mimeType === 'image/jpeg' || mimeType === 'image/jpg') &&
|
||||
(expectedMime === 'image/jpeg' || expectedMime === 'image/jpg')) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
if (expectedMime !== mimeType) {
|
||||
return { valid: false, error: `File extension ${ext} does not match content type` }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export function useWidgetFiles(token: Ref<string | null>, fileInputRef: Ref<HTMLInputElement | null>) {
|
||||
// File handling state
|
||||
const uploadedAttachments = ref<Array<{
|
||||
content: string
|
||||
filename: string
|
||||
type: string
|
||||
size: number
|
||||
url: string
|
||||
file_url: string
|
||||
}>>([])
|
||||
|
||||
const previewModal = ref(false)
|
||||
const previewFile = ref<{
|
||||
url: string
|
||||
filename: string
|
||||
type: string
|
||||
file_url?: string
|
||||
size?: number
|
||||
} | null>(null)
|
||||
|
||||
// Format file size for display
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Check if file is an image
|
||||
const isImageAttachment = (contentType: string): boolean => {
|
||||
return contentType.startsWith('image/')
|
||||
}
|
||||
|
||||
// Generate download URL for attachments
|
||||
const getDownloadUrl = (fileUrl: string | undefined | null): string => {
|
||||
if (!fileUrl) return ''
|
||||
|
||||
// Blob previews and absolute S3 URLs pass through; local paths already
|
||||
// carry the /api/v1 prefix, so they resolve against the API origin.
|
||||
return resolveWidgetUploadUrl(fileUrl)
|
||||
}
|
||||
|
||||
// Generate preview URL for file display (similar to FileUpload.vue)
|
||||
const getPreviewUrl = (file: {url: string, file_url?: string}): string => {
|
||||
const urlToUse = file.file_url || file.url
|
||||
if (!urlToUse) return ''
|
||||
|
||||
return resolveWidgetUploadUrl(urlToUse)
|
||||
}
|
||||
|
||||
// Handle file selection from input
|
||||
const handleFileSelect = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
await uploadFiles(Array.from(target.files))
|
||||
// Reset the input value to allow selecting the same file again
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag and drop
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
const files = event.dataTransfer?.files
|
||||
if (files && files.length > 0) {
|
||||
await uploadFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Handle paste events
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const files: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
await uploadFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
// Compress image if it's too large
|
||||
const compressImage = async (file: File, maxSizeKB: number = 500): Promise<{blob: Blob, base64: string}> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
|
||||
// Calculate new dimensions (max 1920px width/height while maintaining aspect ratio)
|
||||
const maxDimension = 1920
|
||||
if (width > maxDimension || height > maxDimension) {
|
||||
if (width > height) {
|
||||
height = (height / width) * maxDimension
|
||||
width = maxDimension
|
||||
} else {
|
||||
width = (width / height) * maxDimension
|
||||
height = maxDimension
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Start with quality 0.9 and reduce if needed
|
||||
let quality = 0.9
|
||||
const tryCompress = () => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to compress image'))
|
||||
return
|
||||
}
|
||||
|
||||
const sizeKB = blob.size / 1024
|
||||
|
||||
// If still too large and quality can be reduced, try again
|
||||
if (sizeKB > maxSizeKB && quality > 0.3) {
|
||||
quality -= 0.1
|
||||
tryCompress()
|
||||
} else {
|
||||
// Convert blob to base64
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1]
|
||||
resolve({ blob, base64 })
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
}
|
||||
}, file.type === 'image/png' ? 'image/png' : 'image/jpeg', quality)
|
||||
}
|
||||
|
||||
tryCompress()
|
||||
}
|
||||
img.onerror = () => reject(new Error('Failed to load image'))
|
||||
img.src = e.target?.result as string
|
||||
}
|
||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
// Upload files (convert to base64 and store locally)
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
const MAX_FILES = 3 // Maximum 3 files per message
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB for images
|
||||
const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 // 10MB for documents
|
||||
const TARGET_SIZE_KB = 500 // Target 500KB after compression
|
||||
|
||||
// Check if adding these files would exceed the limit
|
||||
if (uploadedAttachments.value.length >= MAX_FILES) {
|
||||
alert(`每条消息最多 ${MAX_FILES} 个文件`)
|
||||
return
|
||||
}
|
||||
|
||||
const remainingSlots = MAX_FILES - uploadedAttachments.value.length
|
||||
const filesToUpload = files.slice(0, remainingSlots)
|
||||
|
||||
if (files.length > remainingSlots) {
|
||||
alert(`最多还能上传 ${remainingSlots} 个文件。每条消息最多 ${MAX_FILES} 个文件。`)
|
||||
}
|
||||
|
||||
for (const file of filesToUpload) {
|
||||
try {
|
||||
// Check if file with same name already exists
|
||||
const isDuplicate = uploadedAttachments.value.some(att => att.filename === file.name)
|
||||
if (isDuplicate) {
|
||||
console.warn(`File ${file.name} is already selected`)
|
||||
alert(`文件“${file.name}”已选择`)
|
||||
continue
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const maxSize = isImage ? MAX_FILE_SIZE : MAX_DOCUMENT_SIZE
|
||||
|
||||
// Validate file size before upload
|
||||
if (file.size > maxSize) {
|
||||
const maxSizeMB = maxSize / (1024 * 1024)
|
||||
console.error(`File ${file.name} is too large. Maximum size is ${maxSizeMB}MB`)
|
||||
alert(`文件“${file.name}”过大,${isImage ? '图片' : '文档'}最大为 ${maxSizeMB}MB`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isImage) {
|
||||
// Compress image before upload
|
||||
try {
|
||||
const { blob, base64 } = await compressImage(file, TARGET_SIZE_KB)
|
||||
const compressedSize = blob.size
|
||||
|
||||
console.log(`Compressed ${file.name}: ${(file.size / 1024).toFixed(2)}KB → ${(compressedSize / 1024).toFixed(2)}KB`)
|
||||
|
||||
uploadedAttachments.value.push({
|
||||
content: base64,
|
||||
filename: file.name,
|
||||
type: file.type,
|
||||
size: compressedSize,
|
||||
url: URL.createObjectURL(blob),
|
||||
file_url: URL.createObjectURL(blob)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Image compression failed, uploading original:', error)
|
||||
// Fallback to original file if compression fails
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const base64Content = e.target?.result as string
|
||||
const base64Data = base64Content.split(',')[1]
|
||||
|
||||
uploadedAttachments.value.push({
|
||||
content: base64Data,
|
||||
filename: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
url: URL.createObjectURL(file),
|
||||
file_url: URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
} else {
|
||||
// For non-images, read as-is
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const base64Content = e.target?.result as string
|
||||
const base64Data = base64Content.split(',')[1]
|
||||
|
||||
uploadedAttachments.value.push({
|
||||
content: base64Data,
|
||||
filename: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
url: '',
|
||||
file_url: ''
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File upload error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove attachment and call delete API
|
||||
const removeAttachment = async (index: number) => {
|
||||
const file = uploadedAttachments.value[index]
|
||||
if (!file) return
|
||||
|
||||
// Call delete API to remove file from storage
|
||||
try {
|
||||
// Extract the file path from the URL
|
||||
let filePath = file.url
|
||||
|
||||
// Remove /uploads/ prefix if present
|
||||
if (filePath.startsWith('/uploads/')) {
|
||||
filePath = filePath.substring(9)
|
||||
} else if (filePath.startsWith('/')) {
|
||||
filePath = filePath.substring(1)
|
||||
}
|
||||
|
||||
// For absolute S3/CDN URLs, reduce to the object key (the URL path).
|
||||
// Parsing the URL avoids matching a host substring, which is unreliable
|
||||
// and flagged by static analysis (js/incomplete-url-substring-sanitization).
|
||||
if (isAbsoluteUrl(filePath)) {
|
||||
try {
|
||||
filePath = new URL(filePath).pathname.replace(/^\/+/, '')
|
||||
} catch {
|
||||
// Not a parseable URL — leave the path as-is.
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (token.value) {
|
||||
headers['Authorization'] = `Bearer ${token.value}`
|
||||
}
|
||||
|
||||
// API_URL already ends in /api/v1 — do not repeat it here.
|
||||
const response = await fetch(`${widgetEnv.API_URL}/files/upload/${filePath}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error('Failed to delete file:', errorData.detail)
|
||||
// Optionally, show an error message to the user
|
||||
} else {
|
||||
console.log('File deleted successfully from backend.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error calling delete API:', error)
|
||||
}
|
||||
|
||||
// Revoke blob URLs to free memory
|
||||
if (file.url && file.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(file.url)
|
||||
}
|
||||
if (file.file_url && file.file_url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(file.file_url)
|
||||
}
|
||||
|
||||
uploadedAttachments.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Open file preview modal
|
||||
const openPreview = (file: {
|
||||
url: string
|
||||
filename: string
|
||||
type: string
|
||||
file_url?: string
|
||||
size?: number
|
||||
}) => {
|
||||
previewFile.value = file
|
||||
previewModal.value = true
|
||||
}
|
||||
|
||||
// Close preview modal
|
||||
const closePreview = () => {
|
||||
previewModal.value = false
|
||||
// Don't clear previewFile immediately to allow smooth transition
|
||||
setTimeout(() => {
|
||||
previewFile.value = null
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Open file picker
|
||||
const openFilePicker = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
// Check if file type is image (utility function)
|
||||
const isImage = (type: string): boolean => {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
|
||||
return {
|
||||
uploadedAttachments,
|
||||
previewModal,
|
||||
previewFile,
|
||||
formatFileSize,
|
||||
isImageAttachment,
|
||||
getDownloadUrl,
|
||||
getPreviewUrl,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handlePaste,
|
||||
uploadFiles,
|
||||
removeAttachment,
|
||||
openPreview,
|
||||
closePreview,
|
||||
openFilePicker,
|
||||
isImage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { AgentCustomization } from '../types/widget'
|
||||
import { isColorDark } from '../types/widget'
|
||||
import { resolveWidgetUploadUrl } from '../webclient/widget-env'
|
||||
|
||||
// Themed colours now come from the shared design tokens (widget-theme.ts →
|
||||
// `--cm-*` vars set on `.chat-container`). These computeds return `var(--cm-*)`
|
||||
// references so the widget's existing inline styles resolve to the token values,
|
||||
// while user overrides are folded into the vars by `themeCssVars()`.
|
||||
export function useWidgetStyles(customization: { value: AgentCustomization }) {
|
||||
const chatStyles = computed(() => ({
|
||||
backgroundColor: 'var(--cm-card)',
|
||||
color: 'var(--cm-text)'
|
||||
}))
|
||||
|
||||
// Launcher icon still follows the dedicated chat_bubble_color.
|
||||
const chatIconStyles = computed(() => ({
|
||||
backgroundColor: customization.value.chat_bubble_color || '#C9F24E',
|
||||
color: isColorDark(customization.value.chat_bubble_color || '#C9F24E') ? '#FFFFFF' : '#000000'
|
||||
}))
|
||||
|
||||
const agentBubbleStyles = computed(() => ({
|
||||
backgroundColor: 'var(--cm-agent-bg)',
|
||||
color: 'var(--cm-text)'
|
||||
}))
|
||||
|
||||
const userBubbleStyles = computed(() => ({
|
||||
backgroundColor: 'var(--cm-accent)',
|
||||
color: 'var(--cm-on-accent)'
|
||||
}))
|
||||
|
||||
const messageNameStyles = computed(() => ({
|
||||
color: 'var(--cm-text)'
|
||||
}))
|
||||
|
||||
const headerBorderStyles = computed(() => ({
|
||||
borderBottom: '1px solid var(--cm-hairline)'
|
||||
}))
|
||||
|
||||
// Signed S3/CDN URLs pass through; local paths are resolved against the
|
||||
// runtime API origin (photo_url already carries the /api/v1 prefix).
|
||||
const photoUrl = computed(() => resolveWidgetUploadUrl(customization.value.photo_url))
|
||||
|
||||
const shadowStyle = computed(() => {
|
||||
const bgColor = customization.value.chat_background_color || '#ffffff'
|
||||
return {
|
||||
boxShadow: `0 8px 5px ${isColorDark(bgColor) ? 'rgba(0, 0, 0, 0.24)' : 'rgba(0, 0, 0, 0.12)'}`
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
chatStyles,
|
||||
chatIconStyles,
|
||||
agentBubbleStyles,
|
||||
userBubbleStyles,
|
||||
messageNameStyles,
|
||||
headerBorderStyles,
|
||||
photoUrl,
|
||||
shadowStyle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { getApiUrl } from '@/config/api'
|
||||
|
||||
// The loader (chattermate.min.js) is served by THIS frontend, so resolve its
|
||||
// origin at snippet-generation time from where the dashboard is running — no
|
||||
// build-time VITE_WIDGET_URL bake, so a self-hosted install just works.
|
||||
function getLoaderOrigin(): string {
|
||||
return typeof window !== 'undefined' ? window.location.origin : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the embeddable <script> snippet for a widget.
|
||||
* - Simple (anonymous) variant: drops `window.chattermateId` + the loader script.
|
||||
* - Token-auth variant: fetches a short-lived token from the host's backend
|
||||
* before loading the widget (used when the agent requires token auth).
|
||||
* Shared by the AI Agents list and the onboarding Launch step. Its exact text
|
||||
* is pinned in __tests__/fixtures/widget-snippet.txt, which other code that
|
||||
* builds the snippet tests against too.
|
||||
*
|
||||
* `window.chattermateBaseUrl` is baked into the snippet from the runtime API URL
|
||||
* so the embedded widget (running on the customer's own site, where APP_CONFIG is
|
||||
* absent) talks to THIS install's backend instead of the vendor cloud.
|
||||
*/
|
||||
export function buildWidgetEmbed(widgetId: string, requireTokenAuth?: boolean): string {
|
||||
const loaderOrigin = getLoaderOrigin()
|
||||
const apiUrl = getApiUrl()
|
||||
if (requireTokenAuth) {
|
||||
return `<!-- Get token from your backend: POST /api/v1/generate-token with API key -->
|
||||
<!-- Security Note: Widget ID and token are cryptographically bound in the JWT. -->
|
||||
<script>
|
||||
(function() {
|
||||
fetch('/api/chattermate')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
let token, widget_id;
|
||||
if (d.data && d.data.token && d.data.widget_id) {
|
||||
token = d.data.token;
|
||||
widget_id = d.data.widget_id;
|
||||
} else if (d.token && d.token.data && d.token.data.data) {
|
||||
token = d.token.data.data.token;
|
||||
widget_id = d.token.data.data.widget_id;
|
||||
} else if (d.token && d.widget_id) {
|
||||
token = d.token;
|
||||
widget_id = d.widget_id;
|
||||
}
|
||||
if (!token || !widget_id) throw new Error('Failed to extract token or widget_id');
|
||||
window.chattermateId = widget_id;
|
||||
window.chattermateBaseUrl = '${apiUrl}';
|
||||
localStorage.setItem('ctid', token);
|
||||
const script = document.createElement('script');
|
||||
script.src = '${loaderOrigin}/webclient/chattermate.min.js';
|
||||
document.head.appendChild(script);
|
||||
})
|
||||
.catch(e => console.error('[ChatterMate] Initialization failed:', e));
|
||||
})();
|
||||
<\/script>`
|
||||
}
|
||||
return `<script>
|
||||
window.chattermateId = '${widgetId}';
|
||||
window.chattermateBaseUrl = '${apiUrl}';
|
||||
<\/script>
|
||||
<script src="${loaderOrigin}/webclient/chattermate.min.js"><\/script>`
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<!--
|
||||
The Ask AI surface used by the ASK_ANYTHING and AURORA chat styles: a command
|
||||
palette, not a chat bubble. One input pinned at the top, suggested questions while
|
||||
it's empty, and answers streaming underneath with their sources — the shape people
|
||||
know from every "Ask AI" search box.
|
||||
|
||||
Presentational only: all state and socket work stays in WidgetBuilder, which passes
|
||||
it in and handles the emitted intents. Colours come from the --cm-* theme tokens
|
||||
(widget-theme.ts), so both styles and any custom accent are handled for free.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { postToHost } from './host-bridge'
|
||||
import NewChatConfirm from './NewChatConfirm.vue'
|
||||
import { NEW_CHAT_LABEL } from './new-chat'
|
||||
import type { Message } from '../types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[]
|
||||
draft: string
|
||||
agentName: string
|
||||
suggestions: string[]
|
||||
welcomeTitle?: string
|
||||
welcomeSubtitle?: string
|
||||
placeholder: string
|
||||
/** Whether a message may be sent: false while a reply is in flight. */
|
||||
inputEnabled: boolean
|
||||
/** Whether the field accepts typing. Stays true while a reply is in flight —
|
||||
* disabling the input blurs it, which cost the visitor their cursor after
|
||||
* every reply (#316). Defaults to inputEnabled for callers that don't care. */
|
||||
typingEnabled?: boolean
|
||||
loading: boolean
|
||||
showCitations: boolean
|
||||
disclaimer?: string
|
||||
/** Whether the embedder is actually showing the widget — gates autofocus. */
|
||||
active: boolean
|
||||
/** False when the site disabled the ⌘K chord. */
|
||||
hotkey: boolean
|
||||
citationLabel: (source: any) => string
|
||||
citationTooltip: (source: any) => string
|
||||
/** Typewriter reveal, keyed by index into `messages` (see useTypewriter). */
|
||||
displayText: (index: number, fullText: string) => string
|
||||
isStreaming: (index: number) => boolean
|
||||
/** Opt-in "New chat" control (customization.allow_new_chat). */
|
||||
canStartNewChat: boolean
|
||||
startingNewChat: boolean
|
||||
/** Armed = the confirmation is showing (ending a chat can't be undone). */
|
||||
newChatArmed: boolean
|
||||
/** Set when the last attempt to start a new chat failed. */
|
||||
newChatError?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:draft', value: string): void
|
||||
(e: 'send'): void
|
||||
(e: 'ask', question: string): void
|
||||
(e: 'close'): void
|
||||
(e: 'newChat'): void
|
||||
(e: 'confirmNewChat'): void
|
||||
(e: 'cancelNewChat'): void
|
||||
}>()
|
||||
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
const bodyEl = ref<HTMLElement | null>(null)
|
||||
const contentEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Questions, answers and system notes. Interactive kinds (forms, ratings, product
|
||||
// cards) never reach here: WidgetBuilder falls the whole conversation back to the
|
||||
// chat panel rather than let this surface drop them.
|
||||
// The original index travels with each turn: useTypewriter keys its reveal state by
|
||||
// position in the unfiltered `messages` array.
|
||||
const RENDERABLE = ['user', 'bot', 'agent', 'system']
|
||||
const turns = computed(() =>
|
||||
props.messages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => RENDERABLE.includes(message.message_type))
|
||||
)
|
||||
|
||||
const hasConversation = computed(() => turns.value.length > 0)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
emit('update:draft', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
// The typing gate, not the sending one: a message typed during a reply has to
|
||||
// reach the parent, which decides whether to send it now or hold it (#316).
|
||||
if (!(props.typingEnabled ?? props.inputEnabled) || !props.draft.trim()) return
|
||||
emit('send')
|
||||
}
|
||||
|
||||
const askSuggestion = (question: string) => {
|
||||
if (!props.inputEnabled) return
|
||||
emit('ask', question)
|
||||
}
|
||||
|
||||
// Esc closes from inside the palette. The loader's own handler lives on the host page
|
||||
// and can't see keys pressed in this iframe, so without this the shortcut would die
|
||||
// the moment the input takes focus — which is immediately.
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform || '')
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
// Same chord that opened it closes it. Platform-specific on purpose: Ctrl+K on
|
||||
// macOS is kill-to-end-of-line inside a text field, and this listener sits on a
|
||||
// surface whose input is focused.
|
||||
const chord = isMac ? (event.metaKey && !event.ctrlKey) : (event.ctrlKey && !event.metaKey)
|
||||
if (props.hotkey && chord && !event.altKey && (event.key === 'k' || event.key === 'K')) {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => inputEl.value?.focus())
|
||||
}
|
||||
|
||||
// An iframe cannot size its embedder, so the palette measures its own content and
|
||||
// asks the loader for that height — that's what makes the box hug a short answer
|
||||
// instead of sitting at a fixed height with dead space under it. The loader clamps.
|
||||
let lastReportedHeight = 0
|
||||
const reportHeight = () => {
|
||||
if (!contentEl.value) return
|
||||
const root = contentEl.value.closest('.askai') as HTMLElement | null
|
||||
const body = bodyEl.value
|
||||
if (!root || !body) return
|
||||
// Fixed chrome (ask bar + footer), then the content's natural height plus the
|
||||
// scroll area's own padding — offsetHeight of the body is the CLAMPED height, so
|
||||
// it can't be used for the content itself.
|
||||
const chrome = root.offsetHeight - body.offsetHeight
|
||||
const style = getComputedStyle(body)
|
||||
const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
|
||||
const height = Math.ceil(chrome + padding + contentEl.value.getBoundingClientRect().height)
|
||||
// Ignore sub-pixel churn, which would otherwise ping-pong with the resize.
|
||||
if (Math.abs(height - lastReportedHeight) < 3) return
|
||||
lastReportedHeight = height
|
||||
postToHost({ type: 'WIDGET_RESIZE', height })
|
||||
}
|
||||
|
||||
let contentObserver: ResizeObserver | null = null
|
||||
|
||||
// Keep the tail in view as an answer types itself out. Measures REVEALED length, so
|
||||
// the scroll follows the typewriter rather than jumping once on arrival.
|
||||
const revealedLength = computed(() =>
|
||||
turns.value.reduce((n, { message, index }) => n + props.displayText(index, message.message || '').length, 0)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [turns.value.length, revealedLength.value, props.loading] as const,
|
||||
() => nextTick(() => {
|
||||
if (bodyEl.value) bodyEl.value.scrollTop = bodyEl.value.scrollHeight
|
||||
})
|
||||
)
|
||||
|
||||
// The confirmation bar is chrome outside the measured content, so the ResizeObserver
|
||||
// never sees it come or go - the palette would be reported at the wrong height and
|
||||
// clip it.
|
||||
watch(() => props.newChatArmed, () => nextTick(() => reportHeight()))
|
||||
|
||||
// Focus only once the embedder actually shows the widget: the iframe is created
|
||||
// eagerly at page load, so focusing on mount would steal it from the host page.
|
||||
watch(() => props.active, (visible) => {
|
||||
if (visible) focusInput()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) focusInput()
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
if (contentEl.value && typeof ResizeObserver !== 'undefined') {
|
||||
contentObserver = new ResizeObserver(() => reportHeight())
|
||||
contentObserver.observe(contentEl.value)
|
||||
}
|
||||
reportHeight()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
contentObserver?.disconnect()
|
||||
contentObserver = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="askai">
|
||||
<!-- Ask bar: stays at the top, follow-ups are typed here too. -->
|
||||
<div class="askai__bar">
|
||||
<svg class="askai__bar-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3l1.9 4.9L19 9.8l-4.9 1.9L12 17l-1.9-5.3L5 9.8l5.1-1.9L12 3z" />
|
||||
</svg>
|
||||
<input
|
||||
ref="inputEl"
|
||||
type="text"
|
||||
class="askai__input"
|
||||
:value="draft"
|
||||
:placeholder="placeholder"
|
||||
:disabled="!(typingEnabled ?? inputEnabled)"
|
||||
:aria-label="placeholder"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@input="onInput"
|
||||
@keydown.enter.prevent="submit"
|
||||
>
|
||||
<button
|
||||
v-if="canStartNewChat"
|
||||
type="button"
|
||||
class="askai__new"
|
||||
:class="{ 'askai__new--armed': newChatArmed }"
|
||||
:disabled="startingNewChat"
|
||||
:title="NEW_CHAT_LABEL"
|
||||
:aria-label="NEW_CHAT_LABEL"
|
||||
:aria-expanded="newChatArmed"
|
||||
@click="emit('newChat')"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="askai__close" aria-label="关闭" title="关闭(Esc)" @click="emit('close')">
|
||||
<span class="askai__kbd">Esc</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NewChatConfirm
|
||||
v-if="newChatArmed && canStartNewChat"
|
||||
:busy="startingNewChat"
|
||||
:error="newChatError"
|
||||
@confirm="emit('confirmNewChat')"
|
||||
@cancel="emit('cancelNewChat')"
|
||||
/>
|
||||
|
||||
<div ref="bodyEl" class="askai__body">
|
||||
<!-- flow-root so child margins don't collapse out of this box: its measured
|
||||
height is what the palette asks the loader to size itself to. -->
|
||||
<div ref="contentEl" class="askai__content">
|
||||
<!-- Empty state. A blank panel is the one thing an answer surface must
|
||||
never show: give people something to click. -->
|
||||
<template v-if="!hasConversation">
|
||||
<div class="askai__intro">
|
||||
<h2 class="askai__title">{{ welcomeTitle || `向 ${agentName} 提问` }}</h2>
|
||||
<p v-if="welcomeSubtitle" class="askai__subtitle">{{ welcomeSubtitle }}</p>
|
||||
</div>
|
||||
<!-- Hidden once the visitor starts typing: picking a suggestion
|
||||
replaces the draft, so offering both would discard their text. -->
|
||||
<div v-if="suggestions.length && !draft.trim()" class="askai__suggestions">
|
||||
<p class="askai__label">推荐提问</p>
|
||||
<button
|
||||
v-for="question in suggestions"
|
||||
:key="question"
|
||||
type="button"
|
||||
class="askai__suggestion"
|
||||
:disabled="!inputEnabled"
|
||||
@click="askSuggestion(question)"
|
||||
>
|
||||
<span>{{ question }}</span>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Answers. Questions read as headings, answers as prose — a document,
|
||||
not a back-and-forth of bubbles. -->
|
||||
<template v-else>
|
||||
<!-- Announced only once the reveal finishes: a live region on text that
|
||||
mutates per character makes screen readers re-read the whole answer. -->
|
||||
<div
|
||||
v-for="{ message, index } in turns"
|
||||
:key="index"
|
||||
class="askai__turn"
|
||||
:aria-live="isStreaming(index) ? 'off' : 'polite'"
|
||||
>
|
||||
<p v-if="message.message_type === 'user'" class="askai__question">{{ message.message }}</p>
|
||||
<p v-else-if="message.message_type === 'system'" class="askai__system">{{ message.message }}</p>
|
||||
<template v-else>
|
||||
<!-- Revealed progressively while streaming, so an answer types
|
||||
itself out here exactly as it does in the chat panel. -->
|
||||
<div
|
||||
class="askai__answer"
|
||||
:class="{ 'askai__answer--streaming': isStreaming(index) }"
|
||||
v-html="renderMarkdown(isStreaming(index) ? displayText(index, message.message || '') : (message.message || ''))"
|
||||
></div>
|
||||
<div v-if="showCitations && !isStreaming(index) && message.sources && message.sources.length" class="askai__sources">
|
||||
<span class="askai__label">参考来源</span>
|
||||
<span
|
||||
v-for="(source, sourceIndex) in message.sources"
|
||||
:key="sourceIndex"
|
||||
class="askai__source"
|
||||
:title="citationTooltip(source)"
|
||||
>{{ citationLabel(source) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="loading" class="askai__thinking" role="status" aria-live="polite">
|
||||
<span class="askai__dot"></span><span class="askai__dot"></span><span class="askai__dot"></span>
|
||||
<span class="askai__thinking-text">{{ showCitations ? '正在检索知识库' : '思考中' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="askai__foot">
|
||||
<span v-if="disclaimer">{{ disclaimer }}</span>
|
||||
<a class="askai__brand" href="https://chattermate.chat" target="_blank" rel="noopener noreferrer">由 ChatterMate 提供支持</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Every colour is a --cm-* theme token, so light (Ask Anything) and dark (Aurora)
|
||||
both work, and a custom accent_color flows through untouched. */
|
||||
.askai {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--cm-card);
|
||||
color: var(--cm-text);
|
||||
font-family: var(--cm-body-font, inherit);
|
||||
}
|
||||
|
||||
.askai__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 14px 14px 16px;
|
||||
border-bottom: 1px solid var(--cm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.askai__bar-icon {
|
||||
color: var(--cm-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.askai__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--cm-text);
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.askai__input::placeholder { color: var(--cm-muted); }
|
||||
.askai__input:disabled { opacity: 0.6; }
|
||||
|
||||
/* Icon-only, same weight as the Esc chip beside it — the bar is for asking, not a
|
||||
place for two competing labelled buttons. */
|
||||
.askai__new {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--cm-border);
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--cm-muted);
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.askai__new:hover:not(:disabled) {
|
||||
color: var(--cm-text);
|
||||
border-color: var(--cm-accent);
|
||||
}
|
||||
|
||||
.askai__new:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.askai__new--armed {
|
||||
color: var(--cm-text);
|
||||
border-color: var(--cm-accent);
|
||||
}
|
||||
|
||||
.askai__close {
|
||||
border: 1px solid var(--cm-border);
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
padding: 3px 7px;
|
||||
cursor: pointer;
|
||||
color: var(--cm-muted);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.askai__close:hover {
|
||||
color: var(--cm-text);
|
||||
border-color: var(--cm-accent);
|
||||
}
|
||||
|
||||
.askai__kbd {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.askai__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* Reserve the scrollbar gutter: without it, the bar appearing mid-answer narrows
|
||||
the text, re-wraps it taller, and the auto-grow measurement oscillates. */
|
||||
scrollbar-gutter: stable;
|
||||
padding: 18px 20px 22px;
|
||||
}
|
||||
|
||||
.askai__content { display: flow-root; }
|
||||
|
||||
.askai__intro { margin-bottom: 18px; }
|
||||
|
||||
.askai__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.askai__subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--cm-muted);
|
||||
}
|
||||
|
||||
.askai__label {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--cm-muted);
|
||||
}
|
||||
|
||||
.askai__suggestions { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.askai__suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--cm-border);
|
||||
border-radius: 10px;
|
||||
background: var(--cm-agent-bg);
|
||||
color: var(--cm-text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.askai__suggestion:hover:not(:disabled) {
|
||||
border-color: var(--cm-accent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.askai__suggestion:disabled { opacity: 0.55; cursor: default; }
|
||||
.askai__suggestion svg { color: var(--cm-muted); flex-shrink: 0; }
|
||||
|
||||
.askai__turn + .askai__turn { margin-top: 20px; }
|
||||
|
||||
.askai__question {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.askai__answer {
|
||||
font-size: 14.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--cm-text);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Blinking caret rides the end of the last rendered block while text reveals. */
|
||||
.askai__answer--streaming :deep(> :last-child)::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 1em;
|
||||
margin-left: 2px;
|
||||
vertical-align: -0.15em;
|
||||
background: var(--cm-accent, currentColor);
|
||||
animation: askai-blink 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes askai-blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.askai__answer :deep(p) { margin: 0 0 10px; }
|
||||
.askai__answer :deep(p:last-child) { margin-bottom: 0; }
|
||||
.askai__answer :deep(ul),
|
||||
.askai__answer :deep(ol) { margin: 0 0 10px; padding-left: 20px; }
|
||||
.askai__answer :deep(li) { margin-bottom: 4px; }
|
||||
.askai__answer :deep(a) { color: var(--cm-accent); }
|
||||
.askai__answer :deep(code) {
|
||||
background: var(--cm-agent-bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.askai__answer :deep(pre) {
|
||||
background: var(--cm-agent-bg);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.askai__answer :deep(pre code) { background: none; padding: 0; }
|
||||
/* Wide tables scroll inside the answer instead of forcing a horizontal scrollbar on
|
||||
the whole body (which the height measurement doesn't account for). */
|
||||
.askai__answer :deep(table) { display: block; max-width: 100%; overflow-x: auto; }
|
||||
|
||||
.askai__sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.askai__sources .askai__label { margin: 0 2px 0 0; }
|
||||
|
||||
.askai__source {
|
||||
font-size: 12px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--cm-border);
|
||||
color: var(--cm-muted);
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.askai__thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 16px;
|
||||
color: var(--cm-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.askai__thinking-text { margin-left: 4px; }
|
||||
|
||||
.askai__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--cm-accent);
|
||||
animation: askai-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.askai__dot:nth-child(2) { animation-delay: 0.18s; }
|
||||
.askai__dot:nth-child(3) { animation-delay: 0.36s; }
|
||||
|
||||
@keyframes askai-pulse {
|
||||
0%, 100% { opacity: 0.35; transform: translateY(0); }
|
||||
50% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.askai__dot { animation: none; }
|
||||
.askai__suggestion:hover:not(:disabled) { transform: none; }
|
||||
.askai__answer--streaming :deep(> :last-child)::after { display: none; }
|
||||
}
|
||||
|
||||
.askai__system {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: var(--cm-muted);
|
||||
}
|
||||
|
||||
.askai__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
margin: 0;
|
||||
padding: 10px 16px 12px;
|
||||
border-top: 1px solid var(--cm-border);
|
||||
font-size: 11.5px;
|
||||
text-align: center;
|
||||
color: var(--cm-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.askai__brand {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.askai__brand:hover { text-decoration: underline; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.askai__body { padding: 16px 16px 20px; }
|
||||
/* 16px keeps iOS from zooming the page when the input takes focus. */
|
||||
.askai__input { font-size: 16px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Asks before a new chat ends the current one.
|
||||
*
|
||||
* Ending a chat cannot be undone, and the control used to say so only in a tooltip
|
||||
* and a hint that lapsed after a few seconds - easy to miss, and easy to trigger by
|
||||
* accident. This states the consequence inside the widget and waits for an answer.
|
||||
* Shared by the chat header and the Ask AI bar so both ask the same question.
|
||||
*/
|
||||
import {
|
||||
NEW_CHAT_CANCEL_ACTION,
|
||||
NEW_CHAT_CONFIRM_ACTION,
|
||||
NEW_CHAT_CONFIRM_QUESTION,
|
||||
} from './new-chat'
|
||||
|
||||
defineProps<{
|
||||
/** Shown instead of the question when the last attempt failed. */
|
||||
error?: string
|
||||
/** Disables the actions while the close is in flight. */
|
||||
busy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="new-chat-confirm" role="alertdialog" aria-live="polite" :aria-label="NEW_CHAT_CONFIRM_QUESTION">
|
||||
<p class="new-chat-confirm__question">{{ error || NEW_CHAT_CONFIRM_QUESTION }}</p>
|
||||
<div class="new-chat-confirm__actions">
|
||||
<button type="button" class="new-chat-confirm__button" :disabled="busy" @click="emit('cancel')">
|
||||
{{ NEW_CHAT_CANCEL_ACTION }}
|
||||
</button>
|
||||
<button type="button" class="new-chat-confirm__button new-chat-confirm__button--primary" :disabled="busy"
|
||||
@click="emit('confirm')">
|
||||
{{ NEW_CHAT_CONFIRM_ACTION }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.new-chat-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--cm-hairline, rgba(0, 0, 0, 0.07));
|
||||
background: var(--cm-agent-bg, rgba(0, 0, 0, 0.03));
|
||||
font-family: var(--cm-body-font, inherit);
|
||||
}
|
||||
|
||||
.new-chat-confirm__question {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.35;
|
||||
color: var(--cm-text, inherit);
|
||||
}
|
||||
|
||||
.new-chat-confirm__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.new-chat-confirm__button {
|
||||
padding: 5px 11px;
|
||||
border-radius: var(--cm-field-radius, 8px);
|
||||
border: 1px solid var(--cm-border, rgba(0, 0, 0, 0.12));
|
||||
background: transparent;
|
||||
color: var(--cm-text, inherit);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.new-chat-confirm__button--primary {
|
||||
border-color: transparent;
|
||||
background: var(--cm-accent, #f34611);
|
||||
color: var(--cm-on-accent, #ffffff);
|
||||
}
|
||||
|
||||
.new-chat-confirm__button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.new-chat-confirm__button:not(:disabled):hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Ending a chat cannot be undone, and on a phone this is a thumb hitting glass:
|
||||
give both answers a real touch target rather than a 27px pill. */
|
||||
@media (pointer: coarse) {
|
||||
.new-chat-confirm {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.new-chat-confirm__button {
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.new-chat-confirm__button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2024-2026 ChatterMate
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The one way the widget talks to the page it is embedded in.
|
||||
*
|
||||
* These messages include the visitor's conversation token, so they are addressed to a
|
||||
* specific origin rather than broadcast with "*": if the embedding page is ever
|
||||
* replaced by another site's document, the browser drops the message instead of
|
||||
* handing that site a live token.
|
||||
*
|
||||
* The widget runs in a srcdoc iframe, which inherits the embedder's origin, so the
|
||||
* host origin is normally readable. When it genuinely cannot be determined we fall
|
||||
* back to "*" - a widget that cannot talk to its loader is broken, and that is worse
|
||||
* than the broadcast we have always done.
|
||||
*/
|
||||
|
||||
const ANY_ORIGIN = '*'
|
||||
|
||||
/** Origins a browser reports for an opaque (sandboxed, data:, srcdoc) document. */
|
||||
const OPAQUE = new Set(['null', 'about:blank', ''])
|
||||
|
||||
let resolved: string | null = null
|
||||
|
||||
const usable = (origin: string | undefined | null): string | null =>
|
||||
origin && !OPAQUE.has(origin) ? origin : null
|
||||
|
||||
const originOf = (url: string | undefined | null): string | null => {
|
||||
if (!url) return null
|
||||
try {
|
||||
return usable(new URL(url).origin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin of the page hosting the widget, or "*" when it cannot be established.
|
||||
* Resolved once: an embedding page navigating between routes keeps its origin.
|
||||
*/
|
||||
export function hostOrigin(): string {
|
||||
if (resolved) return resolved
|
||||
|
||||
// Not framed at all - the dashboard preview renders the widget inline.
|
||||
if (window.parent === window) {
|
||||
resolved = usable(window.location.origin) || ANY_ORIGIN
|
||||
return resolved
|
||||
}
|
||||
|
||||
// Same-origin parent: the srcdoc frame the embed loader creates. Reading its
|
||||
// location throws for a cross-origin embed, which the catch below handles.
|
||||
try {
|
||||
const parentOrigin = usable(window.parent.location.origin)
|
||||
if (parentOrigin) {
|
||||
resolved = parentOrigin
|
||||
return resolved
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin parent - fall through */
|
||||
}
|
||||
|
||||
// Cross-origin embed: the browser still names our ancestors (not in Firefox), and
|
||||
// the referrer is the embedding document.
|
||||
try {
|
||||
const ancestors = window.location.ancestorOrigins
|
||||
const nearest = ancestors && ancestors.length ? usable(ancestors[0]) : null
|
||||
if (nearest) {
|
||||
resolved = nearest
|
||||
return resolved
|
||||
}
|
||||
} catch {
|
||||
/* not supported - fall through */
|
||||
}
|
||||
|
||||
resolved = originOf(document.referrer) || ANY_ORIGIN
|
||||
return resolved
|
||||
}
|
||||
|
||||
/** Send a message to the embedding page, addressed to its origin. */
|
||||
export function postToHost(message: Record<string, unknown>): void {
|
||||
window.parent.postMessage(message, hostOrigin())
|
||||
}
|
||||
|
||||
/** Test seam: forget the cached origin. */
|
||||
export function resetHostOrigin(): void {
|
||||
resolved = null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Agent-answer markdown → safe HTML, shared by every widget surface that renders a
|
||||
* reply (the chat panel and the Ask AI palette) so they can't drift apart on either
|
||||
* formatting or sanitisation.
|
||||
*
|
||||
* Output always goes through `sanitizeHtml`, which strips iframes/scripts/handlers —
|
||||
* answers contain model output and knowledge-base content, so it is never trusted.
|
||||
*/
|
||||
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeHtml } from '../utils/sanitize'
|
||||
|
||||
marked.setOptions({
|
||||
renderer: new marked.Renderer(),
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
})
|
||||
|
||||
// Links open in a new tab (the widget lives in an iframe, so a same-tab navigation
|
||||
// would replace the chat rather than the page). sanitizeHtml already forces
|
||||
// target="_blank" + rel on every surviving anchor, so nothing is needed here.
|
||||
export const renderMarkdown = (text: string): string => sanitizeHtml(marked(text || '') as string)
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2024-2026 ChatterMate
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copy for the "New chat" control. Starting a new chat ends the current one for
|
||||
* good, so both surfaces (chat header and the Ask AI bar) ask the same question in
|
||||
* the same words, from here.
|
||||
*/
|
||||
export const NEW_CHAT_LABEL = '开始新会话'
|
||||
export const NEW_CHAT_CONFIRM_QUESTION = '开始新会话?当前会话将结束。'
|
||||
export const NEW_CHAT_CONFIRM_ACTION = '开始新会话'
|
||||
export const NEW_CHAT_CANCEL_ACTION = '取消'
|
||||
export const NEW_CHAT_FAILED_TEXT = "无法开始新会话,请重试。"
|
||||
|
||||
/** How long the confirmation stays up before it gives up on being answered. */
|
||||
export const NEW_CHAT_CONFIRM_TIMEOUT_MS = 15000
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-nocheck
|
||||
import { buildUploadUrl } from '../utils/avatars';
|
||||
|
||||
export interface InitialData {
|
||||
widgetId: string;
|
||||
agentName: string;
|
||||
customization: Record<string, any>;
|
||||
customerId: string;
|
||||
customer: Record<string, any>;
|
||||
initialToken?: string;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
interface Window {
|
||||
// @ts-ignore
|
||||
__INITIAL_DATA__: InitialData;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get runtime configuration
|
||||
function getRuntimeConfig() {
|
||||
// @ts-ignore - APP_CONFIG might not be available at build time
|
||||
return typeof window !== 'undefined' && window.APP_CONFIG ? window.APP_CONFIG : {};
|
||||
}
|
||||
|
||||
export const widgetEnv = {
|
||||
get API_URL() {
|
||||
const config = getRuntimeConfig();
|
||||
// Default to the production API so a plain `build:widget` (no VITE_API_URL,
|
||||
// no runtime APP_CONFIG) ships a working widget instead of localhost. Local
|
||||
// dev overrides via VITE_API_URL; self-hosters via window.APP_CONFIG.
|
||||
return config.API_URL || import.meta.env.VITE_API_URL || 'https://api.chattermate.chat/api/v1';
|
||||
},
|
||||
get WS_URL() {
|
||||
const config = getRuntimeConfig();
|
||||
return config.WS_URL || import.meta.env.VITE_WS_URL || 'wss://api.chattermate.chat';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget-side counterpart of resolveUploadUrl (src/config/api.ts). Same rules,
|
||||
* but resolved against widgetEnv.API_URL so the widget keeps its own fallback.
|
||||
*/
|
||||
export function resolveWidgetUploadUrl(stored?: string | null): string {
|
||||
return buildUploadUrl(stored, widgetEnv.API_URL);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* SHARED chat-surface theme — the single place the widget's themed appearance is
|
||||
* defined. Imported by BOTH the real widget (`WidgetBuilder.vue`) and the admin
|
||||
* preview (`AgentChatPreviewPanel.vue`) so they can never drift.
|
||||
*
|
||||
* Every rule is scoped under `.chat-container.cm-surface` (both surfaces add the
|
||||
* `cm-surface` marker class to their panel root). The extra class both contains the
|
||||
* global import to the widget and gives these rules enough specificity to win over
|
||||
* each component's own scoped `[data-v-*]` CSS. Every value comes from the `--cm-*`
|
||||
* custom properties emitted by `themeCssVars()` in `widget-theme.ts` — no hard-coded
|
||||
* per-theme values live here.
|
||||
*
|
||||
* Components keep only their own NON-themed, structural CSS (layout, animations,
|
||||
* socket/streaming bits, admin-mock chrome) in their scoped <style> blocks.
|
||||
*/
|
||||
|
||||
/* ===== Panel ===== */
|
||||
.chat-container.cm-surface {
|
||||
font-family: var(--cm-body-font);
|
||||
color: var(--cm-text);
|
||||
}
|
||||
.chat-container.cm-surface .chat-panel {
|
||||
background: var(--cm-card);
|
||||
color: var(--cm-text);
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.chat-container.cm-surface .chat-header {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--cm-hairline);
|
||||
background: transparent;
|
||||
}
|
||||
.chat-container.cm-surface .header-info h3 {
|
||||
color: var(--cm-text);
|
||||
font-family: var(--cm-body-font);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
/* --cm-presence is the accent when it is readable on the card, otherwise the
|
||||
contrast-derived muted colour (see themeCssVars). */
|
||||
.chat-container.cm-surface .cm-presence,
|
||||
.chat-container.cm-surface .status-text {
|
||||
color: var(--cm-presence, var(--cm-accent));
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.chat-container.cm-surface .status-indicator.online {
|
||||
background: var(--cm-presence, var(--cm-accent));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ===== Avatars — circle by default, rounded-square on mono (Terminal); no ring ===== */
|
||||
.chat-container.cm-surface .header-orb,
|
||||
.chat-container.cm-surface .header-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--cm-avatar-radius);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-container.cm-surface .cm-msg-avatar,
|
||||
.chat-container.cm-surface .cm-msg-avatar-orb,
|
||||
.chat-container.cm-surface .cm-msg-avatar-img,
|
||||
.chat-container.cm-surface .cm-welcome-orb,
|
||||
.chat-container.cm-surface .cm-welcome-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--cm-avatar-radius);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.chat-container.cm-surface .cm-msg-avatar-img,
|
||||
.chat-container.cm-surface .cm-welcome-avatar { object-fit: cover; }
|
||||
|
||||
/* ===== Messages ===== */
|
||||
.chat-container.cm-surface .message {
|
||||
max-width: 100%;
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.chat-container.cm-surface .message-bubble {
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
box-shadow: none;
|
||||
max-width: 84%;
|
||||
}
|
||||
/* Collapse markdown paragraph margins so a one-line reply is a compact bubble. */
|
||||
.chat-container.cm-surface .message-bubble p { margin: 0; }
|
||||
.chat-container.cm-surface .message-bubble p + p { margin-top: 0.5em; }
|
||||
/* Markdown links inside messages: clearly a link and clickable (v-html content, so
|
||||
this must be a global rule — scoped styles don't reach it). Inherits the bubble
|
||||
colour so it's readable on both the agent surface and the accent user bubble. */
|
||||
.chat-container.cm-surface .message-bubble a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-container.cm-surface .message-bubble a:hover { opacity: 0.82; }
|
||||
/* Markdown code & wide content (v-html, so global rules): scroll inside the
|
||||
bubble instead of painting past it and forcing the chat to scroll sideways.
|
||||
Backgrounds derive from currentColor so they read on light and dark bubbles. */
|
||||
.chat-container.cm-surface .message-bubble pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
padding: var(--space-sm, 12px);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.chat-container.cm-surface .message-bubble pre code {
|
||||
/* Code keeps its formatting; the <pre> scrollbar handles long lines. */
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.chat-container.cm-surface .message-bubble code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.chat-container.cm-surface .message-bubble table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.chat-container.cm-surface .message-bubble th,
|
||||
.chat-container.cm-surface .message-bubble td {
|
||||
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
/* Agent/bot bubble: agent surface + hairline, tail top-left. */
|
||||
.chat-container.cm-surface .agent-message .message-bubble,
|
||||
.chat-container.cm-surface .assistant-message .message-bubble {
|
||||
background: var(--cm-agent-bg);
|
||||
color: var(--cm-text);
|
||||
border: 1px solid var(--cm-hairline);
|
||||
border-radius: var(--cm-bubble-tail) var(--cm-bubble) var(--cm-bubble) var(--cm-bubble);
|
||||
}
|
||||
/* User bubble: accent fill, contrast text, tail bottom-right. */
|
||||
.chat-container.cm-surface .user-message .message-bubble {
|
||||
background: var(--cm-accent);
|
||||
color: var(--cm-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--cm-bubble) var(--cm-bubble) var(--cm-bubble-tail) var(--cm-bubble);
|
||||
}
|
||||
|
||||
/* ===== Quick-action pills ===== */
|
||||
.chat-container.cm-surface .cm-quick-action {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--cm-accent) 66%, transparent);
|
||||
background: color-mix(in srgb, var(--cm-accent) 18%, transparent);
|
||||
color: var(--cm-text);
|
||||
font-family: var(--cm-body-font);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, color .15s ease, border-color .15s ease;
|
||||
}
|
||||
.chat-container.cm-surface .cm-quick-action:hover:not(:disabled) {
|
||||
background: var(--cm-accent);
|
||||
color: var(--cm-on-accent);
|
||||
border-color: var(--cm-accent);
|
||||
}
|
||||
.chat-container.cm-surface.theme-terminal .cm-quick-action { border-radius: 6px; }
|
||||
|
||||
/* ===== Input bar + send (transparent bar — card shows through, no grey band) ===== */
|
||||
.chat-container.cm-surface .chat-input {
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--cm-hairline);
|
||||
}
|
||||
.chat-container.cm-surface .message-input input,
|
||||
.chat-container.cm-surface .email-input input {
|
||||
border-radius: var(--cm-field-radius);
|
||||
border: 1px solid color-mix(in srgb, var(--cm-accent) 30%, var(--cm-hairline));
|
||||
background: color-mix(in srgb, var(--cm-text) 6%, transparent);
|
||||
color: var(--cm-text);
|
||||
font-family: var(--cm-body-font);
|
||||
font-size: 14px;
|
||||
}
|
||||
.chat-container.cm-surface .message-input input::placeholder,
|
||||
.chat-container.cm-surface .email-input input::placeholder {
|
||||
color: var(--cm-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
.chat-container.cm-surface .message-input input:focus,
|
||||
.chat-container.cm-surface .email-input input:focus {
|
||||
border-color: var(--cm-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cm-accent) 22%, transparent);
|
||||
}
|
||||
.chat-container.cm-surface .send-button {
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: var(--cm-field-radius);
|
||||
background: var(--cm-accent);
|
||||
color: var(--cm-on-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ===== Powered-by footer (transparent — card shows, no grey band) ===== */
|
||||
.chat-container.cm-surface .powered-by,
|
||||
.chat-container.cm-surface .powered-by-welcome {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
color: var(--cm-muted);
|
||||
font-family: var(--cm-body-font);
|
||||
}
|
||||
.chat-container.cm-surface .cm-powered-prefix { color: var(--cm-muted); opacity: 1; }
|
||||
.chat-container.cm-surface .cm-brand { color: var(--cm-text); font-weight: 700; }
|
||||
|
||||
/* Mono (Terminal): monospace across the whole surface. */
|
||||
.chat-container.cm-surface.theme-terminal,
|
||||
.chat-container.cm-surface.theme-terminal .chat-panel,
|
||||
.chat-container.cm-surface.theme-terminal .header-info h3,
|
||||
.chat-container.cm-surface.theme-terminal .message-bubble,
|
||||
.chat-container.cm-surface.theme-terminal .cm-quick-action,
|
||||
.chat-container.cm-surface.theme-terminal .message-input input,
|
||||
.chat-container.cm-surface.theme-terminal .powered-by {
|
||||
font-family: var(--cm-body-font);
|
||||
}
|
||||
|
||||
/* Glass: frosted panel. */
|
||||
.chat-container.cm-surface.theme-glass .chat-panel {
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Single source of truth for the widget's per-`chat_style` design tokens — colours
|
||||
* (panel / text / muted / agent-bubble / accent) AND structure (radius, glow,
|
||||
* border, bubble radius, mono flag). Every visual value the comp defines lives here.
|
||||
*
|
||||
* `themeCssVars()` flattens these into `--cm-*` custom properties that BOTH the real
|
||||
* widget (`WidgetBuilder.vue`) and the admin preview (`AgentChatPreviewPanel.vue`)
|
||||
* apply on `.chat-container`, and the shared `widget-surface.css` consumes. A user's
|
||||
* explicit `accent_color` / `chat_background_color` / `font_family` overrides the
|
||||
* theme default; otherwise the comp values win.
|
||||
*/
|
||||
|
||||
import { isColorDark, adjustColorBrightness } from '../types/widget'
|
||||
|
||||
export interface ThemeTokens {
|
||||
/** Light surface (affects overlay/contrast + hairline choices). */
|
||||
light: boolean
|
||||
/** Monospace-forward theme (Terminal). */
|
||||
mono: boolean
|
||||
/** Panel corner radius, px. */
|
||||
radius: number
|
||||
/** Message-bubble corner radius, px (the speech "tail" corner is derived from this). */
|
||||
bubble: number
|
||||
/** Ambient glow colour used in the panel shadow. */
|
||||
glow: string
|
||||
/** Panel border colour. */
|
||||
border: string
|
||||
/** Panel/card background (solid or gradient). */
|
||||
card: string
|
||||
/** Primary text colour. */
|
||||
text: string
|
||||
/** Muted/secondary text colour. */
|
||||
muted: string
|
||||
/** Agent message-bubble background. */
|
||||
agentBg: string
|
||||
/** Default accent (user `accent_color` overrides). */
|
||||
accent: string
|
||||
}
|
||||
|
||||
// Token sets from the design comp (Glass / Aurora / Terminal / Calm Mint /
|
||||
// Playful / Sunrise). Each chat_style maps 1:1 to a comp theme. The light themes'
|
||||
// muted values are darkened from the comp to reach WCAG AA (4.5:1) on white.
|
||||
const GLASS: ThemeTokens = {
|
||||
light: false, mono: false, radius: 22, bubble: 16,
|
||||
glow: 'rgba(157,140,255,.26)', border: 'rgba(157,140,255,.32)',
|
||||
card: 'linear-gradient(180deg,rgba(28,26,40,.94),rgba(15,14,22,.97))',
|
||||
text: '#ECEAFA', muted: '#9C97BE', agentBg: 'rgba(255,255,255,.06)', accent: '#9D8CFF',
|
||||
}
|
||||
const AURORA: ThemeTokens = {
|
||||
light: false, mono: false, radius: 26, bubble: 18,
|
||||
glow: 'rgba(157,140,255,.32)', border: 'rgba(157,140,255,.40)',
|
||||
card: 'linear-gradient(180deg,#16131F,#0A0910)',
|
||||
text: '#F2F3F8', muted: '#A7A0CC', agentBg: 'rgba(255,255,255,.05)', accent: '#9D8CFF',
|
||||
}
|
||||
const TERMINAL: ThemeTokens = {
|
||||
light: false, mono: true, radius: 8, bubble: 4,
|
||||
glow: 'rgba(201,242,78,.20)', border: 'rgba(201,242,78,.30)',
|
||||
card: '#070907',
|
||||
text: '#D7F7C8', muted: '#7F9B57', agentBg: 'rgba(201,242,78,.045)', accent: '#C9F24E',
|
||||
}
|
||||
const CALM_MINT: ThemeTokens = {
|
||||
light: false, mono: false, radius: 18, bubble: 14,
|
||||
glow: 'rgba(95,227,214,.22)', border: 'rgba(95,227,214,.30)',
|
||||
card: 'linear-gradient(180deg,#0E1A1A,#0A1414)',
|
||||
text: '#DDF7F3', muted: '#6FAFA8', agentBg: 'rgba(255,255,255,.05)', accent: '#5FE3D6',
|
||||
}
|
||||
const PLAYFUL: ThemeTokens = {
|
||||
light: true, mono: false, radius: 28, bubble: 20,
|
||||
glow: 'rgba(255,138,115,.30)', border: 'rgba(0,0,0,.07)',
|
||||
card: '#FFFFFF',
|
||||
text: '#2A2730', muted: '#736C7C', agentBg: '#F4F1F6', accent: '#FF8A73',
|
||||
}
|
||||
const SUNRISE: ThemeTokens = {
|
||||
light: true, mono: false, radius: 24, bubble: 16,
|
||||
glow: 'rgba(255,138,115,.22)', border: 'rgba(0,0,0,.08)',
|
||||
card: '#FFFFFF',
|
||||
text: '#2A2A33', muted: '#6E6E7B', agentBg: '#F3F3F6', accent: '#FF8A73',
|
||||
}
|
||||
|
||||
// chat_style → token-set mapping. CHATBOT (legacy) and ASK_ANYTHING reuse Sunrise.
|
||||
const THEME_TOKENS: Record<string, ThemeTokens> = {
|
||||
GLASS,
|
||||
AURORA,
|
||||
TERMINAL,
|
||||
CALM_MINT,
|
||||
PLAYFUL,
|
||||
SUNRISE,
|
||||
CHATBOT: SUNRISE,
|
||||
ASK_ANYTHING: SUNRISE,
|
||||
}
|
||||
|
||||
const MONO_FONT = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
|
||||
const BODY_FONT = "'Instrument Sans', system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||
|
||||
/** Speech-bubble "tail" corner radius, derived from the bubble radius (comp formula). */
|
||||
function bubbleTail(bubble: number): number {
|
||||
return Math.max(4, Math.round(bubble * 0.3))
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrast colour for text/icons sitting ON the accent (user bubble, send button).
|
||||
* Matches the comp `on()`: relative luminance > 0.62 → near-black, else white.
|
||||
*/
|
||||
export function onAccent(hex: string): string {
|
||||
const c = (hex || '').replace('#', '')
|
||||
if (c.length < 6) return '#0B0C10'
|
||||
const r = parseInt(c.slice(0, 2), 16)
|
||||
const g = parseInt(c.slice(2, 4), 16)
|
||||
const b = parseInt(c.slice(4, 6), 16)
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return lum > 0.62 ? '#0B0C10' : '#FFFFFF'
|
||||
}
|
||||
|
||||
/** Resolve structural + colour tokens for a chat style (defaults to the light Sunrise set). */
|
||||
export function getThemeTokens(chatStyle?: string | null): ThemeTokens {
|
||||
return THEME_TOKENS[chatStyle || ''] || SUNRISE
|
||||
}
|
||||
|
||||
/** Minimal shape of the customization fields that can override theme defaults. */
|
||||
export interface ThemeOverrides {
|
||||
chat_background_color?: string | null
|
||||
chat_text_color?: string | null
|
||||
accent_color?: string | null
|
||||
font_family?: string | null
|
||||
}
|
||||
|
||||
// Legacy DB default for chat_text_color (dark). Treated as "unset" so existing
|
||||
// agents fall back to the per-theme text colour instead of near-black on dark themes.
|
||||
const LEGACY_TEXT_DEFAULT = '#212529'
|
||||
|
||||
/**
|
||||
* Flatten tokens (merged with any user overrides) into the `--cm-*` custom
|
||||
* properties consumed by `widget-surface.css`. This is the ONLY place per-theme
|
||||
* values are turned into CSS — both widget and preview call it.
|
||||
*/
|
||||
export function themeCssVars(chatStyle?: string | null, overrides?: ThemeOverrides): Record<string, string> {
|
||||
const t = getThemeTokens(chatStyle)
|
||||
|
||||
// A user-set background is a hex colour from the picker; when present we derive
|
||||
// text/muted/agent-bubble from it (so custom colours stay legible), otherwise we
|
||||
// use the comp's exact per-theme colours.
|
||||
const customBg = overrides?.chat_background_color || ''
|
||||
const hasCustomBg = /^#[0-9a-fA-F]{6}$/.test(customBg)
|
||||
const card = customBg || t.card
|
||||
|
||||
// Text colour precedence: explicit user text colour > derived from a custom
|
||||
// background > the theme's design default.
|
||||
const customText = overrides?.chat_text_color || ''
|
||||
const hasCustomText = /^#[0-9a-fA-F]{6}$/.test(customText) && customText.toLowerCase() !== LEGACY_TEXT_DEFAULT
|
||||
const text = hasCustomText
|
||||
? customText
|
||||
: (hasCustomBg ? (isColorDark(customBg) ? '#FFFFFF' : '#111111') : t.text)
|
||||
const muted = hasCustomBg ? (isColorDark(customBg) ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.62)') : t.muted
|
||||
const agentBg = hasCustomBg ? adjustColorBrightness(customBg, 20) : t.agentBg
|
||||
|
||||
const accent = overrides?.accent_color || t.accent
|
||||
|
||||
// Presence line ("Online · replies instantly"): the comp colours it with the
|
||||
// accent, but a light accent on a light card (e.g. lime on the white Sunrise
|
||||
// header) — or a dark accent on a dark card — is unreadable. Fall back to the
|
||||
// contrast-derived muted colour in that case. Same luminance rule as onAccent().
|
||||
const cardIsLight = hasCustomBg ? !isColorDark(customBg) : t.light
|
||||
const accentIsLight = onAccent(accent) === '#0B0C10'
|
||||
const presence = cardIsLight === accentIsLight ? muted : accent
|
||||
// font_family may be a bare name ("Space Grotesk") or a full stack
|
||||
// ("Instrument Sans, sans-serif"); append it raw so the comma-separated stack
|
||||
// stays valid (quoting the whole stack would make the first token invalid).
|
||||
const bodyFont = t.mono
|
||||
? MONO_FONT
|
||||
: (overrides?.font_family ? `${overrides.font_family}, ${BODY_FONT}` : BODY_FONT)
|
||||
|
||||
return {
|
||||
'--cm-card': card,
|
||||
'--cm-text': text,
|
||||
'--cm-muted': muted,
|
||||
'--cm-agent-bg': agentBg,
|
||||
'--cm-accent': accent,
|
||||
'--cm-on-accent': onAccent(accent),
|
||||
'--cm-presence': presence,
|
||||
'--cm-border': t.border,
|
||||
'--cm-glow': t.glow,
|
||||
'--cm-radius': `${t.radius}px`,
|
||||
'--cm-bubble': `${t.bubble}px`,
|
||||
'--cm-bubble-tail': `${bubbleTail(t.bubble)}px`,
|
||||
'--cm-field-radius': t.mono ? '7px' : '12px',
|
||||
'--cm-avatar-radius': t.mono ? '28%' : '50%',
|
||||
'--cm-hairline': t.light ? 'rgba(0,0,0,0.07)' : 'rgba(255,255,255,0.08)',
|
||||
'--cm-body-font': bodyFont,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import WidgetBuilder from './WidgetBuilder.vue'
|
||||
import './widget.css'
|
||||
|
||||
// Ensure the environment is defined
|
||||
// @ts-ignore
|
||||
if (!window.process) {
|
||||
// @ts-ignore
|
||||
window.process = { env: { NODE_ENV: 'production' } }
|
||||
}
|
||||
|
||||
// Extract widget ID and token from window or URL
|
||||
// @ts-ignore
|
||||
const initialData = window.__INITIAL_DATA__
|
||||
const url = new URL(window.location.href)
|
||||
const isPreviewMode = url.searchParams.get('preview') === 'true'
|
||||
|
||||
// Helper to handle URL params that might be the string "undefined"
|
||||
const getURLParam = (name: string): string | undefined => {
|
||||
const value = url.searchParams.get(name)
|
||||
// Return undefined if value is null, empty, or the literal string "undefined"
|
||||
if (!value || value === 'undefined' || value.trim() === '') {
|
||||
return undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Get widget ID - prefer URL param in preview mode
|
||||
const widgetId = isPreviewMode
|
||||
? getURLParam('widget_id') || (initialData?.widgetId) || undefined
|
||||
: (initialData?.widgetId) || undefined
|
||||
|
||||
// Get token - prefer initial data, fallback to URL param in preview mode
|
||||
// @ts-ignore
|
||||
const token = isPreviewMode
|
||||
? (initialData?.initialToken) || getURLParam('token') || undefined
|
||||
: (initialData?.initialToken) || undefined
|
||||
|
||||
/**
|
||||
* SECURITY: Token Handling
|
||||
*
|
||||
* The widget supports two authentication modes based on agent's require_token_auth setting:
|
||||
*
|
||||
* 1. require_token_auth=true: Token MUST be provided from portal backend via /api/v1/generate-token
|
||||
* 2. require_token_auth=false: Backend auto-generates tokens for anonymous users
|
||||
*
|
||||
* Token is a JWT that cryptographically binds:
|
||||
* - widget_id (the specific widget instance)
|
||||
* - customer_id (the authenticated user)
|
||||
* - expiration time (TTL)
|
||||
*
|
||||
* Token signature is verified on the backend before any WebSocket connection.
|
||||
*/
|
||||
|
||||
const app = createApp(WidgetBuilder, {
|
||||
widgetId: widgetId,
|
||||
token: token || undefined,
|
||||
initialAuthError: null // Let backend determine if auth is required
|
||||
})
|
||||
app.mount('#app')
|
||||