useMentionPopups.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import { computed, nextTick, onMounted, ref, watch } from 'vue';
  2. import { getSkills } from '/@/views/ventAI/manageAssistent/api';
  3. import type { Skill, Task } from '/@/views/ventAI/manageAssistent/types/index';
  4. /**
  5. * 输入框的会话/技能引用弹层:`#` 触发会话提及、`$` 触发技能提及,含搜索过滤、方向键导航、
  6. * 回车/点击选中、标签增删,以及输入法组合态下不驱动弹层的处理。
  7. *
  8. * 输入框本身(文本、发送、附件)仍在 ChatInputArea 里,这里通过 deps 读写它的文本与焦点;
  9. * 技能列表缓存放在模块级,跨组件实例复用,避免重复请求。
  10. */
  11. export interface MentionPopupDeps {
  12. getModelValue: () => string;
  13. setModelValue: (value: string) => void;
  14. getTaskList: () => Task[];
  15. getCurrentTaskId: () => string;
  16. isComposing: () => boolean;
  17. closeAttachPopup: () => void;
  18. focusTextarea: () => void;
  19. setSelectedSessions: (sessions: Array<{ id: string; name: string }>) => void;
  20. setSelectedSkill: (skill: { name: string; display_name: string } | null) => void;
  21. }
  22. // Module-level cache for skill list to avoid redundant API calls across component mounts
  23. let skillListCache: Skill[] | null = null;
  24. let skillListFetchPromise: Promise<void> | null = null;
  25. export const useMentionPopups = (deps: MentionPopupDeps) => {
  26. const {
  27. getModelValue,
  28. setModelValue,
  29. getTaskList,
  30. getCurrentTaskId,
  31. isComposing,
  32. closeAttachPopup,
  33. focusTextarea,
  34. setSelectedSessions,
  35. setSelectedSkill,
  36. } = deps;
  37. const showSessionPopup = ref(false);
  38. const activeSessionIndex = ref(0);
  39. const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
  40. const sessionPopupManual = ref(false);
  41. let isRemovingTag = false;
  42. let sessionJustSelected = false;
  43. // 技能列表弹框
  44. const showSkillPopup = ref(false);
  45. const activeSkillIndex = ref(0);
  46. const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
  47. const skillPopupManual = ref(false);
  48. const skillList = ref<Skill[]>([]);
  49. // 技能列表拉取失败:$ 弹层的空态据此区分「加载失败」与「确实没有技能」,避免失败被显示成空数据
  50. const skillListLoadFailed = ref(false);
  51. let skillJustSelected = false;
  52. const fetchSkillList = async () => {
  53. // Use cached data if available
  54. if (skillListCache) {
  55. skillList.value = skillListCache;
  56. return;
  57. }
  58. // Deduplicate concurrent requests
  59. if (skillListFetchPromise) {
  60. await skillListFetchPromise;
  61. skillList.value = skillListCache || [];
  62. return;
  63. }
  64. skillListFetchPromise = (async () => {
  65. try {
  66. const res = await getSkills();
  67. skillList.value = res?.skills || [];
  68. skillListCache = skillList.value;
  69. skillListLoadFailed.value = false;
  70. } catch (e) {
  71. // 这里刻意不弹提示:组件一挂载就预取,失败会打扰与技能无关的操作。
  72. // 改为记住失败状态,由 $ 弹层的空态如实说明。
  73. console.error('获取技能列表失败:', e);
  74. skillList.value = [];
  75. skillListLoadFailed.value = true;
  76. } finally {
  77. skillListFetchPromise = null;
  78. }
  79. })();
  80. await skillListFetchPromise;
  81. };
  82. onMounted(() => {
  83. fetchSkillList();
  84. });
  85. const sessionSearchText = computed(() => {
  86. const val = getModelValue() || '';
  87. const hashIndex = val.lastIndexOf('#');
  88. if (hashIndex === -1) return '';
  89. const afterHash = val.substring(hashIndex + 1);
  90. if (afterHash.includes(' ')) return '';
  91. return afterHash.toLowerCase();
  92. });
  93. const filteredSessionList = computed(() => {
  94. const list = getTaskList().filter((t) => t.sessionId);
  95. if (!sessionSearchText.value) return list;
  96. return list.filter((t) => t.name.toLowerCase().includes(sessionSearchText.value));
  97. });
  98. watch(filteredSessionList, () => {
  99. activeSessionIndex.value = 0;
  100. });
  101. watch(showSessionPopup, (val) => {
  102. if (val) activeSessionIndex.value = 0;
  103. });
  104. const skillSearchText = computed(() => {
  105. const val = getModelValue() || '';
  106. const dollarIndex = val.lastIndexOf('$');
  107. if (dollarIndex === -1) return '';
  108. const afterDollar = val.substring(dollarIndex + 1);
  109. if (afterDollar.includes(' ')) return '';
  110. return afterDollar.toLowerCase();
  111. });
  112. const filteredSkillList = computed(() => {
  113. if (!skillSearchText.value) return skillList.value;
  114. return skillList.value.filter(
  115. (s) => s.display_name.toLowerCase().includes(skillSearchText.value) || s.name.toLowerCase().includes(skillSearchText.value)
  116. );
  117. });
  118. watch(filteredSkillList, () => {
  119. activeSkillIndex.value = 0;
  120. });
  121. watch(showSkillPopup, (val) => {
  122. if (val) activeSkillIndex.value = 0;
  123. });
  124. const handleSessionKeydown = (e: KeyboardEvent) => {
  125. // 组合态期间键盘输入归输入法处理(候选上下翻页也用方向键),不驱动会话/技能弹框
  126. if (isComposing() || e.isComposing) return;
  127. if (showSkillPopup.value && filteredSkillList.value.length > 0) {
  128. if (e.key === 'ArrowDown') {
  129. e.preventDefault();
  130. activeSkillIndex.value = (activeSkillIndex.value + 1) % filteredSkillList.value.length;
  131. } else if (e.key === 'ArrowUp') {
  132. e.preventDefault();
  133. activeSkillIndex.value = (activeSkillIndex.value - 1 + filteredSkillList.value.length) % filteredSkillList.value.length;
  134. } else if (e.key === 'Enter') {
  135. e.preventDefault();
  136. const skill = filteredSkillList.value[activeSkillIndex.value];
  137. if (skill) {
  138. skillJustSelected = true;
  139. handleSelectSkill(skill);
  140. }
  141. }
  142. return;
  143. }
  144. if (showSessionPopup.value && filteredSessionList.value.length > 0) {
  145. if (e.key === 'ArrowDown') {
  146. e.preventDefault();
  147. activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
  148. } else if (e.key === 'ArrowUp') {
  149. e.preventDefault();
  150. activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
  151. } else if (e.key === 'Enter') {
  152. e.preventDefault();
  153. const task = filteredSessionList.value[activeSessionIndex.value];
  154. if (task) {
  155. sessionJustSelected = true;
  156. handleSelectSession(task);
  157. }
  158. }
  159. }
  160. };
  161. const handleInsertSession = () => {
  162. closeAttachPopup();
  163. sessionPopupManual.value = true;
  164. showSessionPopup.value = true;
  165. const cur = getModelValue() || '';
  166. const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
  167. setModelValue(`${cur}${needsSpace}#`);
  168. nextTick(() => focusTextarea());
  169. };
  170. const handleSelectSession = (task: Task) => {
  171. if (task.id === getCurrentTaskId()) return;
  172. const idx = selectedSessions.value.findIndex((s) => s.id === task.id);
  173. if (idx >= 0) {
  174. selectedSessions.value.splice(idx, 1);
  175. } else {
  176. if (selectedSessions.value.length >= 5) return;
  177. selectedSessions.value.push({ id: task.id, name: task.name });
  178. }
  179. removeSearchTag();
  180. setSelectedSessions([...selectedSessions.value]);
  181. };
  182. const handleInsertSkill = () => {
  183. closeAttachPopup();
  184. showSessionPopup.value = false;
  185. skillPopupManual.value = true;
  186. showSkillPopup.value = true;
  187. const cur = getModelValue() || '';
  188. const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
  189. setModelValue(`${cur}${needsSpace}$`);
  190. nextTick(() => focusTextarea());
  191. };
  192. const handleSelectSkill = (skill: Skill) => {
  193. selectedSkill.value = { name: skill.name, display_name: skill.display_name };
  194. removeSkillSearchTag();
  195. showSkillPopup.value = false;
  196. skillPopupManual.value = false;
  197. setSelectedSkill({ name: skill.name, display_name: skill.display_name });
  198. };
  199. const removeSkillSearchTag = () => {
  200. isRemovingTag = true;
  201. const val = getModelValue() || '';
  202. const dollarIndex = val.lastIndexOf('$');
  203. if (dollarIndex === -1) {
  204. isRemovingTag = false;
  205. return;
  206. }
  207. setModelValue(val.substring(0, dollarIndex).trimEnd());
  208. nextTick(() => {
  209. isRemovingTag = false;
  210. });
  211. };
  212. const removeSkill = () => {
  213. selectedSkill.value = null;
  214. setSelectedSkill(null);
  215. };
  216. const removeSearchTag = () => {
  217. isRemovingTag = true;
  218. const val = getModelValue() || '';
  219. const hashIndex = val.lastIndexOf('#');
  220. if (hashIndex === -1) {
  221. isRemovingTag = false;
  222. return;
  223. }
  224. setModelValue(val.substring(0, hashIndex).trimEnd());
  225. nextTick(() => {
  226. isRemovingTag = false;
  227. });
  228. };
  229. watch(
  230. () => getModelValue(),
  231. (newVal, oldVal) => {
  232. if (isRemovingTag) return;
  233. if (oldVal && !newVal) {
  234. // 输入框被清空时,引用一并作废。必须同步回父组件:真正写进请求的是父组件那份
  235. // (发送时按它拼 #session:/$skill: 前缀),只清本地 ref 会让 chip 消失但引用照旧发出,
  236. // 用户会看到一条自己以为已删除、且无法再移除的引用。
  237. selectedSessions.value = [];
  238. setSelectedSessions([]);
  239. selectedSkill.value = null;
  240. setSelectedSkill(null);
  241. }
  242. }
  243. );
  244. const removeSession = (sessionId: string) => {
  245. selectedSessions.value = selectedSessions.value.filter((s) => s.id !== sessionId);
  246. setSelectedSessions([...selectedSessions.value]);
  247. };
  248. const syncPopups = (val: string) => {
  249. // 技能弹框 $ 检测
  250. if (!skillPopupManual.value) {
  251. const dollarIndex = val.lastIndexOf('$');
  252. if (dollarIndex === -1) {
  253. showSkillPopup.value = false;
  254. } else {
  255. const afterDollar = val.substring(dollarIndex + 1);
  256. if (afterDollar.includes(' ')) {
  257. showSkillPopup.value = false;
  258. } else {
  259. showSkillPopup.value = true;
  260. showSessionPopup.value = false;
  261. }
  262. }
  263. }
  264. // 会话弹框 # 检测
  265. if (!sessionPopupManual.value) {
  266. const hashIndex = val.lastIndexOf('#');
  267. if (hashIndex === -1) {
  268. showSessionPopup.value = false;
  269. } else {
  270. const afterHash = val.substring(hashIndex + 1);
  271. if (afterHash.includes(' ')) {
  272. showSessionPopup.value = false;
  273. } else {
  274. showSessionPopup.value = true;
  275. showSkillPopup.value = false;
  276. }
  277. }
  278. }
  279. };
  280. const handleBackspace = (e: KeyboardEvent) => {
  281. const val = getModelValue() || '';
  282. if (val) return;
  283. if (selectedSkill.value) {
  284. e.preventDefault();
  285. selectedSkill.value = null;
  286. setSelectedSkill(null);
  287. } else if (selectedSessions.value.length > 0) {
  288. e.preventDefault();
  289. selectedSessions.value.pop();
  290. setSelectedSessions([...selectedSessions.value]);
  291. }
  292. };
  293. // 点击弹层外部关闭:由弹层组件自己监听根元素后回调进来(组件持有自己的根元素,父组件无需解析 $el)
  294. const closeSessionPopup = () => {
  295. showSessionPopup.value = false;
  296. sessionPopupManual.value = false;
  297. };
  298. const closeSkillPopup = () => {
  299. showSkillPopup.value = false;
  300. skillPopupManual.value = false;
  301. };
  302. // 弹层选中项被回车确认后,紧接着的那次回车不应触发发送(组合态下 Safari 的 isComposing 不可靠,同样要挡)
  303. const consumeJustSelected = () => {
  304. if (sessionJustSelected) {
  305. sessionJustSelected = false;
  306. return true;
  307. }
  308. if (skillJustSelected) {
  309. skillJustSelected = false;
  310. return true;
  311. }
  312. return false;
  313. };
  314. return {
  315. showSessionPopup,
  316. activeSessionIndex,
  317. selectedSessions,
  318. showSkillPopup,
  319. activeSkillIndex,
  320. selectedSkill,
  321. filteredSessionList,
  322. filteredSkillList,
  323. skillListLoadFailed,
  324. handleSessionKeydown,
  325. handleSelectSession,
  326. handleSelectSkill,
  327. handleInsertSession,
  328. handleInsertSkill,
  329. removeSession,
  330. removeSkill,
  331. handleBackspace,
  332. syncPopups,
  333. consumeJustSelected,
  334. closeSessionPopup,
  335. closeSkillPopup,
  336. };
  337. };