index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. <template>
  2. <u-navbar :titleStyle="{ color: '#000' }" :autoBack="true" title="小天AI" :placeholder="true" :safeAreaInsetTop="true" bgColor="#fff">
  3. <template #left>
  4. <view class="u-navbar__content__left__item">
  5. <u-icon name="arrow-left" size="20" color="#000"></u-icon>
  6. </view>
  7. </template>
  8. </u-navbar>
  9. <oa-scroll
  10. customClass="doorList-container scroll-height"
  11. :isSticky="true"
  12. :customStyle="{
  13. //#ifdef APP-PLUS || MP-WEIXIN
  14. height: `calc(100vh - (44px + ${footerHeight}px + ${proxy.$settingStore.StatusBarHeight}))`,
  15. //#endif
  16. //#ifdef H5
  17. height: `calc(100vh - (44px + ${footerHeight}px))`,
  18. //#endif
  19. }"
  20. :refresherLoad="false"
  21. :refresherEnabled="false"
  22. :scrollTop="scrollTop"
  23. :scrollIntoView="scrollIntoView"
  24. :refresherDefaultStyle="'none'"
  25. :refresherBackground="'#fffff'"
  26. :data-theme="'theme-' + proxy.$settingStore.themeColor.name"
  27. >
  28. <template #default>
  29. <view class="mainArea">
  30. <view class="center-area">
  31. <view class="center-area-item">
  32. <viwe class="center-area-item__avatar">
  33. <image src="@/static/images/ai/ai-avatar.png" mode="widthFix"></image>
  34. </viwe>
  35. <view class="center-area-item__center">
  36. <view class="center-area-item__center__content">
  37. <span class="defaultQuestion-title">您好,我是智能助手小天!</span>
  38. 作为您的智能助手,我能帮助您解决各种问题。 除此之外,小天还可以尝试帮助解决其他方面的问题或在您需要的时候陪您聊天!
  39. <!-- 您可以试着问我:
  40. <view class="defaultQuestion">
  41. <view class="defaultQuestion-item" @click="onClickDefaultQuestion($event, item)" v-for="item in defaultQuestion.list" :key="item.id" :span="12">
  42. {{ item.value }}
  43. </view>
  44. </view> -->
  45. </view>
  46. </view>
  47. </view>
  48. <viwe v-for="(item, index) in answerList" :key="index" :class="['center-area-item', { roleUser: item.role === 'user' }]" :id="index == answerList.length - 1 ? 'bottomInfo' : ''">
  49. <viwe class="center-area-item__avatar">
  50. <image v-show="item.role === 'assistant'" src="@/static/images/ai/ai-avatar.png" mode="widthFix"></image>
  51. <image v-show="item.role === 'user'" src="@/static/images/ai/ai-question-avatar.png" mode="widthFix"></image>
  52. </viwe>
  53. <view class="center-area-item__center">
  54. <viwe v-if="item.role === 'user'" class="center-area-item__center__content">
  55. {{ item.content }}
  56. </viwe>
  57. <viwe v-else-if="item.role === 'assistant'" class="center-area-item__center__content">
  58. <!-- <viwe class="center-area-item__outputReasonContent">{{ item.reasoningContent }}</viwe> -->
  59. <zero-markdown-view :markdown="item.reasoningContent" themeColor="#05073b"></zero-markdown-view>
  60. </viwe>
  61. <view
  62. class="iconfont oaIcon-copy center-area-item__center__icon"
  63. @click="copy(item.role === 'user' ? item.content : item.reasoningContent)"
  64. :style="{
  65. float: item.role === 'user' ? 'right' : 'left',
  66. margin: item.role === 'user' ? '5px 5px 0 0' : '5px 0 0 5px',
  67. }"
  68. >
  69. </view>
  70. </view>
  71. </viwe>
  72. </view>
  73. </view>
  74. </template>
  75. </oa-scroll>
  76. <view class="footer-area">
  77. <view class="footer-area-top">
  78. <view class="footer-area-top__record" @click="handle('Record')">对话历史</view>
  79. <view class="footer-area-top__new" @click="handle('CreateChat')">新话题</view>
  80. </view>
  81. <view class="footer-area-center">
  82. <textarea
  83. class="footer-area-center-textarea"
  84. v-model="answerKeyword"
  85. placeholder="请输入问题,可通过回车换行"
  86. maxlength="500"
  87. @confirm="onSubmit"
  88. @linechange="linechange"
  89. auto-height
  90. ></textarea>
  91. <button class="footer-area-center-button" type="primary" :loading="submitLoading" @click="onSubmit">发送</button>
  92. </view>
  93. <view class="footer-area-prompt">以上内容均由AI大模型生成,仅供参考</view>
  94. </view>
  95. <uni-popup ref="popup" type="left" safeArea backgroundColor="#fff">
  96. <view class="chatArea">
  97. <view class="content">
  98. <view class="content-title">近30天</view>
  99. <view class="content-item" v-for="item in state.chatHistory.list" :key="item" @click="onClickChatItem(item)" @longpress="onLongPressChatItem(item)"> {{ item.question }}</view>
  100. </view>
  101. </view>
  102. </uni-popup>
  103. <u-action-sheet
  104. :actions="sheet.actions"
  105. :show="sheet.show"
  106. cancelText="取消"
  107. :round="10"
  108. :wrapMaxHeight="'50vh'"
  109. :closeOnClickOverlay="true"
  110. :safeAreaInsetBottom="true"
  111. @close="sheet.show = false"
  112. @select="changeSheet"
  113. ></u-action-sheet>
  114. <u-modal
  115. :show="modal.show"
  116. :title="modal.title"
  117. :showCancelButton="true"
  118. :closeOnClickOverlay="true"
  119. @confirm="handle(modal.type == '删除' ? 'Delete' : 'Update')"
  120. @cancel="modal.show = false"
  121. @close="modal.show = false"
  122. >
  123. <view class="slot-content" style="width: 100%">
  124. <view v-if="modal.type == '删除'"> {{ modal.content }}</view>
  125. <view v-if="modal.type == '重命名'">
  126. <textarea v-model="modal.content" placeholder="请输入" border="bottom" maxlength="50" auto-height style="width: 100%; border-bottom: 1px #cbcbcb solid"></textarea>
  127. </view>
  128. </view>
  129. </u-modal>
  130. <oa-chatSSEClient ref="chatSSEClientRef" @onOpen="openCore" @onError="errorCore" @onMessage="messageCore" @onFinish="finishCore" />
  131. </template>
  132. <script setup>
  133. /*----------------------------------依赖引入-----------------------------------*/
  134. import config from "@/config";
  135. import { getToken } from "@/utils/auth";
  136. import { onLoad, onShow, onReady, onHide, onLaunch, onUnload, onNavigationBarButtonTap, onPageScroll } from "@dcloudio/uni-app";
  137. import { ref, reactive, computed, getCurrentInstance, toRefs, inject, nextTick } from "vue";
  138. /*----------------------------------接口引入-----------------------------------*/
  139. import { aiApi } from "@/api/business/ai.js";
  140. /*----------------------------------组件引入-----------------------------------*/
  141. /*----------------------------------store引入-----------------------------------*/
  142. import { useStores, commonStores, controlStores } from "@/store/modules/index";
  143. /*----------------------------------公共方法引入-----------------------------------*/
  144. /*----------------------------------公共变量-----------------------------------*/
  145. const { proxy } = getCurrentInstance();
  146. const useStore = useStores();
  147. const controlStore = controlStores();
  148. /*----------------------------------变量声明-----------------------------------*/
  149. const state = reactive({
  150. buffer: "", // 用于存储流式数据不完整的行
  151. defaultQuestion: {
  152. list: [
  153. { id: 2, value: "如何看待中国锂矿储量从全球占比6%升至16.5%,从世界第六跃至第二?" },
  154. { id: 3, value: "为什么特斯拉和理想,都不想承认自己是「汽车公司」?" },
  155. { id: 4, value: "全美第二大的城市洛杉矶大火,为什么能蔓延到无法控制的地步?" },
  156. { id: 4, value: "为什么主流都不再力推英特尔 CPU?" },
  157. ],
  158. },
  159. submitLoading: false,
  160. answerKeyword: undefined,
  161. answerList: [],
  162. // 会话记录
  163. chatHistory: {
  164. list: [],
  165. sessionId: undefined,
  166. },
  167. footerHeight: 0, //底部区域高度
  168. scrollIntoView: "", //滚动位置
  169. sheet: {
  170. show: false,
  171. actions: [
  172. {
  173. name: "删除",
  174. },
  175. {
  176. name: "重命名",
  177. },
  178. ],
  179. list: {},
  180. },
  181. modal: {
  182. show: false,
  183. title: "",
  184. content: "",
  185. type: "",
  186. },
  187. });
  188. const { buffer, defaultQuestion, submitLoading, answerKeyword, answerList, chatHistory, footerHeight, scrollIntoView, sheet, modal } = toRefs(state);
  189. /** 操作 */
  190. function handle(type) {
  191. if (type == "Record") {
  192. getChatList();
  193. proxy.$refs["popup"].open();
  194. } else if (type == "CreateChat") {
  195. if (state.chatHistory.list.length >= 10) {
  196. return uni.showToast({ title: "您最多可同时建立10个对话,如需新建对话,请先删除会话!", icon: "none" });
  197. }
  198. state.chatHistory.sessionId = undefined;
  199. state.answerList = [];
  200. state.answerKeyword = undefined;
  201. } else if (type == "Delete") {
  202. aiApi()
  203. .Delete(state.sheet.list.sessionId)
  204. .then((response) => {
  205. proxy.$modal.msg("删除成功");
  206. state.modal.show = false;
  207. });
  208. } else if (type == "Update") {
  209. aiApi()
  210. .Update({
  211. sessionId: state.sheet.list.sessionId,
  212. question: state.modal.content,
  213. })
  214. .then((response) => {
  215. proxy.$modal.msg("修改成功");
  216. state.modal.show = false;
  217. });
  218. }
  219. }
  220. /** 获取历史对话列表 */
  221. function getChatList() {
  222. aiApi()
  223. .recordSelect()
  224. .then((response) => {
  225. state.chatHistory.list = response.data;
  226. });
  227. }
  228. /** 点击历史对话事件 */
  229. function onClickChatItem(item) {
  230. state.scrollIntoView = "";
  231. state.chatHistory.sessionId = item.sessionId;
  232. state.answerList = item.itemList;
  233. nextTick(() => {
  234. state.scrollIntoView = "bottomInfo";
  235. proxy.$refs["popup"].close();
  236. });
  237. }
  238. /** 长按历史对话事件 */
  239. function onLongPressChatItem(item) {
  240. proxy.$refs["popup"].close();
  241. state.sheet.show = true;
  242. state.sheet.list = item;
  243. }
  244. /** 当输入框行数发生变化 */
  245. function linechange(event) {
  246. nextTick(() => {
  247. const query = uni.createSelectorQuery();
  248. query
  249. .select(".footer-area")
  250. .boundingClientRect((rect) => {
  251. state.footerHeight = rect.height;
  252. })
  253. .exec();
  254. });
  255. }
  256. /** 操作菜单回调事件 */
  257. function changeSheet(event) {
  258. if (event.name == "删除") {
  259. state.modal.title = "永久删除对话";
  260. state.modal.content = "删除后,改对话将不可恢复。确认删除吗?";
  261. state.modal.type = "删除";
  262. state.modal.show = true;
  263. } else if (event.name == "重命名") {
  264. state.modal.title = "重命名会话";
  265. state.modal.content = state.sheet.list.question;
  266. state.modal.type = "重命名";
  267. state.modal.show = true;
  268. }
  269. }
  270. /** 复制粘贴板 */
  271. function copy(value) {
  272. // 触发方法
  273. proxy.$common.uniCopy({
  274. content: value,
  275. success: (res) => {
  276. uni.showToast({
  277. title: res,
  278. icon: "none",
  279. });
  280. },
  281. error: (e) => {
  282. uni.showToast({
  283. title: e,
  284. icon: "none",
  285. duration: 3000,
  286. });
  287. },
  288. });
  289. }
  290. /** 点击默认提问事件 */
  291. function onClickDefaultQuestion(event, item) {
  292. state.answerKeyword = item.value;
  293. onSubmit(event);
  294. }
  295. async function onSubmit(event) {
  296. if (event.keyCode === 13 || event.key === "Enter") {
  297. event.preventDefault(); // 阻止默认行为,即回车换行
  298. if (event.shiftKey) {
  299. state.answerKeyword += "\n";
  300. }
  301. }
  302. if (!state.answerKeyword) return uni.showToast({ title: "请输入内容", icon: "none" });
  303. if (state.submitLoading) return;
  304. state.submitLoading = true;
  305. state.answerList.push({
  306. role: "user",
  307. content: state.answerKeyword,
  308. });
  309. proxy.$refs["chatSSEClientRef"].startChat({
  310. // 将它换成你的地址
  311. url: config.baseUrl + "/service-ai/ai/aliTyqw",
  312. // 请求头
  313. headers: {
  314. Authorization: getToken(),
  315. "Content-Type": "application/json; charset=utf-8",
  316. },
  317. // 默认为 post
  318. method: "post",
  319. body: {
  320. sessionId: state.chatHistory.sessionId,
  321. content: state.answerKeyword,
  322. },
  323. });
  324. }
  325. //数据处理
  326. function processSSEChunk(chunk) {
  327. state.scrollIntoView = "";
  328. try {
  329. const jsonData = JSON.parse(chunk);
  330. if (jsonData.sessionId && !state.chatHistory.sessionId) {
  331. state.chatHistory.sessionId = jsonData.sessionId;
  332. }
  333. if (jsonData.reasoningContent) {
  334. state.answerList[state.answerList.length - 1].reasoningContent += jsonData.reasoningContent;
  335. } else if (jsonData.content) {
  336. state.answerList[state.answerList.length - 1].content += jsonData.content;
  337. }
  338. } catch (error) {
  339. console.log("parsing JSON错误", jsonString, index);
  340. console.log("parsing JSON错误", error);
  341. }
  342. nextTick(() => {
  343. state.scrollIntoView = "bottomInfo";
  344. });
  345. }
  346. //开始回答回调事件
  347. function openCore() {
  348. console.log("open sse");
  349. state.answerKeyword = undefined;
  350. // 定义一个函数来读取流数据
  351. state.answerList.push({
  352. role: "assistant",
  353. content: "",
  354. reasoningContent: "",
  355. });
  356. }
  357. //回答异常回调事件
  358. function errorCore(err) {
  359. console.log("error sse:", err);
  360. state.answerKeyword = undefined;
  361. }
  362. //回答中回调事件
  363. function messageCore(response) {
  364. processSSEChunk(response);
  365. }
  366. //回答完毕回调事件
  367. function finishCore() {
  368. console.log("finish sse");
  369. state.submitLoading = false;
  370. }
  371. //停止回答
  372. function stop() {
  373. proxy.$refs["chatSSEClientRef"].stopChat();
  374. console.log("stop");
  375. }
  376. onReady(() => {});
  377. onShow(() => {});
  378. onLoad((options) => {});
  379. onUnload(() => {});
  380. </script>
  381. <style lang="scss" scoped>
  382. .doorList-container {
  383. background-color: #ffffff;
  384. }
  385. .center-area {
  386. height: auto;
  387. overflow: auto;
  388. &-item {
  389. display: flex;
  390. color: #05073b;
  391. padding: 15px;
  392. &__avatar {
  393. width: 26px;
  394. margin-top: 2px;
  395. margin-right: 10px;
  396. uni-image {
  397. border-radius: 50%;
  398. }
  399. }
  400. &__center {
  401. &__content {
  402. background: #f4f5f9;
  403. border-radius: 8px;
  404. box-shadow: 0 5px 20px 0 #f4f5f9;
  405. display: flex;
  406. flex-direction: column;
  407. padding: 10px 10px;
  408. position: relative;
  409. font-size: 14px;
  410. line-height: 1.75;
  411. min-height: 44.5px;
  412. box-sizing: border-box;
  413. word-break: break-all; //文字默认换行
  414. }
  415. &__icon {
  416. color: #909399;
  417. }
  418. }
  419. }
  420. .roleUser {
  421. justify-content: end;
  422. margin-left: auto;
  423. .center-area-item__avatar {
  424. margin-right: 0;
  425. margin-left: 10px;
  426. order: 2;
  427. }
  428. }
  429. }
  430. .footer-area {
  431. position: fixed;
  432. bottom: 0;
  433. width: 100%;
  434. // height: 137px;
  435. background-color: #ffffff;
  436. &-top {
  437. display: flex;
  438. margin: 10px 20px 0 20px;
  439. color: #7f7f7f;
  440. &__record {
  441. margin-left: auto;
  442. }
  443. &__new {
  444. margin-left: 15px;
  445. }
  446. }
  447. &-center {
  448. display: flex;
  449. width: calc(100% - 30px);
  450. background-color: #f4f5f9;
  451. margin: 10px 15px 10px 15px;
  452. padding: 15px;
  453. border-radius: 8px;
  454. &-textarea {
  455. width: 100%;
  456. margin-right: 20px;
  457. }
  458. &-button {
  459. width: 54px;
  460. height: 33px;
  461. display: flex;
  462. justify-content: flex-end;
  463. font-size: 13px;
  464. white-space: nowrap;
  465. }
  466. }
  467. &-prompt {
  468. margin: 10px 0;
  469. color: #adadad;
  470. font-size: 20rpx;
  471. text-align: center;
  472. }
  473. }
  474. .chatArea {
  475. width: 70vw;
  476. overflow-y: auto;
  477. height: 100vh;
  478. padding: 15px;
  479. .content {
  480. &-title {
  481. color: #8b8b8b;
  482. margin-bottom: 5px;
  483. }
  484. &-item {
  485. padding: 10px 5px;
  486. border-radius: 5px;
  487. letter-spacing: 1px;
  488. white-space: nowrap;
  489. overflow: hidden;
  490. text-overflow: ellipsis; /* 超出部分显示省略号 */
  491. position: relative; /* 为伪元素定位 */
  492. &:active {
  493. background-color: #e5e5e5;
  494. }
  495. &:hover {
  496. background-color: #e5e5e5;
  497. }
  498. }
  499. }
  500. }
  501. </style>