| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- 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<void> | 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<Array<{ id: string; name: string }>>([]);
- 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<Skill[]>([]);
- // 技能列表拉取失败:$ 弹层的空态据此区分「加载失败」与「确实没有技能」,避免失败被显示成空数据
- 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,
- };
- };
|