aiChat.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  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 20rpx">{{ pestMessage }}</view>
  6. <view v-if="isloading" class="loading-hint">AI 正在分析中…</view>
  7. <view v-if="errorMsg" class="loading-hint error-hint">{{ errorMsg }}</view>
  8. <rich-text class="message-text markdown-output" :nodes="outputHtml"></rich-text>
  9. <view class="message-input footer">内容由大模型生成,仅供参考,相关风险需自行承担。</view>
  10. </view>
  11. <view id="chatBottomAnchor"></view>
  12. </scroll-view>
  13. <view v-if="!pestMessage" style="height:100%">
  14. </view>
  15. <!-- App 端 renderjs SSE 桥接:仅 App 渲染(H5/小程序走 sse.js,不触发 renderjs)。
  16. 用 v-if="isApp" 运行时门控,避免 H5 上 change:prop 随组件重渲染被反复触发。 -->
  17. <view v-if="isApp" :sseSignal="sseSignal" :change:sseSignal="sse.onSignalChange" style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0"></view>
  18. </view>
  19. </template>
  20. <script>
  21. import { createSSEConnection } from './sse.js';
  22. import { renderMarkdown } from './markdownRenderer.js';
  23. export default {
  24. name: 'DeepSeekChat',
  25. props: {
  26. pestMessage: {
  27. type: String,
  28. default: ''
  29. },
  30. },
  31. data() {
  32. return {
  33. isApp: typeof plus !== 'undefined', // 运行时判断是否 App:H5/小程序走 sse.js,不启用 renderjs
  34. text: '',
  35. sseConnection: null,
  36. sseUrl: '', // 当前请求地址(供 renderjs 失败时降级复用)
  37. sseSignal: null, // 与 renderjs 通信的信号(App 端用)
  38. result: '',
  39. thinking: false,
  40. activeNames: ['1'],
  41. thinkHtml: '',
  42. isloading: true,
  43. errorMsg: '', // 连接失败/超时时的提示文案
  44. mdParser: null,
  45. mdRawText: '', // 累积的原始 markdown 文本
  46. outputHtml: '', // renderMarkdown 渲染后的 HTML,交给 rich-text 展示
  47. scrollIntoView: '', // scroll-view 滚动到底部锚点用
  48. sseTimer: null, // 连接“一条数据都没收到”的超时兜底定时器
  49. };
  50. },
  51. watch: {
  52. pestMessage(newVal) {
  53. if (newVal) {
  54. this.connectSSE(newVal);
  55. } else {
  56. this.isloading = false;
  57. }
  58. }
  59. },
  60. methods: {
  61. resetStreamState() {
  62. this.mdRawText = '';
  63. this.outputHtml = '';
  64. this.text = '';
  65. this.errorMsg = '';
  66. this._thinkContent = '';
  67. this._isCollecting = false;
  68. this._receivedAny = false;
  69. },
  70. connectSSE(message) {
  71. this.clearSseTimer();
  72. // 关闭上一次 sse.js 连接;App 的 renderjs 旧 XHR 由 start() 内部 stop() 清理,
  73. // 这里不再调 disconnectSSE(),避免在 App 上多发一个 stop 信号导致 onSignalChange 多触发一次
  74. if (this.sseConnection) {
  75. this.sseConnection.close();
  76. this.sseConnection = null;
  77. }
  78. this.resetStreamState();
  79. this.isloading = true; // 进入“分析中”
  80. // message 含中文/逗号/冒号,必须编码后拼到 URL
  81. const sseUrl = `https://ai.nyzhwlw.com/analysis?message=${encodeURIComponent(message)}`;
  82. this.sseUrl = sseUrl;
  83. this._fallbackUsed = false;
  84. // 超时兜底:30s 内一条数据都没收到(含 <think>),判定为连接异常
  85. this.startTimeout();
  86. // App 走 renderjs(WebView 原生 XHR,逐字流式);H5/小程序走 sse.js
  87. if (this.isApp) {
  88. this.startViaRenderjs(sseUrl);
  89. } else {
  90. this.startViaSseJs(sseUrl);
  91. }
  92. },
  93. startTimeout() {
  94. this.sseTimer = setTimeout(() => {
  95. if (!this._receivedAny) {
  96. this.errorMsg = 'AI 分析超时,请稍后重试';
  97. this.isloading = false;
  98. this.disconnectSSE();
  99. }
  100. }, 30000);
  101. },
  102. // H5/小程序(及 App 降级):用 sse.js 连接
  103. startViaSseJs(url) {
  104. this.sseConnection = createSSEConnection(url);
  105. this.sseConnection.onMessage((data) => this.handleChunk(data));
  106. this.sseConnection.onError((err) => this.onStreamError(err));
  107. this.sseConnection.onClose(() => this.onStreamClose());
  108. },
  109. // App:通过 renderjs 桥接,WebView 用原生 XHR 接收流式数据
  110. startViaRenderjs(url) {
  111. this.sseSignal = { action: 'start', url };
  112. },
  113. stopRenderjs() {
  114. this.sseSignal = { action: 'stop' };
  115. },
  116. // 三端共用的数据处理:think 块过滤 + markdown 累积渲染
  117. handleChunk(data) {
  118. this._receivedAny = true;
  119. this.isloading = false;
  120. if (data === 'yfkj@2025' || data === '[DONE]') {
  121. return;
  122. }
  123. // 处理 <think>...</think> 思考块:模板不展示思考内容,只保留 </think> 之后的正文。
  124. // (原实现里 think 用到的 mdParser 未初始化会抛错,导致后续正文也不渲染,这里整体丢弃思考块)
  125. if (this._isCollecting || data.indexOf('<think>') > -1) {
  126. if (data.indexOf('<think>') > -1) {
  127. this._isCollecting = true;
  128. this.thinking = true;
  129. }
  130. this._thinkContent += data;
  131. const endIdx = this._thinkContent.indexOf('</think>');
  132. if (endIdx === -1) {
  133. return; // 仍在思考块内,等待结束标记
  134. }
  135. data = this._thinkContent.slice(endIdx + '</think>'.length);
  136. this._thinkContent = '';
  137. this._isCollecting = false;
  138. this.thinking = false;
  139. if (!data) {
  140. return;
  141. }
  142. }
  143. // 累积正文并渲染为 markdown,用 rich-text 展示
  144. this.mdRawText += data;
  145. this.outputHtml = renderMarkdown(this.mdRawText);
  146. this.scrollToBottom();
  147. },
  148. onStreamError(err) {
  149. console.error('SSE Error:', err);
  150. this.clearSseTimer();
  151. // 已经渲染出内容时不弹错误,避免服务端正常关闭被误判为失败
  152. if (!this.mdRawText) {
  153. this.errorMsg = 'AI 分析失败,请稍后重试';
  154. }
  155. this.isloading = false;
  156. this.disconnectSSE();
  157. },
  158. onStreamClose() {
  159. // 流正常结束,仅清理超时定时器
  160. this.clearSseTimer();
  161. },
  162. // ===== renderjs → 逻辑层 回调(仅 App 会触发)=====
  163. onSseData(data) {
  164. this.handleChunk(data);
  165. },
  166. onSseError(msg) {
  167. // renderjs 流式失败(常见于跨域 CORS):若还没收到任何数据,降级到 sse.js 缓冲通道,
  168. // 保证至少能出结果(只是没有逐字效果)
  169. if (!this._receivedAny && !this._fallbackUsed) {
  170. this._fallbackUsed = true;
  171. this.stopRenderjs();
  172. this.startViaSseJs(this.sseUrl);
  173. return;
  174. }
  175. this.onStreamError(new Error(msg || 'stream error'));
  176. },
  177. onSseClose() {
  178. this.onStreamClose();
  179. },
  180. disconnectSSE() {
  181. if (this.isApp) {
  182. this.stopRenderjs();
  183. }
  184. if (this.sseConnection) {
  185. this.sseConnection.close();
  186. this.sseConnection = null;
  187. }
  188. },
  189. clearSseTimer() {
  190. if (this.sseTimer) {
  191. clearTimeout(this.sseTimer);
  192. this.sseTimer = null;
  193. }
  194. },
  195. formatTime(date) {
  196. return date.toLocaleTimeString([], {
  197. hour: '2-digit',
  198. minute: '2-digit'
  199. });
  200. },
  201. scrollToBottom() {
  202. // 小程序没有 DOM,不能用 $refs.scrollTop;改用 scroll-view 的 scroll-into-view 滚到底部锚点
  203. this.scrollIntoView = '';
  204. this.$nextTick(() => {
  205. this.scrollIntoView = 'chatBottomAnchor';
  206. });
  207. }
  208. },
  209. mounted() {
  210. this.scrollToBottom();
  211. if (this.pestMessage) {
  212. this.connectSSE(this.pestMessage);
  213. } else {
  214. this.isloading = false;
  215. }
  216. },
  217. beforeDestroy() {
  218. // 组件卸载时断开连接并清理定时器(uni-app 默认 Vue2,用 beforeDestroy)
  219. this.clearSseTimer();
  220. this.disconnectSSE();
  221. }
  222. };
  223. </script>
  224. <script module="sse" lang="renderjs">
  225. // App 端:在 WebView 层用原生 XMLHttpRequest 接收 SSE 流式数据。
  226. // 通过 readyState=3 实时读取 responseText 增量并回传逻辑层,实现逐字输出。
  227. // (plus.net.XMLHttpRequest 会被缓冲、无法实时,故改用 renderjs。)
  228. export default {
  229. data() {
  230. return {
  231. xhr: null,
  232. buffer: '', // SSE 事件解析缓冲
  233. processed: 0 // responseText 已处理长度
  234. };
  235. },
  236. methods: {
  237. // 逻辑层改变 sseSignal 时触发(:change:sseSignal="sse.onSignalChange")
  238. onSignalChange(signal) {
  239. if (!signal) return;
  240. if (signal.action === 'start' && signal.url) {
  241. this.start(signal.url);
  242. } else if (signal.action === 'stop') {
  243. this.stop();
  244. }
  245. },
  246. start(url) {
  247. this.stop();
  248. this.buffer = '';
  249. this.processed = 0;
  250. const xhr = new XMLHttpRequest();
  251. xhr.open('GET', url, true);
  252. try { xhr.setRequestHeader('Accept', 'text/event-stream'); } catch (e) {}
  253. const self = this;
  254. xhr.onreadystatechange = function () {
  255. // readyState 3(LOADING) / 4(DONE) 都读取增量,3 时实时推送即为逐字流式
  256. if (xhr.readyState === 3 || xhr.readyState === 4) {
  257. const full = xhr.responseText || '';
  258. if (full.length > self.processed) {
  259. const delta = full.substring(self.processed);
  260. self.processed = full.length;
  261. self.parseAndSend(delta);
  262. }
  263. }
  264. if (xhr.readyState === 4) {
  265. self.$ownerInstance.callMethod('onSseClose');
  266. }
  267. };
  268. xhr.onerror = function () {
  269. self.$ownerInstance.callMethod('onSseError', 'xhr error');
  270. };
  271. xhr.send();
  272. this.xhr = xhr;
  273. },
  274. // SSE 协议解析:按空行(\n\n)切事件,提取 data: 字段后回传逻辑层
  275. parseAndSend(text) {
  276. this.buffer += text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  277. let idx;
  278. while ((idx = this.buffer.indexOf('\n\n')) !== -1) {
  279. const raw = this.buffer.slice(0, idx);
  280. this.buffer = this.buffer.slice(idx + 2);
  281. const lines = raw.split('\n');
  282. let dataStr = '';
  283. for (let i = 0; i < lines.length; i++) {
  284. if (lines[i].indexOf('data:') === 0) {
  285. dataStr += lines[i].slice(5).replace(/^ /, '');
  286. }
  287. }
  288. if (dataStr !== '') {
  289. this.$ownerInstance.callMethod('onSseData', dataStr.replace(/\\n/g, '\n'));
  290. }
  291. }
  292. },
  293. stop() {
  294. if (this.xhr) {
  295. try { this.xhr.abort(); } catch (e) {}
  296. this.xhr = null;
  297. }
  298. }
  299. },
  300. beforeDestroy() {
  301. this.stop();
  302. }
  303. };
  304. </script>
  305. <style scoped lang="less">
  306. .el-collapse {
  307. background: #f6f6f6;
  308. color: #a4aab2;
  309. border-radius: 8rpx;
  310. }
  311. ::v-deep .el-collapse-item__header {
  312. padding: 0 16rpx;
  313. height: 72rpx;
  314. background: #f6f6f6 !important;
  315. border-radius: 8rpx;
  316. }
  317. ::v-deep .el-collapse-item__wrap {
  318. background: #f6f6f6 !important;
  319. color: #a4aab2 !important;
  320. border-radius: 8rpx;
  321. }
  322. ::v-deep .el-collapse-item__content {
  323. color: #a4aab2 !important;
  324. border-radius: 8rpx;
  325. }
  326. .deep-thinking {
  327. padding: 0 52rpx;
  328. border-radius: 8rpx;
  329. }
  330. .deepseek-chat-container {
  331. position: relative;
  332. display: flex;
  333. flex-direction: column;
  334. height: calc(100vh - 640rpx);
  335. // max-width: 1086px;
  336. margin: 0 auto;
  337. border-radius: 8rpx;
  338. // box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  339. overflow: hidden;
  340. font-family: 'Segoe UI', Arial, sans-serif;
  341. // background-color: #f9f9f9;
  342. }
  343. .chat-messages {
  344. flex: 1;
  345. overflow-y: auto;
  346. height: 100%;
  347. // background-color: #ffffff;
  348. }
  349. .message {
  350. display: flex;
  351. margin-bottom: 24rpx;
  352. }
  353. .message-content {
  354. max-width: 100%;
  355. }
  356. .loading-hint {
  357. padding: 16rpx;
  358. color: #999;
  359. font-size: 26rpx;
  360. }
  361. .error-hint {
  362. color: #e64340;
  363. }
  364. .message-text {
  365. padding: 16rpx;
  366. border-radius: 18rpx;
  367. line-height: 1.4;
  368. word-wrap: break-word;
  369. padding-bottom: 60rpx;
  370. }
  371. .message.bot .message-text {
  372. background-color: #fff;
  373. border-top-left-radius: 8rpx;
  374. color: #5c5c5c;
  375. }
  376. .message.user .message-text {
  377. color: white;
  378. border-top-right-radius: 8rpx;
  379. }
  380. .message-time {
  381. font-size: 22rpx;
  382. color: #777;
  383. margin-top: 8rpx;
  384. text-align: right;
  385. }
  386. .message.user .message-time {
  387. text-align: left;
  388. }
  389. .typing-indicator {
  390. display: flex;
  391. padding: 12rpx 30rpx;
  392. background-color: #f6f6f6;
  393. border-radius: 18rpx;
  394. border-top-left-radius: 8rpx;
  395. }
  396. .typing-indicator span {
  397. width: 16rpx;
  398. height: 16rpx;
  399. margin: 0 2rpx;
  400. background-color: #999;
  401. border-radius: 50%;
  402. display: inline-block;
  403. animation: bounce 1.4s infinite ease-in-out both;
  404. }
  405. .typing-indicator span:nth-child(1) {
  406. animation-delay: -0.32s;
  407. }
  408. .typing-indicator span:nth-child(2) {
  409. animation-delay: -0.16s;
  410. }
  411. @keyframes bounce {
  412. 0%,
  413. 80%,
  414. 100% {
  415. transform: scale(0);
  416. }
  417. 40% {
  418. transform: scale(1);
  419. }
  420. }
  421. /* Markdown样式 - 使用深度选择器 */
  422. ::v-deep .markdown-output {
  423. /* 标题样式 */
  424. h1, h2, h3, h4, h5, h6 {
  425. margin-top: 32rpx;
  426. margin-bottom: 16rpx;
  427. font-weight: 600;
  428. line-height: 1.25;
  429. }
  430. h1 { font-size: 1.8em; }
  431. h2 { font-size: 1.5em; }
  432. h3 { font-size: 1.25em; }
  433. h4 { font-size: 1em; }
  434. h5 { font-size: 0.875em; }
  435. h6 { font-size: 0.85em; }
  436. /* 列表样式 */
  437. ul, ol {
  438. padding-left: 2em;
  439. margin: 16rpx 0;
  440. list-style-type: disc;
  441. }
  442. ol {
  443. list-style-type: decimal;
  444. }
  445. li {
  446. margin: 8rpx 0;
  447. line-height: 1.5;
  448. }
  449. /* 粗体斜体 */
  450. strong {
  451. font-weight: 600;
  452. }
  453. em {
  454. font-style: italic;
  455. }
  456. /* 代码块 */
  457. pre {
  458. background-color: #f6f8fa;
  459. padding: 32rex;
  460. border-radius: 12rpx;
  461. overflow-x: auto;
  462. margin: 16rpx 0;
  463. }
  464. code {
  465. background-color: #f6f8fa;
  466. padding: 4rpx 8rpx;
  467. border-radius: 6rpx;
  468. font-family: monospace;
  469. }
  470. /* 段落 */
  471. p {
  472. margin: 16rpx 0;
  473. line-height: 1.5;
  474. }
  475. }
  476. .message-input {
  477. padding: 20rpx 30rpx;
  478. border-radius: 8rpx;
  479. background-color: #f6f6f6;
  480. &.footer {
  481. position: fixed;
  482. bottom: 20rpx;
  483. left: 0;
  484. right: 0rpx;
  485. margin-top: 40rpx;
  486. font-size: 24rpx;
  487. color: #999;
  488. text-align: center;
  489. }
  490. }
  491. </style>