Quellcode durchsuchen

[Mod 0000]AI助手文件上传预览支持更多的格式

wangkeyi vor 3 Wochen
Ursprung
Commit
7c4f3a6255

+ 1 - 0
package.json

@@ -94,6 +94,7 @@
     "xgplayer-flv": "^3.0.14",
     "xgplayer-hls": "^3.0.14",
     "xgplayer-mp4": "^3.0.21",
+    "xlsx": "^0.18.5",
     "xss": "^1.0.14"
   },
   "devDependencies": {

+ 25 - 4
pnpm-lock.yaml

@@ -221,6 +221,9 @@ importers:
       xgplayer-mp4:
         specifier: ^3.0.21
         version: 3.0.26(core-js@3.49.0)(xgplayer@3.0.26(core-js@3.49.0))
+      xlsx:
+        specifier: ^0.18.5
+        version: 0.18.5
       xss:
         specifier: ^1.0.14
         version: 1.0.15
@@ -350,7 +353,7 @@ importers:
         version: 11.3.6
       http-server:
         specifier: ^14.1.1
-        version: 14.1.1
+        version: 14.1.1(debug@4.4.3)
       husky:
         specifier: ^8.0.3
         version: 8.0.3
@@ -2429,6 +2432,7 @@ packages:
   '@xmldom/xmldom@0.8.13':
     resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
     engines: {node: '>=10.0.0'}
+    deprecated: this version has critical issues, please update to the latest version
 
   '@zxcvbn-ts/core@3.0.4':
     resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==}
@@ -3322,6 +3326,7 @@ packages:
   cron-parser@4.9.0:
     resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
     engines: {node: '>=12.0.0'}
+    deprecated: v4 is no longer maintained, upgrade to v5
 
   cropperjs@1.6.2:
     resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==}
@@ -3353,6 +3358,7 @@ packages:
 
   crypto-js@4.2.0:
     resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+    deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
 
   css-functions-list@3.3.3:
     resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==}
@@ -8493,6 +8499,11 @@ packages:
     engines: {node: '>=0.8'}
     hasBin: true
 
+  xlsx@0.18.5:
+    resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   xml-name-validator@3.0.0:
     resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
 
@@ -13972,7 +13983,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  http-proxy@1.18.1:
+  http-proxy@1.18.1(debug@4.4.3):
     dependencies:
       eventemitter3: 4.0.7
       follow-redirects: 1.16.0(debug@4.4.3)
@@ -13980,14 +13991,14 @@ snapshots:
     transitivePeerDependencies:
       - debug
 
-  http-server@14.1.1:
+  http-server@14.1.1(debug@4.4.3):
     dependencies:
       basic-auth: 2.0.1
       chalk: 4.1.2
       corser: 2.0.1
       he: 1.2.0
       html-encoding-sniffer: 3.0.0
-      http-proxy: 1.18.1
+      http-proxy: 1.18.1(debug@4.4.3)
       mime: 1.6.0
       minimist: 1.2.8
       opener: 1.5.2
@@ -18235,6 +18246,16 @@ snapshots:
       wmf: 1.0.2
       word: 0.3.0
 
+  xlsx@0.18.5:
+    dependencies:
+      adler-32: 1.3.1
+      cfb: 1.2.2
+      codepage: 1.15.0
+      crc-32: 1.2.2
+      ssf: 0.11.2
+      wmf: 1.0.2
+      word: 0.3.0
+
   xml-name-validator@3.0.0: {}
 
   xml-name-validator@4.0.0: {}

+ 14 - 2
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -109,6 +109,7 @@
             @file-upload="triggerFileUpload"
             @file-change="handleFileUpload"
             @remove-pending-file="removePendingFile"
+            @pending-file-preview="openFilePreview"
             @think-level-change="handleThinkLevelChange"
             @edit-mode-change="handleEditModeChange"
             @update:selected-sessions="handleUpdateSelectedSessions"
@@ -188,7 +189,7 @@
   import ScheduleDetailPanel from './chatModal/ScheduleDetailPanel.vue';
   import { getScheduleList } from '../api';
   import type { AttachedFile, Message, Task } from './chatModal/types';
-  import { isTextFile } from './chatModal/utils';
+  import { isTextFile, isVideoFile, isAudioFile, isWordFile, isExcelFile, isCsvFile } from './chatModal/utils';
 
   interface Props {
     visible: boolean;
@@ -847,6 +848,12 @@
     const file = target.files?.[0];
     if (!file) return;
 
+    if (isVideoFile(file.name) || isAudioFile(file.name)) {
+      message.warning('不支持上传音频和视频文件');
+      target.value = '';
+      return;
+    }
+
     try {
       const newFile: AttachedFile = {
         id: `file-${Date.now()}`,
@@ -858,11 +865,16 @@
       };
 
       const reader = new FileReader();
-      if (isTextFile(file.name)) {
+      if (isTextFile(file.name) || isCsvFile(file.name)) {
         reader.onload = (e) => {
           newFile.content = e.target?.result as string;
         };
         reader.readAsText(file);
+      } else if (isWordFile(file.name) || isExcelFile(file.name)) {
+        reader.onload = (e) => {
+          newFile.arrayBuffer = e.target?.result as ArrayBuffer;
+        };
+        reader.readAsArrayBuffer(file);
       } else {
         reader.onload = (e) => {
           newFile.preview = e.target?.result as string;

+ 4 - 2
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -1,13 +1,13 @@
 <template>
   <div class="chat-input-area">
     <div v-if="pendingFile" class="pending-file-display">
-      <div class="pending-file-card">
+      <div class="pending-file-card" @click="emit('pending-file-preview', pendingFile)">
         <div class="file-icon-pending"></div>
         <div class="file-info-pending">
           <div class="file-name-pending">{{ pendingFile.name }}</div>
           <div class="file-size-pending">{{ formatFileSize(pendingFile.size) }}</div>
         </div>
-        <div class="remove-pending" @click="emit('remove-pending-file')">
+        <div class="remove-pending" @click.stop="emit('remove-pending-file')">
           <CloseOutlined />
         </div>
       </div>
@@ -316,6 +316,7 @@
     (e: 'file-upload'): void;
     (e: 'file-change', event: Event): void;
     (e: 'remove-pending-file'): void;
+    (e: 'pending-file-preview', file: AttachedFile): void;
     (e: 'model-change', modelKey: string): void;
     (e: 'think-level-change', levelKey: string): void;
     (e: 'edit-mode-change', modeKey: string): void;
@@ -849,6 +850,7 @@
         border: 1px solid rgba(63, 80, 106, 0.5);
         border-radius: 6px;
         transition: all 0.3s;
+        cursor: pointer;
 
         &:hover {
           background: rgba(10, 132, 255, 0.1);

+ 420 - 49
src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue

@@ -11,26 +11,58 @@
         <img :src="file.preview" alt="预览图片" />
       </div>
       <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
-        <iframe v-if="file.preview" :src="file.preview" frameborder="0"></iframe>
+        <iframe v-if="file.preview" :src="pdfSrc" frameborder="0"></iframe>
         <div v-else class="no-preview-text">无法加载PDF预览</div>
       </div>
-      <div v-else-if="isVideoFile(file.name)" class="video-preview">
-        <video v-if="file.preview" controls>
-          <source :src="file.preview" />
-          您的浏览器不支持视频预览
-        </video>
-        <div v-else class="no-preview-text">无法加载视频预览</div>
+      <div v-else-if="isOldDocFile(file.name)" class="no-preview">
+        <div class="no-preview-icon"></div>
+        <div class="no-preview-text">暂不支持 .doc 格式预览</div>
+        <div class="no-preview-hint">请将文件转换为 .docx 格式后重新上传</div>
+      </div>
+      <div v-else-if="isWordFile(file.name)" class="word-preview">
+        <VueOfficeDocx v-if="file.arrayBuffer" :src="file.arrayBuffer" @rendered="handleRendered" @error="handleError" />
+        <div v-else class="no-preview-text">无法加载Word文档</div>
       </div>
-      <div v-else-if="isAudioFile(file.name)" class="audio-preview">
-        <div class="audio-info">
-          <div class="audio-icon"></div>
-          <span class="audio-name">{{ file.name }}</span>
+      <div v-else-if="isExcelFile(file.name)" class="excel-preview">
+        <div v-if="excelSheets.length > 1" class="excel-tabs">
+          <div v-for="(sheet, i) in excelSheets" :key="i" :class="['excel-tab', { active: activeSheet === i }]" @click="activeSheet = i">
+            {{ sheet.name }}
+          </div>
         </div>
-        <audio v-if="file.preview" controls>
-          <source :src="file.preview" />
-          您的浏览器不支持音频预览
-        </audio>
-        <div v-else class="no-preview-text">无法加载音频预览</div>
+        <div v-if="excelLoading" class="no-preview-text">加载中...</div>
+        <div v-else-if="currentSheetData.length > 0" class="excel-table-wrapper">
+          <table class="excel-table">
+            <thead>
+              <tr>
+                <th v-for="(cell, i) in currentSheetData[0]" :key="i">{{ cell }}</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(row, ri) in currentSheetData.slice(1)" :key="ri">
+                <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+        <div v-else class="no-preview-text">Excel文件内容为空</div>
+      </div>
+      <div v-else-if="isCsvFile(file.name)" class="csv-preview">
+        <table v-if="csvRows.length > 0" class="csv-table">
+          <thead>
+            <tr>
+              <th v-for="(cell, i) in csvRows[0]" :key="i">{{ cell }}</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="(row, ri) in csvRows.slice(1)" :key="ri">
+              <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
+            </tr>
+          </tbody>
+        </table>
+        <div v-else class="no-preview-text">CSV文件内容为空</div>
+      </div>
+      <div v-else-if="isMdFile(file.name)" class="markdown-preview">
+        <div class="markdown-body" v-html="renderedMarkdown"></div>
       </div>
       <div v-else-if="isTextFile(file.name)" class="text-preview">
         <pre>{{ file.content || '文件内容加载中...' }}</pre>
@@ -45,16 +77,121 @@
 </template>
 
 <script setup lang="ts">
-  import { isImageFile, isPdfFile, isVideoFile, isAudioFile, isTextFile, getFileExtension } from './utils';
+  import { ref, computed, watch } from 'vue';
+  import VueOfficeDocx from '@vue-office/docx/lib/v3/vue-office-docx.mjs';
+  import * as XLSX from 'xlsx';
+  import {
+    isImageFile,
+    isPdfFile,
+    isWordFile,
+    isOldDocFile,
+    isExcelFile,
+    isCsvFile,
+    isMdFile,
+    isTextFile,
+    getFileExtension,
+    renderMarkdown,
+  } from './utils';
   import type { AttachedFile } from './types';
 
-  defineProps<{
+  const props = defineProps<{
     file: AttachedFile;
   }>();
 
   const emit = defineEmits<{
     (e: 'close'): void;
   }>();
+
+  // Excel 解析
+  const excelSheets = ref<{ name: string; data: string[][] }[]>([]);
+  const activeSheet = ref(0);
+  const excelLoading = ref(false);
+
+  const currentSheetData = computed(() => {
+    return excelSheets.value[activeSheet.value]?.data || [];
+  });
+
+  const parseExcel = async (file: AttachedFile) => {
+    excelLoading.value = true;
+    try {
+      let buffer = file.arrayBuffer;
+      if (!buffer && file.originalFile) {
+        buffer = await file.originalFile.arrayBuffer();
+      }
+      if (!buffer) return;
+      const workbook = XLSX.read(buffer, { type: 'array' });
+      excelSheets.value = workbook.SheetNames.map((name) => {
+        const sheet = workbook.Sheets[name];
+        const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, defval: '' });
+        return { name, data: rows };
+      });
+      activeSheet.value = 0;
+    } catch (err) {
+      console.error('Excel解析失败:', err);
+      excelSheets.value = [];
+    } finally {
+      excelLoading.value = false;
+    }
+  };
+
+  watch(
+    () => props.file,
+    (file) => {
+      if (isExcelFile(file.name)) {
+        parseExcel(file);
+      }
+    },
+    { immediate: true }
+  );
+
+  const pdfSrc = computed(() => {
+    const base = props.file.preview || '';
+    if (!base) return '';
+    return `${base}#toolbar=0&navpanes=0&scrollbar=0`;
+  });
+
+  const renderedMarkdown = computed(() => {
+    return renderMarkdown(props.file.content || '');
+  });
+
+  const csvRows = computed(() => {
+    const content = props.file.content || '';
+    if (!content.trim()) return [];
+    return content
+      .split(/\r?\n/)
+      .filter((line) => line.trim())
+      .map((line) => {
+        const cells: string[] = [];
+        let current = '';
+        let inQuotes = false;
+        for (let i = 0; i < line.length; i++) {
+          const ch = line[i];
+          if (ch === '"') {
+            if (inQuotes && line[i + 1] === '"') {
+              current += '"';
+              i++;
+            } else {
+              inQuotes = !inQuotes;
+            }
+          } else if (ch === ',' && !inQuotes) {
+            cells.push(current);
+            current = '';
+          } else {
+            current += ch;
+          }
+        }
+        cells.push(current);
+        return cells;
+      });
+  });
+
+  const handleRendered = () => {
+    console.log('文件渲染完成');
+  };
+
+  const handleError = (err: any) => {
+    console.error('文件预览失败:', err);
+  };
 </script>
 
 <style scoped lang="less">
@@ -88,9 +225,6 @@
       cursor: pointer;
       width: 36px;
       height: 36px;
-      // background-image: var(--img-chat-close-bg);
-      // background-repeat: no-repeat;
-      // background-size: 100% 100%;
       display: flex;
       align-items: center;
       justify-content: center;
@@ -143,49 +277,286 @@
       }
     }
 
-    .video-preview {
-      width: 100%;
-      height: 100%;
-      display: flex;
-      align-items: center;
-      justify-content: center;
+    .word-preview {
+      width: calc(100% + 32px);
+      height: calc(100% + 32px);
+      margin: -16px;
+      overflow-x: auto;
+      overflow-y: auto;
 
-      video {
-        width: 100%;
-        max-height: 100%;
-        border-radius: 4px;
+      :deep(.docx-wrapper) {
+        padding: 0;
+        background: transparent;
+        display: inline-block;
+        min-width: 100%;
       }
     }
 
-    .audio-preview {
-      width: 100%;
+    .excel-preview {
+      width: calc(100% + 32px);
+      height: calc(100% + 32px);
+      margin: -16px;
       display: flex;
       flex-direction: column;
-      align-items: center;
-      gap: 16px;
-      padding: 20px 0;
+      overflow: hidden;
 
-      .audio-info {
+      .excel-tabs {
         display: flex;
-        align-items: center;
-        gap: 10px;
+        gap: 0;
+        border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+        flex-shrink: 0;
+        padding: 0 16px;
+
+        .excel-tab {
+          padding: 8px 16px;
+          font-size: 13px;
+          color: #8b949e;
+          cursor: pointer;
+          border-bottom: 2px solid transparent;
+          transition: all 0.2s;
+
+          &:hover {
+            color: #e0e6ed;
+          }
 
-        .audio-icon {
-          width: 40px;
-          height: 40px;
-          background-image: var(--img-chat-attach-icon);
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
+          &.active {
+            color: #e0e6ed;
+            border-bottom-color: #3a8fd4;
+          }
         }
+      }
 
-        .audio-name {
-          font-size: 14px;
-          color: #e0e6ed;
+      .excel-table-wrapper {
+        flex: 1;
+        overflow: auto;
+        padding: 0 16px;
+
+        .excel-table {
+          border-collapse: collapse;
+          width: max-content;
+          min-width: 100%;
+          font-size: 13px;
+
+          th,
+          td {
+            border: 1px solid rgba(255, 255, 255, 0.15);
+            padding: 6px 10px;
+            text-align: left;
+            white-space: nowrap;
+          }
+
+          th {
+            background: rgba(10, 132, 255, 0.15);
+            color: #e0e6ed;
+            font-weight: 600;
+            position: sticky;
+            top: 0;
+          }
+
+          td {
+            color: #c9d1d9;
+          }
+
+          tr:hover td {
+            background: rgba(10, 132, 255, 0.08);
+          }
         }
       }
+    }
 
-      audio {
+    .csv-preview {
+      width: 100%;
+      height: 100%;
+      overflow: auto;
+
+      .csv-table {
+        border-collapse: collapse;
         width: 100%;
+        font-size: 13px;
+
+        th,
+        td {
+          border: 1px solid rgba(255, 255, 255, 0.15);
+          padding: 6px 10px;
+          text-align: left;
+          white-space: nowrap;
+        }
+
+        th {
+          background: rgba(10, 132, 255, 0.15);
+          color: #e0e6ed;
+          font-weight: 600;
+          position: sticky;
+          top: 0;
+        }
+
+        td {
+          color: #c9d1d9;
+        }
+
+        tr:hover td {
+          background: rgba(10, 132, 255, 0.08);
+        }
+      }
+    }
+
+    .doc-preview {
+      width: 100%;
+      height: 100%;
+      overflow: auto;
+
+      .doc-html-body {
+        color: #e0e6ed;
+        font-size: 14px;
+        line-height: 1.8;
+
+        :deep(table) {
+          border-collapse: collapse;
+          width: 100%;
+          margin-bottom: 12px;
+
+          th,
+          td {
+            border: 1px solid rgba(255, 255, 255, 0.15);
+            padding: 8px 12px;
+            text-align: left;
+          }
+
+          th {
+            background: rgba(10, 132, 255, 0.1);
+            font-weight: 600;
+          }
+        }
+
+        :deep(img) {
+          max-width: 100%;
+        }
+
+        :deep(p) {
+          margin-bottom: 8px;
+        }
+      }
+    }
+
+    .markdown-preview {
+      .markdown-body {
+        color: #e0e6ed;
+        font-size: 14px;
+        line-height: 1.8;
+
+        :deep(h1),
+        :deep(h2),
+        :deep(h3),
+        :deep(h4),
+        :deep(h5),
+        :deep(h6) {
+          color: #fff;
+          margin-top: 16px;
+          margin-bottom: 8px;
+          font-weight: 600;
+        }
+
+        :deep(h1) {
+          font-size: 24px;
+          padding-bottom: 8px;
+          border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+        }
+
+        :deep(h2) {
+          font-size: 20px;
+          padding-bottom: 6px;
+          border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+        }
+
+        :deep(h3) {
+          font-size: 16px;
+        }
+
+        :deep(p) {
+          margin-bottom: 12px;
+        }
+
+        :deep(code) {
+          background: rgba(10, 132, 255, 0.15);
+          padding: 2px 6px;
+          border-radius: 4px;
+          font-family: monospace;
+          font-size: 13px;
+          color: #79c0ff;
+        }
+
+        :deep(pre) {
+          background: rgba(0, 0, 0, 0.3);
+          padding: 12px 16px;
+          border-radius: 6px;
+          overflow-x: auto;
+          margin-bottom: 12px;
+
+          code {
+            background: none;
+            padding: 0;
+            color: #e0e6ed;
+          }
+        }
+
+        :deep(blockquote) {
+          border-left: 3px solid #3a8fd4;
+          padding-left: 12px;
+          margin: 12px 0;
+          color: #8b949e;
+        }
+
+        :deep(ul),
+        :deep(ol) {
+          padding-left: 24px;
+          margin-bottom: 12px;
+
+          li {
+            margin-bottom: 4px;
+          }
+        }
+
+        :deep(table) {
+          border-collapse: collapse;
+          width: 100%;
+          margin-bottom: 12px;
+
+          th,
+          td {
+            border: 1px solid rgba(255, 255, 255, 0.15);
+            padding: 8px 12px;
+            text-align: left;
+          }
+
+          th {
+            background: rgba(10, 132, 255, 0.1);
+            font-weight: 600;
+          }
+
+          tr:hover td {
+            background: rgba(10, 132, 255, 0.05);
+          }
+        }
+
+        :deep(a) {
+          color: #3a8fd4;
+          text-decoration: none;
+
+          &:hover {
+            text-decoration: underline;
+          }
+        }
+
+        :deep(img) {
+          max-width: 100%;
+          border-radius: 4px;
+        }
+
+        :deep(hr) {
+          border: none;
+          border-top: 1px solid rgba(255, 255, 255, 0.1);
+          margin: 16px 0;
+        }
       }
     }
 

+ 1 - 0
src/views/ventAI/manageAssistent/components/chatModal/types.ts

@@ -5,6 +5,7 @@ export interface AttachedFile {
   type: string;
   preview?: string;
   content?: string;
+  arrayBuffer?: ArrayBuffer;
   uploadTime: string;
   messageId?: string;
   originalFile?: File;

+ 10 - 0
src/views/ventAI/manageAssistent/components/chatModal/utils.ts

@@ -115,6 +115,16 @@ export const isVideoFile = (filename: string): boolean => /\.(mp4|webm|ogg|mov|a
 
 export const isAudioFile = (filename: string): boolean => /\.(mp3|wav|ogg|aac|flac|wma|m4a|opus)$/i.test(filename);
 
+export const isMdFile = (filename: string): boolean => /\.md$/i.test(filename);
+
+export const isWordFile = (filename: string): boolean => /\.(doc|docx)$/i.test(filename);
+
+export const isOldDocFile = (filename: string): boolean => /\.doc$/i.test(filename) && !/\.docx$/i.test(filename);
+
+export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(filename);
+
+export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename);
+
 export const isTextFile = (filename: string): boolean =>
   /\.(txt|md|json|csv|xml|yaml|yml|log|ini|toml|cfg|conf|env|gitignore|dockerignore|editorconfig|prettierrc|eslintrc|babelrc|properties|sh|bat|ps1|cmd|bash|zsh|fish|sql|graphql|proto|js|ts|jsx|tsx|vue|css|scss|sass|less|html|htm|svelte|py|java|kt|kts|c|cpp|h|hpp|cc|cxx|cs|go|rs|rb|php|swift|m|mm|r|R|lua|pl|pm|hs|ex|exs|erl|clj|scala|dart|zig|nim|v|sol|tf|hcl|gradle|cmake|makefile|mk)$/i.test(
     filename