import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { getSkills } from '/@/views/ventAI/manageAssistent/api'; import type { Skill, Task } from '/@/views/ventAI/manageAssistent/types/index'; /** * 输入框的会话/技能引用弹层:`#` 触发会话提及、`$` 触发技能提及,含搜索过滤、方向键导航、 * 回车/点击选中、标签增删,以及输入法组合态下不驱动弹层的处理。 * * 输入框本身(文本、发送、附件)仍在 ChatInputArea 里,这里通过 deps 读写它的文本与焦点; * 技能列表缓存放在模块级,跨组件实例复用,避免重复请求。 */ export interface MentionPopupDeps { getModelValue: () => string; setModelValue: (value: string) => void; getTaskList: () => Task[]; getCurrentTaskId: () => string; isComposing: () => boolean; closeAttachPopup: () => void; focusTextarea: () => void; setSelectedSessions: (sessions: Array<{ id: string; name: string }>) => void; setSelectedSkill: (skill: { name: string; display_name: string } | null) => void; } // Module-level cache for skill list to avoid redundant API calls across component mounts let skillListCache: Skill[] | null = null; let skillListFetchPromise: Promise | null = null; export const useMentionPopups = (deps: MentionPopupDeps) => { const { getModelValue, setModelValue, getTaskList, getCurrentTaskId, isComposing, closeAttachPopup, focusTextarea, setSelectedSessions, setSelectedSkill, } = deps; const showSessionPopup = ref(false); const activeSessionIndex = ref(0); const selectedSessions = ref>([]); const sessionPopupManual = ref(false); let isRemovingTag = false; let sessionJustSelected = false; // 技能列表弹框 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([]); // 技能列表拉取失败:$ 弹层的空态据此区分「加载失败」与「确实没有技能」,避免失败被显示成空数据 const skillListLoadFailed = ref(false); let skillJustSelected = false; const fetchSkillList = async () => { // Use cached data if available if (skillListCache) { skillList.value = skillListCache; return; } // Deduplicate concurrent requests if (skillListFetchPromise) { await skillListFetchPromise; skillList.value = skillListCache || []; return; } skillListFetchPromise = (async () => { try { const res = await getSkills(); skillList.value = res?.skills || []; skillListCache = skillList.value; skillListLoadFailed.value = false; } catch (e) { // 这里刻意不弹提示:组件一挂载就预取,失败会打扰与技能无关的操作。 // 改为记住失败状态,由 $ 弹层的空态如实说明。 console.error('获取技能列表失败:', e); skillList.value = []; skillListLoadFailed.value = true; } finally { skillListFetchPromise = null; } })(); await skillListFetchPromise; }; onMounted(() => { fetchSkillList(); }); const sessionSearchText = computed(() => { const val = getModelValue() || ''; const hashIndex = val.lastIndexOf('#'); if (hashIndex === -1) return ''; const afterHash = val.substring(hashIndex + 1); if (afterHash.includes(' ')) return ''; return afterHash.toLowerCase(); }); const filteredSessionList = computed(() => { const list = getTaskList().filter((t) => t.sessionId); if (!sessionSearchText.value) return list; return list.filter((t) => t.name.toLowerCase().includes(sessionSearchText.value)); }); watch(filteredSessionList, () => { activeSessionIndex.value = 0; }); watch(showSessionPopup, (val) => { if (val) activeSessionIndex.value = 0; }); const skillSearchText = computed(() => { const val = getModelValue() || ''; 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 (isComposing() || e.isComposing) return; 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); } } } }; const handleInsertSession = () => { closeAttachPopup(); sessionPopupManual.value = true; showSessionPopup.value = true; const cur = getModelValue() || ''; const needsSpace = cur && !cur.endsWith(' ') ? ' ' : ''; setModelValue(`${cur}${needsSpace}#`); nextTick(() => focusTextarea()); }; const handleSelectSession = (task: Task) => { if (task.id === getCurrentTaskId()) return; const idx = selectedSessions.value.findIndex((s) => s.id === task.id); if (idx >= 0) { selectedSessions.value.splice(idx, 1); } else { if (selectedSessions.value.length >= 5) return; selectedSessions.value.push({ id: task.id, name: task.name }); } removeSearchTag(); setSelectedSessions([...selectedSessions.value]); }; const handleInsertSkill = () => { closeAttachPopup(); showSessionPopup.value = false; skillPopupManual.value = true; showSkillPopup.value = true; const cur = getModelValue() || ''; const needsSpace = cur && !cur.endsWith(' ') ? ' ' : ''; setModelValue(`${cur}${needsSpace}$`); nextTick(() => focusTextarea()); }; const handleSelectSkill = (skill: Skill) => { selectedSkill.value = { name: skill.name, display_name: skill.display_name }; removeSkillSearchTag(); showSkillPopup.value = false; skillPopupManual.value = false; setSelectedSkill({ name: skill.name, display_name: skill.display_name }); }; const removeSkillSearchTag = () => { isRemovingTag = true; const val = getModelValue() || ''; const dollarIndex = val.lastIndexOf('$'); if (dollarIndex === -1) { isRemovingTag = false; return; } setModelValue(val.substring(0, dollarIndex).trimEnd()); nextTick(() => { isRemovingTag = false; }); }; const removeSkill = () => { selectedSkill.value = null; setSelectedSkill(null); }; const removeSearchTag = () => { isRemovingTag = true; const val = getModelValue() || ''; const hashIndex = val.lastIndexOf('#'); if (hashIndex === -1) { isRemovingTag = false; return; } setModelValue(val.substring(0, hashIndex).trimEnd()); nextTick(() => { isRemovingTag = false; }); }; watch( () => getModelValue(), (newVal, oldVal) => { if (isRemovingTag) return; if (oldVal && !newVal) { // 输入框被清空时,引用一并作废。必须同步回父组件:真正写进请求的是父组件那份 // (发送时按它拼 #session:/$skill: 前缀),只清本地 ref 会让 chip 消失但引用照旧发出, // 用户会看到一条自己以为已删除、且无法再移除的引用。 selectedSessions.value = []; setSelectedSessions([]); selectedSkill.value = null; setSelectedSkill(null); } } ); const removeSession = (sessionId: string) => { selectedSessions.value = selectedSessions.value.filter((s) => s.id !== sessionId); setSelectedSessions([...selectedSessions.value]); }; const syncPopups = (val: string) => { // 技能弹框 $ 检测 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 { const afterHash = val.substring(hashIndex + 1); if (afterHash.includes(' ')) { showSessionPopup.value = false; } else { showSessionPopup.value = true; showSkillPopup.value = false; } } } }; const handleBackspace = (e: KeyboardEvent) => { const val = getModelValue() || ''; if (val) return; if (selectedSkill.value) { e.preventDefault(); selectedSkill.value = null; setSelectedSkill(null); } else if (selectedSessions.value.length > 0) { e.preventDefault(); selectedSessions.value.pop(); setSelectedSessions([...selectedSessions.value]); } }; // 点击弹层外部关闭:由弹层组件自己监听根元素后回调进来(组件持有自己的根元素,父组件无需解析 $el) const closeSessionPopup = () => { showSessionPopup.value = false; sessionPopupManual.value = false; }; const closeSkillPopup = () => { showSkillPopup.value = false; skillPopupManual.value = false; }; // 弹层选中项被回车确认后,紧接着的那次回车不应触发发送(组合态下 Safari 的 isComposing 不可靠,同样要挡) const consumeJustSelected = () => { if (sessionJustSelected) { sessionJustSelected = false; return true; } if (skillJustSelected) { skillJustSelected = false; return true; } return false; }; return { showSessionPopup, activeSessionIndex, selectedSessions, showSkillPopup, activeSkillIndex, selectedSkill, filteredSessionList, filteredSkillList, skillListLoadFailed, handleSessionKeydown, handleSelectSession, handleSelectSkill, handleInsertSession, handleInsertSkill, removeSession, removeSkill, handleBackspace, syncPopups, consumeJustSelected, closeSessionPopup, closeSkillPopup, }; };