assistant.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. /* global $, userID, postData */
  2. /* eslint-env browser */
  3. 'use strict';
  4. (function() {
  5. $(function() {
  6. const root = $('#global-assistant');
  7. if (root.length === 0) return;
  8. const panel = root.find('.global-assistant-panel');
  9. const messages = root.find('.global-assistant-messages');
  10. const suggestions = root.find('.global-assistant-suggestions');
  11. const input = root.find('.global-assistant-input');
  12. const sendButton = root.find('.global-assistant-send');
  13. const entry = root.find('.global-assistant-entry');
  14. const projectId = root.data('project-id');
  15. const storageKey = 'global-assistant-history-v2-' + projectId + '-' + userID;
  16. const entryPositionKey = 'global-assistant-entry-position-v2-' + userID;
  17. let history = loadHistory();
  18. let conversationId = getConversationId();
  19. let sending = false;
  20. let entryPosition = loadEntryPosition();
  21. let entryDrag = null;
  22. let suppressEntryClick = false;
  23. let suppressClickTimer = null;
  24. let entryExpanded = false;
  25. applyEntryPosition();
  26. entry.on('click', function(event) {
  27. if (suppressEntryClick) {
  28. event.preventDefault();
  29. suppressEntryClick = false;
  30. return;
  31. }
  32. panel.addClass('show').attr('aria-hidden', 'false');
  33. $(this).attr('aria-expanded', 'true').hide();
  34. input.trigger('focus');
  35. scrollToBottom();
  36. });
  37. entry.on('pointerdown', function(event) {
  38. const pointer = event.originalEvent;
  39. if (pointer.pointerType === 'mouse' && pointer.button !== 0) return;
  40. if (entryDrag) return;
  41. const rect = this.getBoundingClientRect();
  42. entryDrag = {
  43. pointerId: pointer.pointerId,
  44. startX: pointer.clientX,
  45. startY: pointer.clientY,
  46. grabRatio: rect.width ? (pointer.clientX - rect.left) / rect.width : 0.5,
  47. grabY: pointer.clientY - rect.top,
  48. originalPosition: Object.assign({}, entryPosition),
  49. moved: false,
  50. };
  51. clearTimeout(suppressClickTimer);
  52. suppressEntryClick = false;
  53. this.setPointerCapture(pointer.pointerId);
  54. event.preventDefault();
  55. });
  56. entry.on('pointermove', function(event) {
  57. const pointer = event.originalEvent;
  58. if (!entryDrag || pointer.pointerId !== entryDrag.pointerId) return;
  59. const offsetX = pointer.clientX - entryDrag.startX;
  60. const offsetY = pointer.clientY - entryDrag.startY;
  61. if (!entryDrag.moved && Math.abs(offsetX) <= 4 && Math.abs(offsetY) <= 4) return;
  62. entryDrag.moved = true;
  63. entryExpanded = false;
  64. entry.addClass('dragging');
  65. const width = 92;
  66. entryPosition = clampEntryPosition({
  67. right: window.innerWidth - pointer.clientX - width * (1 - entryDrag.grabRatio),
  68. top: pointer.clientY - entryDrag.grabY,
  69. docked: false,
  70. });
  71. applyEntryPosition();
  72. event.preventDefault();
  73. });
  74. entry.on('pointerup pointercancel lostpointercapture', function(event) {
  75. const pointer = event.originalEvent;
  76. if (!entryDrag || pointer.pointerId !== entryDrag.pointerId) return;
  77. const drag = entryDrag;
  78. entryDrag = null;
  79. suppressEntryClick = event.type === 'pointerup' && drag.moved;
  80. if (event.type !== 'pointerup') entryPosition = drag.originalPosition;
  81. else if (drag.moved) entryPosition.docked = entryPosition.right <= 24;
  82. if (drag.moved || event.type !== 'pointerup') entryExpanded = false;
  83. entryPosition = clampEntryPosition(entryPosition);
  84. entry.removeClass('dragging');
  85. applyEntryPosition();
  86. if (this.hasPointerCapture(pointer.pointerId)) this.releasePointerCapture(pointer.pointerId);
  87. saveEntryPosition();
  88. suppressClickTimer = setTimeout(function() { suppressEntryClick = false; }, 0);
  89. });
  90. entry.on('pointerenter focusin', function(event) {
  91. if (!entryPosition.docked || entryDrag || (event.originalEvent && event.originalEvent.pointerType === 'touch')) return;
  92. entryExpanded = true;
  93. applyEntryPosition();
  94. });
  95. entry.on('pointerleave focusout', function() {
  96. if (entryDrag) return;
  97. entryExpanded = false;
  98. applyEntryPosition();
  99. });
  100. $(window).on('resize.globalAssistant', function() {
  101. entryPosition = clampEntryPosition(entryPosition);
  102. applyEntryPosition();
  103. saveEntryPosition();
  104. });
  105. root.find('.global-assistant-close').on('click', function() {
  106. panel.removeClass('show').attr('aria-hidden', 'true');
  107. entryExpanded = false;
  108. entry.attr('aria-expanded', 'false').show();
  109. applyEntryPosition();
  110. });
  111. root.find('.global-assistant-clear').on('click', function() {
  112. if (sending) return;
  113. history = [];
  114. conversationId = '';
  115. saveHistory();
  116. renderHistory();
  117. });
  118. root.find('.global-assistant-form').on('submit', function(event) {
  119. event.preventDefault();
  120. ask(input.val());
  121. });
  122. input.on('keydown', function(event) {
  123. if (event.key === 'Enter' && !event.shiftKey) {
  124. event.preventDefault();
  125. root.find('.global-assistant-form').trigger('submit');
  126. }
  127. });
  128. suggestions.on('click', 'button', function() {
  129. ask($(this).text());
  130. });
  131. renderHistory();
  132. function ask(question) {
  133. question = $.trim(question || '');
  134. if (!question || sending) return;
  135. input.val('');
  136. addHistory({ role: 'user', text: question });
  137. setSending(true);
  138. postData('/assistant/query/v2', {
  139. question,
  140. conversationId,
  141. context: {
  142. path: window.location.pathname,
  143. title: document.title,
  144. subProjectId: root.data('sub-project-id') || '',
  145. tenderId: getTenderId(),
  146. },
  147. }, function(data) {
  148. conversationId = data.conversationId || '';
  149. addHistory({ role: 'assistant', text: data.answer, data });
  150. setSending(false);
  151. }, function(message) {
  152. addHistory({ role: 'assistant', text: message || '查询失败,请稍后重试。' });
  153. setSending(false);
  154. }, false, '助手查询失败');
  155. }
  156. function setSending(value) {
  157. sending = value;
  158. input.prop('disabled', value);
  159. sendButton.prop('disabled', value);
  160. root.find('.global-assistant-clear').prop('disabled', value);
  161. messages.find('.global-assistant-loading').remove();
  162. if (value) {
  163. const loading = $('<div class="global-assistant-message assistant global-assistant-loading"></div>');
  164. loading.append($('<div class="global-assistant-bubble"></div>').text('正在查找相关内容…'));
  165. messages.append(loading);
  166. }
  167. scrollToBottom();
  168. if (!value) input.trigger('focus');
  169. }
  170. function addHistory(message) {
  171. history.push(message);
  172. if (history.length > 20) history = history.slice(history.length - 20);
  173. saveHistory();
  174. renderHistory();
  175. }
  176. function renderHistory() {
  177. messages.empty();
  178. if (history.length === 0) {
  179. appendMessage({
  180. role: 'assistant',
  181. text: '你好,我可以查询当前账号有权查看的业务数据,也可以从用户手册中查找操作说明。',
  182. });
  183. renderSuggestions(['查询平台概况', '查询项目', '如何创建标段', '怎么设置审批流程']);
  184. } else {
  185. history.forEach(appendMessage);
  186. const lastAssistant = history.slice().reverse().find(item => item.role === 'assistant' && item.data);
  187. renderSuggestions(lastAssistant && lastAssistant.data.suggestions ? lastAssistant.data.suggestions : []);
  188. }
  189. scrollToBottom();
  190. }
  191. function appendMessage(message) {
  192. const wrapper = $('<div class="global-assistant-message"></div>').addClass(message.role);
  193. const bubble = $('<div class="global-assistant-bubble"></div>').text(message.text || '');
  194. wrapper.append(bubble);
  195. if (message.role === 'assistant' && message.data) {
  196. appendSummary(bubble, message.data.summary || []);
  197. appendItems(bubble, message.data.items || []);
  198. if (message.data.truncated) {
  199. bubble.append($('<div class="global-assistant-more"></div>').text('结果较多,仅展示前20条。请增加名称或编号缩小范围。'));
  200. }
  201. if (message.data.tenderMatchNotice) {
  202. bubble.append($('<div class="global-assistant-tender-notice"></div>').text(message.data.tenderMatchNotice));
  203. }
  204. }
  205. messages.append(wrapper);
  206. }
  207. function appendSummary(bubble, summary) {
  208. if (!summary.length) return;
  209. const summaryBox = $('<div class="global-assistant-summary"></div>');
  210. summary.forEach(function(item) {
  211. const block = $('<div></div>');
  212. block.append($('<span></span>').text(item.label));
  213. block.append($('<strong></strong>').text(item.value));
  214. summaryBox.append(block);
  215. });
  216. bubble.append(summaryBox);
  217. }
  218. function appendItems(bubble, items) {
  219. if (!items.length) return;
  220. const list = $('<div class="global-assistant-results"></div>');
  221. items.forEach(function(item) {
  222. if (item.pageUrl) {
  223. const row = $('<div class="global-assistant-result"></div>');
  224. const info = $('<div class="global-assistant-result-info"></div>');
  225. info.append($('<strong></strong>').text(item.title || '未命名记录'));
  226. if (item.description) info.append($('<small></small>').text(item.description));
  227. row.append(info);
  228. if (item.value) row.append($('<span class="global-assistant-result-value"></span>').text(item.value));
  229. const actions = $('<div class="global-assistant-result-actions"></div>');
  230. actions.append($('<a></a>').attr({ href: item.pageUrl, title: '前往页面', 'aria-label': '前往页面' })
  231. .append($('<i class="fa fa-external-link" aria-hidden="true"></i>')));
  232. if (item.url) {
  233. actions.append($('<a></a>').attr({ href: item.url, title: '下载', 'aria-label': '下载' })
  234. .append($('<i class="fa fa-download" aria-hidden="true"></i>')));
  235. }
  236. row.append(actions);
  237. list.append(row);
  238. return;
  239. }
  240. const isExternal = /^https?:\/\//i.test(item.url || '');
  241. const row = $('<a></a>')
  242. .attr('href', item.url || 'javascript:void(0)')
  243. .attr('target', isExternal ? '_blank' : '_self');
  244. if (isExternal) row.attr('rel', 'noopener noreferrer');
  245. const info = $('<div></div>');
  246. info.append($('<strong></strong>').text(item.title || '未命名记录'));
  247. if (item.description) info.append($('<small></small>').text(item.description));
  248. row.append(info);
  249. if (item.value) row.append($('<span></span>').text(item.value));
  250. list.append(row);
  251. });
  252. bubble.append(list);
  253. }
  254. function renderSuggestions(list) {
  255. suggestions.empty();
  256. list.slice(0, 4).forEach(function(text) {
  257. suggestions.append($('<button type="button"></button>').text(text));
  258. });
  259. }
  260. function getTenderId() {
  261. const match = window.location.pathname.match(/^\/tender\/(\d+)/);
  262. return match ? match[1] : '';
  263. }
  264. function scrollToBottom() {
  265. messages.scrollTop(messages.prop('scrollHeight'));
  266. }
  267. function loadHistory() {
  268. try {
  269. const value = JSON.parse(localStorage.getItem(storageKey) || '[]');
  270. return value instanceof Array ? value : [];
  271. } catch (err) {
  272. return [];
  273. }
  274. }
  275. function saveHistory() {
  276. try {
  277. localStorage.setItem(storageKey, JSON.stringify(history));
  278. } catch (err) {
  279. // 浏览器禁用本地存储时仅保留当前页面内的对话。
  280. }
  281. }
  282. function getConversationId() {
  283. const last = history.slice().reverse().find(item => item.role === 'assistant' && item.data);
  284. return last && last.data.conversationId ? last.data.conversationId : '';
  285. }
  286. function loadEntryPosition() {
  287. const initial = { right: 0, top: window.innerHeight - 44 - 24, docked: true };
  288. try {
  289. const value = JSON.parse(localStorage.getItem(entryPositionKey) || '{}');
  290. if (!value || typeof value.docked !== 'boolean') return clampEntryPosition(initial);
  291. return clampEntryPosition(value);
  292. } catch (err) {
  293. return clampEntryPosition(initial);
  294. }
  295. }
  296. function saveEntryPosition() {
  297. try {
  298. localStorage.setItem(entryPositionKey, JSON.stringify(entryPosition));
  299. } catch (err) {
  300. // 浏览器禁用本地存储时仅在当前页面保留按钮位置。
  301. }
  302. }
  303. function applyEntryPosition() {
  304. entry.toggleClass('docked', entryPosition.docked)
  305. .toggleClass('expanded', entryPosition.docked && entryExpanded)
  306. .css({ right: entryPosition.right, top: entryPosition.top, transform: 'none' });
  307. }
  308. function clampEntryPosition(position) {
  309. const docked = position.docked === true;
  310. const maxRight = Math.max(0, window.innerWidth - 92 - 8);
  311. const maxTop = Math.max(0, window.innerHeight - 44 - 8);
  312. return {
  313. right: docked ? 0 : Math.min(maxRight, Math.max(Math.min(8, maxRight), Number(position.right) || 0)),
  314. top: Math.min(maxTop, Math.max(Math.min(8, maxTop), Number(position.top) || 0)),
  315. docked,
  316. };
  317. }
  318. });
  319. })();