aiChat.vue 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. <template>
  2. <view class="deepseek-chat-container">
  3. <scroll-view class="chat-messages" scroll-y :scroll-into-view="scrollIntoView" v-show="pestMessage">
  4. <view class="message-content">
  5. <view class="message-input" style="padding:0">{{ pestMessage }}</view>
  6. <view v-if="isloading" class="loading-hint">AI 正在分析中…</view>
  7. <rich-text class="message-text markdown-output" :nodes="outputHtml"></rich-text>
  8. <view class="message-input footer">内容由大模型生成,仅供参考,相关风险需自行承担。</view>
  9. </view>
  10. <view id="chatBottomAnchor"></view>
  11. </scroll-view>
  12. <view v-if="!pestMessage" style="height:100%">
  13. </view>
  14. </view>
  15. </template>
  16. <script>
  17. import { createSSEConnection } from './sse.js';
  18. import { renderMarkdown } from './markdownRenderer.js';
  19. export default {
  20. name: 'DeepSeekChat',
  21. props: {
  22. pestMessage: {
  23. type: String,
  24. default: ''
  25. },
  26. },
  27. data() {
  28. return {
  29. text: '',
  30. sseConnection: null,
  31. result: '',
  32. thinking: false,
  33. activeNames: ['1'],
  34. thinkHtml: '',
  35. isloading: true,
  36. mdParser: null,
  37. mdRawText: '', // 累积的原始 markdown 文本
  38. outputHtml: '', // renderMarkdown 渲染后的 HTML,交给 rich-text 展示
  39. scrollIntoView: '', // scroll-view 滚动到底部锚点用
  40. };
  41. },
  42. watch: {
  43. text: {
  44. handler() {
  45. this.$nextTick(() => {
  46. this.scrollToBottom();
  47. });
  48. },
  49. deep: true
  50. },
  51. pestMessage(newVal) {
  52. if (newVal) {
  53. this.connectSSE(newVal);
  54. } else {
  55. this.isloading = false;
  56. }
  57. }
  58. },
  59. methods: {
  60. initParser() {
  61. this.mdRawText = '';
  62. this.outputHtml = '';
  63. },
  64. connectSSE(message) {
  65. this.initParser();
  66. this.text = '';
  67. this.thinkHtml = '';
  68. let thinkContent = '';
  69. let isCollecting = false;
  70. const sseUrl = `https://ai.nyzhwlw.com/analysis?message=${message}`;
  71. this.sseConnection = createSSEConnection(sseUrl);
  72. this.sseConnection.onMessage((data) => {
  73. this.isloading = false;
  74. if (data === 'yfkj@2025' || data === '[DONE]') {
  75. return;
  76. }
  77. // 处理 <think>...</think> 思考块:模板里不展示思考内容,
  78. // 且原代码用到的 mdParser 未初始化(为 null)会抛错,导致 isCollecting 一直为 true、
  79. // 后续正文也再不会渲染。这里整体丢弃思考块,只保留 </think> 之后可能跟着的正文。
  80. if (isCollecting || data.indexOf('<think>') > -1) {
  81. if (data.indexOf('<think>') > -1) {
  82. isCollecting = true;
  83. this.thinking = true;
  84. }
  85. thinkContent += data;
  86. const endIdx = thinkContent.indexOf('</think>');
  87. if (endIdx === -1) {
  88. return; // 仍在思考块内,等待结束标记
  89. }
  90. // 思考块结束,取出其后的正文继续走正常渲染
  91. data = thinkContent.slice(endIdx + '</think>'.length);
  92. thinkContent = '';
  93. isCollecting = false;
  94. this.thinking = false;
  95. if (!data) {
  96. return;
  97. }
  98. }
  99. // 累积正文并渲染为 markdown,用 rich-text 展示
  100. this.mdRawText += data;
  101. this.outputHtml = renderMarkdown(this.mdRawText);
  102. this.scrollToBottom();
  103. });
  104. this.sseConnection.onError((error) => {
  105. console.error('SSE Error:', error);
  106. this.disconnectSSE();
  107. });
  108. },
  109. collapseHtml() {
  110. let result = '';
  111. return result;
  112. },
  113. async renderHtml(result) {
  114. this.text = result;
  115. // 触发浏览器重绘
  116. await new Promise((resolve) => setTimeout(resolve, 20));
  117. },
  118. disconnectSSE() {
  119. if (this.sseConnection) {
  120. this.sseConnection.close();
  121. this.sseConnection = null;
  122. this.isloading = false;
  123. }
  124. },
  125. formatTime(date) {
  126. return date.toLocaleTimeString([], {
  127. hour: '2-digit',
  128. minute: '2-digit'
  129. });
  130. },
  131. scrollToBottom() {
  132. // 小程序没有 DOM,不能用 $refs.scrollTop;改用 scroll-view 的 scroll-into-view 滚到底部锚点
  133. this.scrollIntoView = '';
  134. this.$nextTick(() => {
  135. this.scrollIntoView = 'chatBottomAnchor';
  136. });
  137. }
  138. },
  139. mounted() {
  140. this.scrollToBottom();
  141. if (this.pestMessage) {
  142. this.connectSSE(this.pestMessage);
  143. } else {
  144. this.isloading = false;
  145. }
  146. },
  147. beforeDestroy() {
  148. // 组件卸载时断开连接(uni-app 默认 Vue2,用 beforeDestroy)
  149. this.disconnectSSE();
  150. }
  151. };
  152. </script>
  153. <style scoped lang="less">
  154. .el-collapse {
  155. background: #f6f6f6;
  156. color: #a4aab2;
  157. border-radius: 8rpx;
  158. }
  159. ::v-deep .el-collapse-item__header {
  160. padding: 0 16rpx;
  161. height: 72rpx;
  162. background: #f6f6f6 !important;
  163. border-radius: 8rpx;
  164. }
  165. ::v-deep .el-collapse-item__wrap {
  166. background: #f6f6f6 !important;
  167. color: #a4aab2 !important;
  168. border-radius: 8rpx;
  169. }
  170. ::v-deep .el-collapse-item__content {
  171. color: #a4aab2 !important;
  172. border-radius: 8rpx;
  173. }
  174. .deep-thinking {
  175. padding: 0 52rpx;
  176. border-radius: 8rpx;
  177. }
  178. .deepseek-chat-container {
  179. position: relative;
  180. display: flex;
  181. flex-direction: column;
  182. height: calc(100vh - 640rpx);
  183. // max-width: 1086px;
  184. margin: 0 auto;
  185. border-radius: 8rpx;
  186. // box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  187. overflow: hidden;
  188. font-family: 'Segoe UI', Arial, sans-serif;
  189. // background-color: #f9f9f9;
  190. }
  191. .chat-messages {
  192. flex: 1;
  193. overflow-y: auto;
  194. height: 100%;
  195. // background-color: #ffffff;
  196. }
  197. .message {
  198. display: flex;
  199. margin-bottom: 24rpx;
  200. }
  201. .message-content {
  202. max-width: 100%;
  203. }
  204. .loading-hint {
  205. padding: 16rpx;
  206. color: #999;
  207. font-size: 26rpx;
  208. }
  209. .message-text {
  210. padding: 16rpx;
  211. border-radius: 18rpx;
  212. line-height: 1.4;
  213. word-wrap: break-word;
  214. padding-bottom: 60rpx;
  215. }
  216. .message.bot .message-text {
  217. background-color: #fff;
  218. border-top-left-radius: 8rpx;
  219. color: #5c5c5c;
  220. }
  221. .message.user .message-text {
  222. color: white;
  223. border-top-right-radius: 8rpx;
  224. }
  225. .message-time {
  226. font-size: 22rpx;
  227. color: #777;
  228. margin-top: 8rpx;
  229. text-align: right;
  230. }
  231. .message.user .message-time {
  232. text-align: left;
  233. }
  234. .typing-indicator {
  235. display: flex;
  236. padding: 12rpx 30rpx;
  237. background-color: #f6f6f6;
  238. border-radius: 18rpx;
  239. border-top-left-radius: 8rpx;
  240. }
  241. .typing-indicator span {
  242. width: 16rpx;
  243. height: 16rpx;
  244. margin: 0 2rpx;
  245. background-color: #999;
  246. border-radius: 50%;
  247. display: inline-block;
  248. animation: bounce 1.4s infinite ease-in-out both;
  249. }
  250. .typing-indicator span:nth-child(1) {
  251. animation-delay: -0.32s;
  252. }
  253. .typing-indicator span:nth-child(2) {
  254. animation-delay: -0.16s;
  255. }
  256. @keyframes bounce {
  257. 0%,
  258. 80%,
  259. 100% {
  260. transform: scale(0);
  261. }
  262. 40% {
  263. transform: scale(1);
  264. }
  265. }
  266. /* Markdown样式 - 使用深度选择器 */
  267. ::v-deep .markdown-output {
  268. /* 标题样式 */
  269. h1, h2, h3, h4, h5, h6 {
  270. margin-top: 32rpx;
  271. margin-bottom: 16rpx;
  272. font-weight: 600;
  273. line-height: 1.25;
  274. }
  275. h1 { font-size: 2em; }
  276. h2 { font-size: 1.5em; }
  277. h3 { font-size: 1.25em; }
  278. h4 { font-size: 1em; }
  279. h5 { font-size: 0.875em; }
  280. h6 { font-size: 0.85em; }
  281. /* 列表样式 */
  282. ul, ol {
  283. padding-left: 2em;
  284. margin: 16rpx 0;
  285. list-style-type: disc;
  286. }
  287. ol {
  288. list-style-type: decimal;
  289. }
  290. li {
  291. margin: 8rpx 0;
  292. line-height: 1.5;
  293. }
  294. /* 粗体斜体 */
  295. strong {
  296. font-weight: 600;
  297. }
  298. em {
  299. font-style: italic;
  300. }
  301. /* 代码块 */
  302. pre {
  303. background-color: #f6f8fa;
  304. padding: 32rex;
  305. border-radius: 12rpx;
  306. overflow-x: auto;
  307. margin: 16rpx 0;
  308. }
  309. code {
  310. background-color: #f6f8fa;
  311. padding: 4rpx 8rpx;
  312. border-radius: 6rpx;
  313. font-family: monospace;
  314. }
  315. /* 段落 */
  316. p {
  317. margin: 16rpx 0;
  318. line-height: 1.5;
  319. }
  320. }
  321. .message-input {
  322. padding: 20rpx 30rpx;
  323. border-radius: 8rpx;
  324. background-color: #f6f6f6;
  325. &.footer {
  326. position: fixed;
  327. bottom: 20rpx;
  328. left: 0;
  329. right: 0rpx;
  330. margin-top: 40rpx;
  331. font-size: 24rpx;
  332. color: #999;
  333. text-align: center;
  334. }
  335. }
  336. </style>