Преглед на файлове

[Feat 0000]AI助手添加插入技能功能

wangkeyi преди 1 месец
родител
ревизия
65f5c4bb63

BIN
src/assets/images/ventAI/chatModalBlue/2-7.png


+ 18 - 1
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -108,6 +108,7 @@
           @think-level-change="handleThinkLevelChange"
           @edit-mode-change="handleEditModeChange"
           @update:selected-sessions="handleUpdateSelectedSessions"
+          @update:selected-skill="handleUpdateSelectedSkill"
         />
       </div>
 
@@ -339,6 +340,7 @@
   const streaming = ref(false);
   const activeAbortController = ref<AbortController | null>(null);
   const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
+  const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
 
   const stopStreaming = () => {
     activeAbortController.value?.abort();
@@ -458,6 +460,15 @@
     selectedSessions.value = sessions;
   };
 
+  const handleUpdateSelectedSkill = (skill: { name: string; display_name: string } | null) => {
+    selectedSkill.value = skill;
+  };
+
+  const getSkillRefIconUrl = () => {
+    const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-options-skill-icon').trim();
+    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
+  };
+
   const getSessionRefIconUrl = () => {
     const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-popup-session-icon').trim();
     return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
@@ -807,9 +818,14 @@
     }
 
     const sessionPrefix = selectedSessions.value.length > 0 ? selectedSessions.value.map((s) => `#session:${s.id}`).join(' ') + ' ' : '';
-    const userInput = sessionPrefix + inputMessage.value.trim();
+    const skillPrefix = selectedSkill.value ? `#skill:${selectedSkill.value.name} ` : '';
+    const userInput = skillPrefix + sessionPrefix + inputMessage.value.trim();
 
     let displayContent = inputMessage.value.trim();
+    if (selectedSkill.value) {
+      const skillIconUrl = getSkillRefIconUrl();
+      displayContent = `[<img src="${skillIconUrl}" class="session-ref-icon" /> 技能:${selectedSkill.value.display_name}] ${displayContent}`;
+    }
     if (selectedSessions.value.length > 0) {
       const names = selectedSessions.value.map((s) => s.name).join('、');
       const iconUrl = getSessionRefIconUrl();
@@ -821,6 +837,7 @@
     inputMessage.value = '';
     pendingFile.value = null;
     selectedSessions.value = [];
+    selectedSkill.value = null;
 
     try {
       loading.value = true;

+ 393 - 25
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -22,6 +22,15 @@
       </div>
     </div>
 
+    <!-- 已选技能引用标签 -->
+    <div v-if="selectedSkill" class="skill-chips">
+      <div class="skill-chip">
+        <div class="skill-chip-icon"></div>
+        <span class="skill-chip-name">{{ selectedSkill.display_name }}</span>
+        <CloseOutlined class="skill-chip-remove" @click.stop="removeSkill()" />
+      </div>
+    </div>
+
     <a-textarea
       ref="textareaRef"
       :value="modelValue"
@@ -56,6 +65,10 @@
               <div class="popup-item-icon session-icon"></div>
               <span class="popup-item-text">插入 # 会话</span>
             </div>
+            <div class="popup-item" @click="handleInsertSkill">
+              <div class="popup-item-icon skill-icon"></div>
+              <span class="popup-item-text">插入 $ 技能</span>
+            </div>
             <!-- <div class="popup-item" @click="handleInsertCommand">
               <div class="popup-item-icon command-icon"></div>
               <span class="popup-item-text">插入 / 命令</span>
@@ -227,15 +240,45 @@
           <span v-else>点击选择会话(最多5个)</span>
         </div>
       </div>
+      <!-- 技能列表弹框 -->
+      <div v-if="showSkillPopup" ref="skillPopupRef" class="skill-popup" @click.stop @pointerdown.stop>
+        <div class="skill-popup-header">
+          <span>选择技能</span>
+          <span class="skill-count">0/1</span>
+        </div>
+        <div class="skill-list">
+          <div
+            v-for="(skill, index) in filteredSkillList"
+            :key="skill.name"
+            class="skill-item"
+            :class="{
+              active: index === activeSkillIndex,
+              selected: isSkillSelected(skill.name),
+            }"
+            @click="handleSelectSkill(skill)"
+          >
+            <div class="skill-item-checkbox" :class="{ checked: isSkillSelected(skill.name) }">
+              <span v-if="isSkillSelected(skill.name)" class="check-mark">✓</span>
+            </div>
+            <div class="skill-item-icon"></div>
+            <span class="skill-item-name">{{ skill.display_name }}</span>
+          </div>
+          <div v-if="filteredSkillList.length === 0" class="skill-empty">暂无匹配技能</div>
+        </div>
+        <div class="skill-popup-footer">
+          <span>点击选择技能(仅可选1个)</span>
+        </div>
+      </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-  import { ref, computed, watch, nextTick } from 'vue';
+  import { ref, computed, watch, nextTick, onMounted } from 'vue';
   import { onClickOutside } from '@vueuse/core';
   import { CloseOutlined } from '@ant-design/icons-vue';
   import { formatFileSize } from './utils';
+  import { getSkills } from '../../api';
   import type { AttachedFile, Task } from './types';
 
   const props = withDefaults(
@@ -277,6 +320,7 @@
     (e: 'think-level-change', levelKey: string): void;
     (e: 'edit-mode-change', modeKey: string): void;
     (e: 'update:selectedSessions', sessions: Array<{ id: string; name: string }>): void;
+    (e: 'update:selectedSkill', skill: { name: string; display_name: string } | null): void;
   }>();
 
   const handleSelectModel = (item: ModelOption) => {
@@ -467,6 +511,42 @@
   let isRemovingTag = false;
   let sessionJustSelected = false;
 
+  // 技能列表弹框
+  interface SkillItem {
+    name: string;
+    display_name: string;
+    path: string;
+    description: string;
+    enabled: boolean;
+    agents: string[];
+    has_skill_md: boolean;
+  }
+
+  const skillPopupRef = ref<HTMLElement>();
+  const showSkillPopup = ref(false);
+  const activeSkillIndex = ref(0);
+  const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
+  const skillPopupManual = ref(false);
+  const skillList = ref<SkillItem[]>([]);
+  let skillJustSelected = false;
+
+  const fetchSkillList = async () => {
+    try {
+      const res = await getSkills();
+      skillList.value = res?.skills || [];
+    } catch {
+      skillList.value = [];
+    }
+  };
+
+  onMounted(() => {
+    fetchSkillList();
+  });
+
+  const isSkillSelected = (name: string) => {
+    return selectedSkill.value?.name === name;
+  };
+
   const isSessionSelected = (sessionId: string) => {
     return selectedSessions.value.some((s) => s.id === sessionId);
   };
@@ -494,20 +574,62 @@
     if (val) activeSessionIndex.value = 0;
   });
 
+  const skillSearchText = computed(() => {
+    const val = props.modelValue || '';
+    const dollarIndex = val.lastIndexOf('$');
+    if (dollarIndex === -1) return '';
+    const afterDollar = val.substring(dollarIndex + 1);
+    if (afterDollar.includes(' ')) return '';
+    return afterDollar.toLowerCase();
+  });
+
+  const filteredSkillList = computed(() => {
+    if (!skillSearchText.value) return skillList.value;
+    return skillList.value.filter(
+      (s) => s.display_name.toLowerCase().includes(skillSearchText.value) || s.name.toLowerCase().includes(skillSearchText.value)
+    );
+  });
+
+  watch(filteredSkillList, () => {
+    activeSkillIndex.value = 0;
+  });
+
+  watch(showSkillPopup, (val) => {
+    if (val) activeSkillIndex.value = 0;
+  });
+
   const handleSessionKeydown = (e: KeyboardEvent) => {
-    if (!showSessionPopup.value || filteredSessionList.value.length === 0) return;
-    if (e.key === 'ArrowDown') {
-      e.preventDefault();
-      activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
-    } else if (e.key === 'ArrowUp') {
-      e.preventDefault();
-      activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
-    } else if (e.key === 'Enter') {
-      e.preventDefault();
-      const task = filteredSessionList.value[activeSessionIndex.value];
-      if (task) {
-        sessionJustSelected = true;
-        handleSelectSession(task);
+    if (showSkillPopup.value && filteredSkillList.value.length > 0) {
+      if (e.key === 'ArrowDown') {
+        e.preventDefault();
+        activeSkillIndex.value = (activeSkillIndex.value + 1) % filteredSkillList.value.length;
+      } else if (e.key === 'ArrowUp') {
+        e.preventDefault();
+        activeSkillIndex.value = (activeSkillIndex.value - 1 + filteredSkillList.value.length) % filteredSkillList.value.length;
+      } else if (e.key === 'Enter') {
+        e.preventDefault();
+        const skill = filteredSkillList.value[activeSkillIndex.value];
+        if (skill) {
+          skillJustSelected = true;
+          handleSelectSkill(skill);
+        }
+      }
+      return;
+    }
+    if (showSessionPopup.value && filteredSessionList.value.length > 0) {
+      if (e.key === 'ArrowDown') {
+        e.preventDefault();
+        activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
+      } else if (e.key === 'ArrowUp') {
+        e.preventDefault();
+        activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
+      } else if (e.key === 'Enter') {
+        e.preventDefault();
+        const task = filteredSessionList.value[activeSessionIndex.value];
+        if (task) {
+          sessionJustSelected = true;
+          handleSelectSession(task);
+        }
       }
     }
   };
@@ -538,6 +660,47 @@
     emit('update:selectedSessions', [...selectedSessions.value]);
   };
 
+  const handleInsertSkill = () => {
+    showPopup.value = false;
+    showSessionPopup.value = false;
+    skillPopupManual.value = true;
+    showSkillPopup.value = true;
+    const cur = props.modelValue || '';
+    const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
+    emit('update:modelValue', `${cur}${needsSpace}$`);
+    nextTick(() => {
+      const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
+      if (el) el.focus();
+    });
+  };
+
+  const handleSelectSkill = (skill: SkillItem) => {
+    selectedSkill.value = { name: skill.name, display_name: skill.display_name };
+    removeSkillSearchTag();
+    showSkillPopup.value = false;
+    skillPopupManual.value = false;
+    emit('update:selectedSkill', { name: skill.name, display_name: skill.display_name });
+  };
+
+  const removeSkillSearchTag = () => {
+    isRemovingTag = true;
+    const val = props.modelValue || '';
+    const dollarIndex = val.lastIndexOf('$');
+    if (dollarIndex === -1) {
+      isRemovingTag = false;
+      return;
+    }
+    emit('update:modelValue', val.substring(0, dollarIndex).trimEnd());
+    nextTick(() => {
+      isRemovingTag = false;
+    });
+  };
+
+  const removeSkill = () => {
+    selectedSkill.value = null;
+    emit('update:selectedSkill', null);
+  };
+
   const removeSearchTag = () => {
     isRemovingTag = true;
     const val = props.modelValue || '';
@@ -556,7 +719,10 @@
     () => props.modelValue,
     (newVal, oldVal) => {
       if (isRemovingTag) return;
-      if (oldVal && !newVal) selectedSessions.value = [];
+      if (oldVal && !newVal) {
+        selectedSessions.value = [];
+        selectedSkill.value = null;
+      }
     }
   );
 
@@ -567,16 +733,36 @@
 
   const handleTextareaInput = (val: string) => {
     emit('update:modelValue', val);
-    if (sessionPopupManual.value) return;
-    const hashIndex = val.lastIndexOf('#');
-    if (hashIndex === -1) {
-      showSessionPopup.value = false;
-    } else {
-      const afterHash = val.substring(hashIndex + 1);
-      if (afterHash.includes(' ')) {
+
+    // 技能弹框 $ 检测
+    if (!skillPopupManual.value) {
+      const dollarIndex = val.lastIndexOf('$');
+      if (dollarIndex === -1) {
+        showSkillPopup.value = false;
+      } else {
+        const afterDollar = val.substring(dollarIndex + 1);
+        if (afterDollar.includes(' ')) {
+          showSkillPopup.value = false;
+        } else {
+          showSkillPopup.value = true;
+          showSessionPopup.value = false;
+        }
+      }
+    }
+
+    // 会话弹框 # 检测
+    if (!sessionPopupManual.value) {
+      const hashIndex = val.lastIndexOf('#');
+      if (hashIndex === -1) {
         showSessionPopup.value = false;
       } else {
-        showSessionPopup.value = true;
+        const afterHash = val.substring(hashIndex + 1);
+        if (afterHash.includes(' ')) {
+          showSessionPopup.value = false;
+        } else {
+          showSessionPopup.value = true;
+          showSkillPopup.value = false;
+        }
       }
     }
   };
@@ -584,7 +770,11 @@
   const handleBackspace = (e: KeyboardEvent) => {
     const val = props.modelValue || '';
     if (val) return;
-    if (selectedSessions.value.length > 0) {
+    if (selectedSkill.value) {
+      e.preventDefault();
+      selectedSkill.value = null;
+      emit('update:selectedSkill', null);
+    } else if (selectedSessions.value.length > 0) {
       e.preventDefault();
       selectedSessions.value.pop();
       emit('update:selectedSessions', [...selectedSessions.value]);
@@ -596,16 +786,25 @@
     sessionPopupManual.value = false;
   });
 
+  onClickOutside(skillPopupRef, () => {
+    showSkillPopup.value = false;
+    skillPopupManual.value = false;
+  });
+
   const handleInsertCommand = () => {
     showPopup.value = false;
   };
 
   const handleEnterKey = (e: KeyboardEvent) => {
-    if (showSessionPopup.value) return;
+    if (showSessionPopup.value || showSkillPopup.value) return;
     if (sessionJustSelected) {
       sessionJustSelected = false;
       return;
     }
+    if (skillJustSelected) {
+      skillJustSelected = false;
+      return;
+    }
     if (!e.shiftKey && props.modelValue.trim() && !props.loading) {
       emit('send');
     }
@@ -837,6 +1036,10 @@
               background-image: var(--img-chat-popup-session-icon);
             }
 
+            .skill-icon {
+              background-image: var(--img-chat-options-skill-icon);
+            }
+
             .command-icon {
               background-image: var(--img-chat-popup-command-icon);
             }
@@ -1340,6 +1543,54 @@
       }
     }
 
+    .skill-chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      margin-bottom: 5px;
+
+      .skill-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        padding: 3px 6px 3px 4px;
+        background: rgba(10, 132, 255, 0.15);
+        border: 1px solid rgba(10, 132, 255, 0.3);
+        border-radius: 4px;
+        font-size: 12px;
+        color: #e0e6ed;
+        cursor: default;
+
+        .skill-chip-icon {
+          width: 16px;
+          height: 16px;
+          background-image: var(--img-chat-options-skill-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .skill-chip-name {
+          max-width: 150px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .skill-chip-remove {
+          font-size: 10px;
+          color: #8b949e;
+          cursor: pointer;
+          padding: 0 2px;
+          transition: color 0.2s;
+
+          &:hover {
+            color: #ff4d4f;
+          }
+        }
+      }
+    }
+
     .session-popup {
       position: absolute;
       bottom: 100%;
@@ -1469,5 +1720,122 @@
         }
       }
     }
+
+    .skill-popup {
+      position: absolute;
+      bottom: 100%;
+      left: 0;
+      right: 0;
+      margin-bottom: 8px;
+      background: #0a2a3f;
+      border: 1px solid #1a4a6f;
+      border-radius: 8px;
+      max-height: 300px;
+      display: flex;
+      flex-direction: column;
+      z-index: 1000;
+      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+      .skill-popup-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 10px 14px 8px;
+        font-size: 13px;
+        color: #e0e6ed;
+        border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+        flex-shrink: 0;
+
+        .skill-count {
+          font-size: 12px;
+          color: #3a8fd4;
+          font-weight: 500;
+        }
+      }
+
+      .skill-list {
+        flex: 1;
+        overflow-y: auto;
+        padding: 6px;
+
+        .skill-item {
+          display: flex;
+          align-items: center;
+          gap: 10px;
+          padding: 8px 10px;
+          cursor: pointer;
+          border-radius: 6px;
+          transition: background 0.2s;
+
+          &:hover {
+            background: rgba(10, 132, 255, 0.15);
+          }
+
+          &.active {
+            background: rgba(10, 132, 255, 0.2);
+          }
+
+          &.selected {
+            background: rgba(10, 132, 255, 0.12);
+          }
+
+          .skill-item-checkbox {
+            width: 16px;
+            height: 16px;
+            border: 1.5px solid rgba(255, 255, 255, 0.25);
+            border-radius: 3px;
+            flex-shrink: 0;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            transition: all 0.2s;
+
+            &.checked {
+              background: #3a8fd4;
+              border-color: #3a8fd4;
+            }
+
+            .check-mark {
+              font-size: 11px;
+              color: #fff;
+              line-height: 1;
+            }
+          }
+
+          .skill-item-icon {
+            width: 18px;
+            height: 18px;
+            background-image: var(--img-chat-options-skill-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+
+          .skill-item-name {
+            font-size: 13px;
+            color: #e0e6ed;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+          }
+        }
+
+        .skill-empty {
+          text-align: center;
+          color: #8b949e;
+          font-size: 13px;
+          padding: 20px 0;
+        }
+      }
+
+      .skill-popup-footer {
+        padding: 8px 14px;
+        font-size: 12px;
+        color: #8b949e;
+        text-align: center;
+        border-top: 1px solid rgba(63, 80, 106, 0.3);
+        flex-shrink: 0;
+      }
+    }
   }
 </style>