dialogGeneration.vue 9.8 KB

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