intelligentLifeChart.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  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 @click="switchToNormal()" class="primary" src="../../static/makedetail/primary.png"></image>
  13. <image @click="newChar()" 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" v-if="!msg.isStartGenerating">
  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. <text>{{ msg.content }}</text>
  39. </template>
  40. </view>
  41. <view v-else class="ai-bubble-content">
  42. <text>OK!~小萌正在根据你的描述生成形象中</text><br>
  43. <div v-if="msg.isStartGenerating && msg.startGeneratingId == 0" class="btn-box">
  44. 点击查看
  45. ({{ countdown }}) s</div>
  46. <div v-else
  47. @click="goPage(`/pages/makedetail/makeImgDetail?id=${msg.startGeneratingId}`)"
  48. class="btn-box"> 点击查看
  49. </div>
  50. </view>
  51. </view>
  52. </template>
  53. </view>
  54. <view id="bottom-anchor"></view>
  55. <view v-if="error" class="chat-bubble ai error">{{ error }}</view>
  56. <image v-if="showToBottomBtn && keyboardHeight === 0" class="to-bottom-btn" @click="scrollToBottom"
  57. src="../../static/makedetail/toBottomBtn.png"></image>
  58. </template>
  59. <template v-else>
  60. <view class="chat-content-empty">
  61. <image src="../../static/makedetail/characterProfilePicture.png" mode="aspectFill"></image>
  62. <view class="chat-content-empty-title">嗨!我是创梦精灵</view>
  63. <view class="chat-content-empty-desc">与我聊聊你想要生成的角色吧!</view>
  64. </view>
  65. </template>
  66. </scroll-view>
  67. <!-- <view class="bom-reserveASeat"></view> -->
  68. <view class="bom-box" :style="{ bottom: 0 + 'px', height: `${190 + textareaHeight}rpx` }">
  69. <view class="bom-box-bg">
  70. <c-lottie ref="cLottieRef" :src='"/static/lottie/xiaomeng.json"' class="icon-img" height="108rpx"
  71. width="112rpx" :loop="true" :autoPlay="false"></c-lottie>
  72. </view>
  73. <!-- 底部输入区 -->
  74. <view class="input-bar">
  75. <!-- <image class="icon-img" src="../../static/makedetail/characterProfilePicture.png" mode="aspectFill">
  76. </image> -->
  77. <textarea :autoHeight="true" class="input-box" :adjust-position="false" v-model="question"
  78. :disabled="isLoading" placeholder="给我发送消息吧..." maxlength="400" @input="onTextareaInput"
  79. confirm-type="search" @confirm="onSend" rows="1"
  80. style="overflow-y:auto;max-height:176rpx;min-height:44rpx;" />
  81. <!-- <button class="send-btn" :disabled="isLoading || !question.trim()" @click="onSend">{{ isLoading ?
  82. '生成中...' :
  83. '发送' }}</button> -->
  84. <image v-if="isLoading" class="stop" src="../../static/makedetail/stop.png" mode=""
  85. @click="stopStreamAnswer" />
  86. <image v-else-if="!isLoading && !question.trim() && keyboardHeight === 0" class="keyboard"
  87. src="../../static/makedetail/keyboard.png" mode="" />
  88. <image v-else-if="!isLoading && question.trim() && keyboardHeight !== 0" class="send"
  89. src="../../static/makedetail/send.png" mode="" @click="onSend" />
  90. </view>
  91. <!-- 底部提示 -->
  92. <view class="footer-tip">内容由AI生成,禁用相关功能请联系管理员。</view>
  93. </view>
  94. </view>
  95. <DialogBox ref="DialogBox"></DialogBox>
  96. </view>
  97. </template>
  98. <script>
  99. import websocket from '@/common/websocket.js';
  100. export default {
  101. data() {
  102. return {
  103. question: '',
  104. answer: '',
  105. displayText: '',
  106. isComplete: false,
  107. isLoading: false,
  108. error: null,
  109. retryCount: 0,
  110. maxRetries: 3,
  111. timer: null,
  112. lastResponseTime: 0,
  113. timeout: 30000,
  114. typingSpeed: 50,
  115. messages: [],
  116. lastAiIndex: -1,
  117. keyboardHeight: 0,
  118. statusBarHeight: 0,
  119. windowBottom: 0,
  120. toView: '',
  121. showToBottomBtn: false,
  122. textareaHeight: 0,
  123. isConnected: false,
  124. isStartGenerating: false,
  125. countdown: 0,
  126. };
  127. },
  128. methods: {
  129. scrollToBottom() {
  130. this.toView = '';
  131. this.$nextTick(() => {
  132. this.toView = 'bottom-anchor';
  133. });
  134. },
  135. async initWebSocket() {
  136. if (this.isConnected) return;
  137. try {
  138. await websocket.connect('wss://e.zhichao.art/Gapi/Work/streamAnswer', {
  139. uuid: getApp().globalData.uuid
  140. });
  141. this.isConnected = true;
  142. // 设置消息处理回调
  143. websocket.onMessage((data) => {
  144. console.log("data:", data);
  145. if (this.isLoading) {
  146. if (data.includes('[DONE]')) {
  147. this.completeAnswer();
  148. } else if (data == 'PING' || data == 'PONG') {
  149. // 心跳消息 忽略
  150. } else if (data.includes('ID:')) {
  151. // 提取ID
  152. const aiMsg = this.messages[this.lastAiIndex];
  153. if (aiMsg) {
  154. aiMsg.startGeneratingId = data.split(':')[1];
  155. }
  156. } else if (data.includes('OKOKOK')) {
  157. const aiMsg = this.messages[this.lastAiIndex];
  158. if (aiMsg) {
  159. aiMsg.isStartGenerating = true;
  160. }
  161. this.isStartGenerating = true;
  162. this.countdownFun(20);
  163. } else {
  164. const aiMsg = this.messages[this.lastAiIndex];
  165. if (aiMsg) {
  166. aiMsg.isStartGenerating = false;
  167. this.displayText += data;
  168. this.answer += data;
  169. aiMsg.content += data;
  170. this.retryCount = 0;
  171. this.lastResponseTime = Date.now();
  172. this.scrollToBottom();
  173. }
  174. }
  175. }
  176. });
  177. // 设置错误处理回调
  178. websocket.onError((error) => {
  179. console.error('WebSocket错误:', error);
  180. this.isConnected = false;
  181. this.handleError(error);
  182. });
  183. // 设置关闭处理回调
  184. websocket.onClose(() => {
  185. console.log('WebSocket已关闭');
  186. this.isConnected = false;
  187. this.isLoading = false;
  188. });
  189. } catch (error) {
  190. console.error('WebSocket初始化失败:', error);
  191. this.isConnected = false;
  192. this.handleError(error);
  193. }
  194. },
  195. async startStreamAnswer(content) {
  196. if (!content.trim()) {
  197. uni.showToast({
  198. title: '请输入问题',
  199. icon: 'none'
  200. });
  201. return;
  202. }
  203. // 检查连接状态,如果断开则重连
  204. if (!this.isConnected) {
  205. try {
  206. await this.initWebSocket();
  207. } catch (error) {
  208. console.error('重连失败:', error);
  209. uni.showToast({
  210. title: '连接已断开,请重试',
  211. icon: 'none'
  212. });
  213. return;
  214. }
  215. }
  216. this.resetState();
  217. this.messages.push({ role: 'user', content });
  218. try {
  219. this.isLoading = true;
  220. let aiMsg = {
  221. role: 'ai',
  222. content: '',
  223. isStartGenerating: false,
  224. startGeneratingId: 0
  225. };
  226. this.messages.push(aiMsg);
  227. this.lastAiIndex = this.messages.length - 1;
  228. // 发送消息
  229. websocket.send(content);
  230. } catch (error) {
  231. console.error('发送消息失败:', error);
  232. this.handleError(error);
  233. }
  234. },
  235. onSend() {
  236. if (!this.question.trim() || this.isLoading) return;
  237. this.startStreamAnswer(this.question);
  238. this.question = '';
  239. },
  240. resetState() {
  241. this.answer = '';
  242. this.displayText = '';
  243. this.isComplete = false;
  244. this.error = null;
  245. this.retryCount = 0;
  246. this.lastResponseTime = Date.now();
  247. },
  248. handleError(error) {
  249. if (this.retryCount < this.maxRetries) {
  250. this.retryCount++;
  251. this.retryRequest();
  252. } else {
  253. this.error = '请求失败,请稍后重试';
  254. this.isLoading = false;
  255. uni.showToast({
  256. title: this.error,
  257. icon: 'none'
  258. });
  259. }
  260. },
  261. retryRequest() {
  262. uni.showToast({
  263. title: `正在重试 (${this.retryCount}/${this.maxRetries})`,
  264. icon: 'none'
  265. });
  266. setTimeout(() => {
  267. this.startStreamAnswer(this.question);
  268. }, 1000 * this.retryCount);
  269. },
  270. completeAnswer() {
  271. this.isComplete = true;
  272. this.isLoading = false;
  273. },
  274. stopStreamAnswer() {
  275. if (this.timer) {
  276. clearTimeout(this.timer);
  277. this.timer = null;
  278. }
  279. this.isLoading = false;
  280. },
  281. checkTimeout() {
  282. if (Date.now() - this.lastResponseTime > this.timeout) {
  283. this.handleError(new Error('请求超时'));
  284. }
  285. },
  286. goBack() {
  287. uni.navigateBack({
  288. delta: 1
  289. });
  290. },
  291. onChatScroll(e) {
  292. const threshold = 600; // 离底部100px以内不显示按钮
  293. const {
  294. scrollHeight,
  295. scrollTop
  296. } = e.detail;
  297. const clientHeight = e.detail.clientHeight || e.detail.height || 0;
  298. if (scrollHeight - scrollTop - clientHeight > threshold) {
  299. this.showToBottomBtn = true;
  300. } else {
  301. this.showToBottomBtn = false;
  302. }
  303. },
  304. onTextareaInput(e) {
  305. console.log(e.detail);
  306. // 获取textarea的实际高度
  307. const query = uni.createSelectorQuery().in(this);
  308. query.select('.input-box').boundingClientRect(data => {
  309. if (data) {
  310. // 将px转换为rpx (假设设计稿是750rpx宽度)
  311. const height = (data.height * 750) / uni.getSystemInfoSync().windowWidth;
  312. // 减去基础高度90rpx,得到额外增加的高度
  313. this.textareaHeight = Math.max(0, height - 90);
  314. // 滚动到底部
  315. this.scrollToBottom();
  316. }
  317. }).exec();
  318. },
  319. retrieveHistoricalRecords() {
  320. uni.request({
  321. url: this.$apiHost + '/Work/streamAnswerLast',
  322. method: 'GET',
  323. header: {
  324. 'content-type': 'application/json',
  325. 'sign': getApp().globalData.headerSign
  326. },
  327. data: {
  328. uuid: getApp().globalData.uuid,
  329. task_type: 2
  330. },
  331. success: (res) => {
  332. console.log("获取历史记录:", res.data.list);
  333. if (res.data.success === "yes" && res.data.list) {
  334. this.messages = res.data.list
  335. if (res && res.data && res.data.list && res.data.list.length > 0) {
  336. this.showToBottomBtn = true
  337. }
  338. } else {
  339. this.messages = []
  340. }
  341. },
  342. fail: (err) => {
  343. console.log('获取历史记录失败', err);
  344. uni.showToast({
  345. title: '获取历史记录失败',
  346. icon: 'none'
  347. });
  348. }
  349. })
  350. },
  351. // 重新计算元素高度
  352. recalculateHeights() {
  353. // 重新计算textarea高度
  354. const query = uni.createSelectorQuery().in(this);
  355. query.select('.input-box').boundingClientRect(data => {
  356. if (data) {
  357. const height = (data.height * 750) / uni.getSystemInfoSync().windowWidth;
  358. this.textareaHeight = Math.max(0, height - 90);
  359. }
  360. }).exec();
  361. },
  362. countdownFun(n) {
  363. if (this.timer) {
  364. clearInterval(this.timer);
  365. }
  366. this.countdown = n;
  367. // 倒计时
  368. this.timer = setInterval(() => {
  369. this.countdown--;
  370. if (this.countdown <= 0) {
  371. clearInterval(this.timer);
  372. }
  373. }, 1000);
  374. },
  375. goPage(page) {
  376. uni.navigateTo({
  377. url: page,
  378. });
  379. },
  380. newChar() {
  381. this.$refs["DialogBox"]
  382. .confirm({
  383. title: "是否创建新对话",
  384. content: "立即结束当前对话内容,开启新的对话?",
  385. DialogType: "inquiry",
  386. btn1: "取消",
  387. btn2: "确定",
  388. animation: 0,
  389. })
  390. .then(() => {
  391. websocket.send('clear');
  392. this.retrieveHistoricalRecords()
  393. });
  394. },
  395. switchToNormal() {
  396. this.$refs["DialogBox"]
  397. .confirm({
  398. title: "是否切回常规模式",
  399. content: "切换至普通常规模式进行创作?",
  400. DialogType: "inquiry",
  401. btn1: "取消",
  402. btn2: "确定",
  403. animation: 0,
  404. })
  405. .then(() => {
  406. this.goPage('/pages/makedetail/makeImgDetail')
  407. });
  408. }
  409. },
  410. async created() {
  411. await this.initWebSocket();
  412. this.retrieveHistoricalRecords();
  413. this.timer = setInterval(() => {
  414. if (this.isLoading) {
  415. this.checkTimeout();
  416. }
  417. }, 1000);
  418. uni.onKeyboardHeightChange(res => {
  419. this.keyboardHeight = res.height;
  420. if (res.height === 0) {
  421. this.$refs.cLottieRef.call('stop')
  422. } else {
  423. this.$refs.cLottieRef.call('play')
  424. }
  425. this.$nextTick(() => {
  426. this.recalculateHeights();
  427. });
  428. });
  429. const systemInfo = uni.getSystemInfoSync();
  430. this.statusBarHeight = systemInfo.statusBarHeight;
  431. this.windowBottom = systemInfo.safeAreaInsets ? systemInfo.safeAreaInsets.bottom : 0;
  432. },
  433. beforeDestroy() {
  434. websocket.close();
  435. this.stopStreamAnswer();
  436. }
  437. }
  438. </script>
  439. <style lang="scss">
  440. @import './intelligentLifeChart.scss';
  441. .to-bottom-btn {
  442. position: fixed;
  443. right: 50%;
  444. transform: translateX(50%);
  445. bottom: 20rpx;
  446. width: 60rpx;
  447. height: 60rpx;
  448. bottom: 220rpx;
  449. z-index: 999;
  450. opacity: .75;
  451. transition: opacity 0.2s;
  452. }
  453. .ai-bubble-row {
  454. display: flex;
  455. align-items: flex-start;
  456. }
  457. .ai-avatar {
  458. width: 60rpx;
  459. height: 60rpx;
  460. border-radius: 50%;
  461. margin-right: 20rpx;
  462. border: solid 2rpx rgb(238, 238, 238, .3);
  463. display: flex;
  464. align-items: flex-end;
  465. justify-content: center;
  466. image {
  467. width: 52rpx;
  468. height: 52rpx;
  469. }
  470. }
  471. .ai-bubble-content {
  472. flex: 1;
  473. }
  474. </style>tyle>