dialogGeneration.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <template>
  2. <view class="dialog-generation">
  3. <view class="subject-matter-ofText" :style="{ paddingBottom: keyboardHeight + 'px' }">
  4. <!-- 顶部导航栏 -->
  5. <div class="navbar">
  6. <view class="navbar-left">
  7. <image @click="goBack()" class="back" src="../../static/vip/hy_icon_jiantou.png"></image>
  8. <view class="elf-name">精灵·小萌</view>
  9. <image class="deepseek" src="../../static/makedetail/deepseek-logo.png"></image>
  10. </view>
  11. <view class="navbar-right">
  12. <image class="primary" src="../../static/makedetail/primary.png"></image>
  13. <image class="createChat" src="../../static/makedetail/createChat.png"></image>
  14. </view>
  15. </div>
  16. <!-- <view class="navbar-reserveASeat"> </view> -->
  17. <!-- 聊天内容区 -->
  18. <scroll-view class="chat-content" :scroll-into-view="toView" scroll-y
  19. :style="{ height: `calc(100% - ${370 + textareaHeight}rpx)` }" @scroll="onChatScroll">
  20. <template v-if="messages && messages.length > 0">
  21. <!-- <scroll-view class="chat-content" scroll-y> -->
  22. <view v-for="(msg, idx) in messages" :key="idx" :class="['chat-bubble', msg.role]">
  23. <template v-if="msg.role === 'user'">
  24. {{ msg.content }}
  25. </template>
  26. <template v-else-if="msg.role === 'ai'">
  27. <view class="ai-bubble-row">
  28. <view class="ai-avatar">
  29. <image src="../../static/makedetail/characterProfilePicture.png" mode="aspectFill">
  30. </image>
  31. </view>
  32. <view class="ai-bubble-content">
  33. <template v-if="idx === lastAiIndex && isLoading">
  34. <text>{{ displayText }}</text>
  35. <text v-if="isLoading" class="loading-dot">...</text>
  36. </template>
  37. <template v-else>
  38. <uv-text size="32rpx" color="#fff" :text="msg.content"></uv-text>
  39. </template>
  40. </view>
  41. </view>
  42. </template>
  43. </view>
  44. <view id="bottom-anchor"></view>
  45. <view v-if="error" class="chat-bubble ai error">{{ error }}</view>
  46. <image v-if="showToBottomBtn && keyboardHeight === 0" class="to-bottom-btn" @click="scrollToBottom"
  47. src="../../static/makedetail/toBottomBtn.png"></image>
  48. </template>
  49. <template v-else>
  50. <view class="chat-content-empty">
  51. <image src="../../static/makedetail/characterProfilePicture.png" mode="aspectFill"></image>
  52. <view class="chat-content-empty-title">嗨!我是创梦精灵</view>
  53. <view class="chat-content-empty-desc">与我聊聊你想要生成的角色吧!</view>
  54. </view>
  55. </template>
  56. </scroll-view>
  57. <!-- <view class="bom-reserveASeat"></view> -->
  58. <view class="bom-box" :style="{ bottom: 0 + 'px', height: `${190 + textareaHeight}rpx` }">
  59. <!-- 底部输入区 -->
  60. <view class="input-bar">
  61. <image class="icon-img" src="../../static/makedetail/characterProfilePicture.png" mode="aspectFill"></image>
  62. <textarea :autoHeight="true" class="input-box" :adjust-position="false" v-model="question"
  63. :disabled="isLoading" placeholder="给我发送消息吧..." @keyup.enter="onSend" maxlength="400"
  64. confirm-type="send" @input="onTextareaInput" rows="1"
  65. style="overflow-y:auto;max-height:176rpx;min-height:44rpx;" />
  66. <!-- <button class="send-btn" :disabled="isLoading || !question.trim()" @click="onSend">{{ isLoading ?
  67. '生成中...' :
  68. '发送' }}</button> -->
  69. <image v-if="isLoading" class="stop" src="../../static/makedetail/stop.png" mode=""
  70. @click="stopStreamAnswer" />
  71. <image v-else-if="!isLoading && !question.trim() && keyboardHeight === 0" class="keyboard"
  72. src="../../static/makedetail/keyboard.png" mode="" />
  73. <image v-else-if="!isLoading && question.trim() && keyboardHeight === 0" class="send" src="../../static/makedetail/send.png"
  74. mode="" @click="onSend" />
  75. </view>
  76. <!-- 底部提示 -->
  77. <view class="footer-tip">内容由AI生成,禁用相关功能请联系管理员。</view>
  78. </view>
  79. </view>
  80. </view>
  81. </template>
  82. <script>
  83. // 音乐流程 是否纯音乐 是否需要歌词 歌词是否合法 选择风格等(是否手动输入
  84. // 图片流程
  85. export default {
  86. data() {
  87. return {
  88. question: '',
  89. answer: '',
  90. displayText: '',
  91. isComplete: false,
  92. isLoading: false,
  93. error: null,
  94. retryCount: 0,
  95. maxRetries: 3,
  96. timer: null,
  97. lastResponseTime: 0,
  98. timeout: 30000,
  99. typingSpeed: 50,
  100. socketTask: null,
  101. messages: [
  102. ],
  103. lastAiIndex: -1,
  104. keyboardHeight: 0, // 存储键盘高度
  105. statusBarHeight: 0,
  106. windowBottom: 0,
  107. toView: '',
  108. showToBottomBtn: false,
  109. textareaHeight: 0, // 新增:textarea高度
  110. };
  111. },
  112. methods: {
  113. scrollToBottom() {
  114. this.toView = '';
  115. this.$nextTick(() => {
  116. this.toView = 'bottom-anchor';
  117. });
  118. },
  119. async startStreamAnswer(content) {
  120. if (!content.trim()) {
  121. uni.showToast({
  122. title: '请输入问题',
  123. icon: 'none'
  124. });
  125. return;
  126. }
  127. this.resetState();
  128. // 用户消息入队
  129. this.messages.push({ role: 'user', content });
  130. try {
  131. const response = await fetch(this.$apiHost + '/Work/streamAnswer?content=' + encodeURIComponent(content) + "&uuid=" + getApp().globalData.uuid);
  132. this.isLoading = true;
  133. const reader = response.body.getReader();
  134. const decoder = new TextDecoder();
  135. let aiMsg = { role: 'ai', content: '' };
  136. this.messages.push(aiMsg);
  137. console.log("this.messages", this.messages);
  138. this.lastAiIndex = this.messages.length - 1;
  139. while (true) {
  140. const { value, done } = await reader.read();
  141. if (done) break;
  142. const text = decoder.decode(value);
  143. console.log("收到的消息", text);
  144. if (text && text.includes('[DONE]')) {
  145. this.completeAnswer();
  146. break;
  147. }
  148. this.displayText += text;
  149. this.answer += text;
  150. aiMsg.content += text;
  151. this.retryCount = 0;
  152. this.lastResponseTime = Date.now();
  153. this.scrollToBottom();
  154. }
  155. } catch (error) {
  156. this.handleError(error);
  157. }
  158. },
  159. onSend() {
  160. if (!this.question.trim() || this.isLoading) return;
  161. this.startStreamAnswer(this.question);
  162. this.question = '';
  163. },
  164. resetState() {
  165. this.answer = '';
  166. this.displayText = '';
  167. this.isComplete = false;
  168. this.error = null;
  169. this.retryCount = 0;
  170. this.lastResponseTime = Date.now();
  171. this.stopStreamAnswer();
  172. },
  173. handleError(error) {
  174. if (this.retryCount < this.maxRetries) {
  175. this.retryCount++;
  176. this.retryRequest();
  177. } else {
  178. this.error = '请求失败,请稍后重试';
  179. this.isLoading = false;
  180. uni.showToast({
  181. title: this.error,
  182. icon: 'none'
  183. });
  184. }
  185. },
  186. retryRequest() {
  187. uni.showToast({
  188. title: `正在重试 (${this.retryCount}/${this.maxRetries})`,
  189. icon: 'none'
  190. });
  191. setTimeout(() => {
  192. this.startStreamAnswer(this.question);
  193. }, 1000 * this.retryCount);
  194. },
  195. completeAnswer() {
  196. this.isComplete = true;
  197. this.isLoading = false;
  198. this.stopStreamAnswer();
  199. },
  200. stopStreamAnswer() {
  201. if (this.timer) {
  202. clearTimeout(this.timer);
  203. this.timer = null;
  204. }
  205. if (this.socketTask) {
  206. this.socketTask.close();
  207. this.socketTask = null;
  208. }
  209. },
  210. checkTimeout() {
  211. if (Date.now() - this.lastResponseTime > this.timeout) {
  212. this.handleError(new Error('请求超时'));
  213. }
  214. },
  215. goBack() {
  216. uni.navigateBack({
  217. delta: 1
  218. });
  219. },
  220. onChatScroll(e) {
  221. const threshold = 600; // 离底部100px以内不显示按钮
  222. const { scrollHeight, scrollTop } = e.detail;
  223. const clientHeight = e.detail.clientHeight || e.detail.height || 0;
  224. if (scrollHeight - scrollTop - clientHeight > threshold) {
  225. this.showToBottomBtn = true;
  226. } else {
  227. this.showToBottomBtn = false;
  228. }
  229. },
  230. onTextareaInput(e) {
  231. // 获取textarea的实际高度
  232. const query = uni.createSelectorQuery().in(this);
  233. query.select('.input-box').boundingClientRect(data => {
  234. if (data) {
  235. // 将px转换为rpx (假设设计稿是750rpx宽度)
  236. const height = (data.height * 750) / uni.getSystemInfoSync().windowWidth;
  237. // 减去基础高度90rpx,得到额外增加的高度
  238. this.textareaHeight = Math.max(0, height - 90);
  239. // 滚动到底部
  240. this.scrollToBottom();
  241. }
  242. }).exec();
  243. },
  244. retrieveHistoricalRecords() {
  245. uni.request({
  246. url: this.$apiHost + '/Work/streamAnswerLast',
  247. method: 'GET',
  248. header: {
  249. 'content-type': 'application/json',
  250. 'sign': getApp().globalData.headerSign
  251. },
  252. data: {
  253. uuid: getApp().globalData.uuid,
  254. task_type: 2
  255. },
  256. success: (res) => {
  257. console.log("获取历史记录:", res.data.list);
  258. if (res.data.success === "yes") {
  259. this.messages = res.data.list
  260. if (res && res.data && res.data.list && res.data.list.length > 0) {
  261. this.showToBottomBtn = true
  262. }
  263. }
  264. },
  265. fail: (err) => {
  266. console.log('获取历史记录失败', err);
  267. uni.showToast({
  268. title: '获取历史记录失败',
  269. icon: 'none'
  270. });
  271. }
  272. })
  273. },
  274. // 重新计算元素高度
  275. recalculateHeights() {
  276. // 重新计算textarea高度
  277. const query = uni.createSelectorQuery().in(this);
  278. query.select('.input-box').boundingClientRect(data => {
  279. if (data) {
  280. const height = (data.height * 750) / uni.getSystemInfoSync().windowWidth;
  281. this.textareaHeight = Math.max(0, height - 90);
  282. }
  283. }).exec();
  284. }
  285. },
  286. created() {
  287. this.retrieveHistoricalRecords();
  288. this.timer = setInterval(() => {
  289. if (this.isLoading) {
  290. this.checkTimeout();
  291. }
  292. }, 1000);
  293. // 监听键盘高度变化
  294. uni.onKeyboardHeightChange(res => {
  295. this.keyboardHeight = res.height;
  296. console.log('键盘高度变化:', res.height);
  297. // 键盘高度变化时重新计算
  298. this.$nextTick(() => {
  299. this.recalculateHeights();
  300. });
  301. });
  302. const systemInfo = uni.getSystemInfoSync();
  303. this.statusBarHeight = systemInfo.statusBarHeight; // 状态栏高度
  304. this.windowBottom = systemInfo.safeAreaInsets ? systemInfo.safeAreaInsets.bottom : 0;
  305. console.log("状态栏高度", this.statusBarHeight, "底部安全区域高度", this.windowBottom);
  306. },
  307. beforeDestroy() {
  308. this.stopStreamAnswer();
  309. }
  310. }
  311. </script>
  312. <style lang="scss">
  313. @import './dialogGeneration.scss';
  314. .to-bottom-btn {
  315. position: fixed;
  316. right: 50%;
  317. transform: translateX(50%);
  318. bottom: 20rpx;
  319. width: 60rpx;
  320. height: 60rpx;
  321. bottom: 220rpx;
  322. z-index: 999;
  323. transition: opacity 0.2s;
  324. }
  325. .ai-bubble-row {
  326. display: flex;
  327. align-items: flex-start;
  328. }
  329. .ai-avatar {
  330. width: 60rpx;
  331. height: 60rpx;
  332. border-radius: 50%;
  333. margin-right: 32rpx;
  334. border: solid 2rpx #999;
  335. display: flex;
  336. align-items: flex-end;
  337. justify-content: center;
  338. image {
  339. width: 52rpx;
  340. height: 52rpx;
  341. }
  342. }
  343. .ai-bubble-content {
  344. flex: 1;
  345. }
  346. </style>