MeetingSummary.vue 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. <template>
  2. <div class="meeting-summary">
  3. <div class="meeting-summary-header">
  4. <img class="icon" src="../../assets/image/home/meet-summary.png" />
  5. <div class="title">会议纪要</div>
  6. </div>
  7. <div class="meeting-summary-body" ref="meeting_summary_body">
  8. <SummaryItem v-for="(item, index) in summary_data" :key="index" :summary_item="item">
  9. <template #tools>
  10. <a-link v-if="item.type != 'loading'" type="text" class="tools tools-item" @click="toRegenerate(item)">
  11. <template #icon>
  12. <icon-refresh class="tools-icon" />
  13. </template>
  14. 重新生成
  15. </a-link>
  16. <a-link v-if="item.type != 'loading'" type="text" class="tools-item" @click="toCopy(item)">
  17. <template #icon>
  18. <icon-copy class="tools-icon" />
  19. </template>
  20. 复制
  21. </a-link>
  22. </template>
  23. </SummaryItem>
  24. <div class="loading" v-if="is_loading_summary">
  25. <a-spin :loading="is_loading_summary" tip="会议纪要生成中..."></a-spin>
  26. </div>
  27. </div>
  28. <div class="meeting-summary-footer">
  29. <MessageInput @submit="handleSubmit"></MessageInput>
  30. <div class="tips">内容由 AI 大模型生成,仅供参考</div>
  31. </div>
  32. </div>
  33. </template>
  34. <script setup>
  35. import { ref, onMounted, nextTick } from 'vue';
  36. import MessageInput from './meeting-summary/MessageInput.vue';
  37. import SummaryItem from './meeting-summary/SummaryItem.vue';
  38. import { formatMarkdownString } from './meeting-summary/format-anwser-string';
  39. import { getMeetingSummaryList, conversation, regenerateSummary } from '../../apis/meeting';
  40. import { useMeetingStore } from '../../base/store/use-meeting';
  41. import copyText from '../../3rd-libs/clipboard';
  42. import { Message } from '@arco-design/web-vue';
  43. const useMeeting = useMeetingStore();
  44. /**
  45. * 存放生成会议纪要的数据
  46. * */
  47. const summary_data = ref([]);
  48. const meeting_summary_body = ref(null);
  49. /**
  50. * 加载会议纪要数据
  51. * */
  52. const is_loading_summary = ref(false);
  53. /**
  54. * 获取会议纪要列表
  55. * 注意:该方法是在结束录音或者点击历史会议时会被调用,
  56. * 当结束录音是调用时,会出现会议纪要还在生成中的情况,返回的res.data.rows为空,
  57. * 所以需要等待一段时间,再重新获取会议纪要列表,直到返回的res.data.rows不为空,才继续执行。
  58. * */
  59. const getMeetingSummaryListFun = async () => {
  60. is_loading_summary.value = true;
  61. try {
  62. const res = await getMeetingSummaryList(useMeeting.current_meeting_data.id);
  63. if (res.code == 200) {
  64. if (res.data.rows.length) {
  65. const format_data = res.data.rows.map((item) => {
  66. return {
  67. ...item,
  68. format_content: item.content.length ? formatMarkdownString(item.content) : ''
  69. };
  70. });
  71. summary_data.value = format_data;
  72. // 解除输入框和按钮禁用
  73. useMeeting.changeDisabledInputOrBtn(false);
  74. scrollToBottom();
  75. } else {
  76. // 等待3s后重新获取会议纪要列表
  77. await new Promise((resolve) => setTimeout(resolve, 3000));
  78. await getMeetingSummaryListFun();
  79. }
  80. }
  81. } catch (e) {
  82. console.log(e);
  83. } finally {
  84. is_loading_summary.value = false;
  85. }
  86. };
  87. /**
  88. * 问答
  89. */
  90. const conversationToAi = async (data) => {
  91. addLoadingToList();
  92. const res = await conversation(data);
  93. if (res.code == 200) {
  94. summary_data.value.pop();
  95. summary_data.value.push({
  96. ...res.data,
  97. format_content: formatMarkdownString(res.data.content)
  98. });
  99. // 解除输入框和按钮禁用
  100. useMeeting.changeDisabledInputOrBtn(false);
  101. scrollToBottom();
  102. }
  103. };
  104. /**
  105. * 发送消息
  106. * */
  107. const handleSubmit = (input_text_value) => {
  108. if (!input_text_value.trim()) return;
  109. // 发送消息添加到列表中
  110. const questionItem = {
  111. content: input_text_value,
  112. speecher: 1 // 1: 用户,2:AI
  113. };
  114. addMessageToList(questionItem);
  115. // 禁用输入框和按钮禁用
  116. useMeeting.changeDisabledInputOrBtn(true);
  117. // 滚动到底部
  118. scrollToBottom();
  119. // 获取数组中最后一条Ai返回数据的content
  120. const lastItem = summary_data.value.findLast((item) => item.speecher == 2);
  121. // 接口所需数据
  122. const data = {
  123. content: lastItem ? lastItem.content : '',
  124. message: input_text_value,
  125. meeting_id: useMeeting.current_meeting_data.id
  126. };
  127. // 发送消息问答
  128. conversationToAi(data);
  129. };
  130. /**
  131. * 添加消息到列表
  132. * */
  133. const addMessageToList = (data) => {
  134. summary_data.value.push(data);
  135. };
  136. /**
  137. * 添加AI加载中
  138. * */
  139. const addLoadingToList = () => {
  140. const loadingItem = {
  141. // 是否需要替换
  142. type: 'loading',
  143. content: '会议纪要生成中...',
  144. format_content: '<p>会议纪要生成中...</p>',
  145. speecher: 2 // 1: 用户,2:AI
  146. };
  147. addMessageToList(loadingItem);
  148. // 滚动到底部
  149. scrollToBottom();
  150. };
  151. /**
  152. * 滚动到底部
  153. * */
  154. const scrollToBottom = () => {
  155. nextTick(() => {
  156. if (meeting_summary_body.value) {
  157. meeting_summary_body.value.scrollTop = meeting_summary_body.value.scrollHeight;
  158. }
  159. });
  160. };
  161. /**
  162. * 重新生成
  163. * */
  164. const regenerateSummaryFun = async (id, content) => {
  165. const data = {
  166. message: '重新生成',
  167. id,
  168. content
  169. };
  170. const res = await regenerateSummary(data);
  171. return res;
  172. };
  173. /**
  174. * 重新生成
  175. * */
  176. const toRegenerate = (data) => {
  177. summary_data.value.forEach(async (item) => {
  178. if (item.id == data.id) {
  179. item.type = 'loading';
  180. item.format_content = '<p>重新生成中...</p>';
  181. useMeeting.changeDisabledInputOrBtn(true);
  182. const res = await regenerateSummaryFun(item.id, item.content);
  183. if (res.code == 200) {
  184. item.type = 'stream'; //重新生成后,给AI返回的数据加一个type标识为stream,暂时没有用到该属性值做判断
  185. item.content = res.data.content;
  186. item.updated_at = res.data.updated_at;
  187. item.format_content = formatMarkdownString(res.data.content);
  188. // 解除输入框和按钮禁用
  189. useMeeting.changeDisabledInputOrBtn(false);
  190. }
  191. }
  192. });
  193. };
  194. /**
  195. * 复制
  196. * */
  197. const toCopy = (item) => {
  198. copyText(item.format_content);
  199. Message.success('复制成功');
  200. };
  201. onMounted(() => {
  202. if (useMeeting.meet_type == 'editMeet') {
  203. getMeetingSummaryListFun();
  204. }
  205. });
  206. </script>
  207. <style scoped lang="css">
  208. .meeting-summary {
  209. /* position: relative; */
  210. width: 851px;
  211. height: 100%;
  212. border-radius: 20px;
  213. background-color: var(--bg-body-color);
  214. /* background-color: #dfdfdf; */
  215. box-shadow: -10px 4px 10px 0px rgba(0, 0, 0, 0.08);
  216. }
  217. .meeting-summary-header {
  218. display: flex;
  219. gap: 10px;
  220. align-items: center;
  221. justify-content: center;
  222. padding: 20px 0;
  223. box-sizing: border-box;
  224. }
  225. .icon {
  226. width: 21px;
  227. }
  228. .title {
  229. font-size: 16px;
  230. font-weight: bold;
  231. color: var(--text-meet-summary-title-color);
  232. }
  233. .meeting-summary-body {
  234. position: relative;
  235. height: calc(100% - 60px - 175px);
  236. padding: 10px 30px;
  237. box-sizing: border-box;
  238. overflow-y: auto;
  239. }
  240. .loading {
  241. position: absolute;
  242. top: 0px;
  243. left: 0px;
  244. width: 100%;
  245. height: 100%;
  246. display: flex;
  247. justify-content: center;
  248. align-items: center;
  249. }
  250. .meeting-summary-footer {
  251. /* position: absolute; */
  252. /* bottom: 30px; */
  253. width: 100%;
  254. height: 175px;
  255. padding: 0px 30px;
  256. box-sizing: border-box;
  257. }
  258. .tips {
  259. margin-top: 10px;
  260. text-align: center;
  261. color: var(--text-qa-tip-color);
  262. }
  263. .tools {
  264. margin-right: 10px;
  265. }
  266. .tools-item {
  267. color: var(--text-download-color);
  268. }
  269. .tools-icon {
  270. font-size: 16px;
  271. }
  272. </style>