| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- <template>
- <view class="deepseek-chat-container">
- <scroll-view class="chat-messages" scroll-y :scroll-into-view="scrollIntoView" v-show="pestMessage">
- <view class="message-content">
- <view class="message-input" style="padding:0 20rpx">{{ pestMessage }}</view>
- <view v-if="isloading" class="loading-hint">AI 正在分析中…</view>
- <view v-if="errorMsg" class="loading-hint error-hint">{{ errorMsg }}</view>
- <rich-text class="message-text markdown-output" :nodes="outputHtml"></rich-text>
- <view class="message-input footer">内容由大模型生成,仅供参考,相关风险需自行承担。</view>
- </view>
- <view id="chatBottomAnchor"></view>
- </scroll-view>
- <view v-if="!pestMessage" style="height:100%">
- </view>
- <!-- App 端 renderjs SSE 桥接:仅 App 渲染(H5/小程序走 sse.js,不触发 renderjs)。
- 用 v-if="isApp" 运行时门控,避免 H5 上 change:prop 随组件重渲染被反复触发。 -->
- <view v-if="isApp" :sseSignal="sseSignal" :change:sseSignal="sse.onSignalChange" style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0"></view>
- </view>
- </template>
- <script>
- import { createSSEConnection } from './sse.js';
- import { renderMarkdown } from './markdownRenderer.js';
- export default {
- name: 'DeepSeekChat',
- props: {
- pestMessage: {
- type: String,
- default: ''
- },
- },
- data() {
- return {
- isApp: typeof plus !== 'undefined', // 运行时判断是否 App:H5/小程序走 sse.js,不启用 renderjs
- text: '',
- sseConnection: null,
- sseUrl: '', // 当前请求地址(供 renderjs 失败时降级复用)
- sseSignal: null, // 与 renderjs 通信的信号(App 端用)
- result: '',
- thinking: false,
- activeNames: ['1'],
- thinkHtml: '',
- isloading: true,
- errorMsg: '', // 连接失败/超时时的提示文案
- mdParser: null,
- mdRawText: '', // 累积的原始 markdown 文本
- outputHtml: '', // renderMarkdown 渲染后的 HTML,交给 rich-text 展示
- scrollIntoView: '', // scroll-view 滚动到底部锚点用
- sseTimer: null, // 连接“一条数据都没收到”的超时兜底定时器
- };
- },
- watch: {
- pestMessage(newVal) {
- if (newVal) {
- this.connectSSE(newVal);
- } else {
- this.isloading = false;
- }
- }
- },
- methods: {
- resetStreamState() {
- this.mdRawText = '';
- this.outputHtml = '';
- this.text = '';
- this.errorMsg = '';
- this._thinkContent = '';
- this._isCollecting = false;
- this._receivedAny = false;
- },
- connectSSE(message) {
- this.clearSseTimer();
- // 关闭上一次 sse.js 连接;App 的 renderjs 旧 XHR 由 start() 内部 stop() 清理,
- // 这里不再调 disconnectSSE(),避免在 App 上多发一个 stop 信号导致 onSignalChange 多触发一次
- if (this.sseConnection) {
- this.sseConnection.close();
- this.sseConnection = null;
- }
- this.resetStreamState();
- this.isloading = true; // 进入“分析中”
- // message 含中文/逗号/冒号,必须编码后拼到 URL
- const sseUrl = `https://ai.nyzhwlw.com/analysis?message=${encodeURIComponent(message)}`;
- this.sseUrl = sseUrl;
- this._fallbackUsed = false;
- // 超时兜底:30s 内一条数据都没收到(含 <think>),判定为连接异常
- this.startTimeout();
- // App 走 renderjs(WebView 原生 XHR,逐字流式);H5/小程序走 sse.js
- if (this.isApp) {
- this.startViaRenderjs(sseUrl);
- } else {
- this.startViaSseJs(sseUrl);
- }
- },
- startTimeout() {
- this.sseTimer = setTimeout(() => {
- if (!this._receivedAny) {
- this.errorMsg = 'AI 分析超时,请稍后重试';
- this.isloading = false;
- this.disconnectSSE();
- }
- }, 30000);
- },
- // H5/小程序(及 App 降级):用 sse.js 连接
- startViaSseJs(url) {
- this.sseConnection = createSSEConnection(url);
- this.sseConnection.onMessage((data) => this.handleChunk(data));
- this.sseConnection.onError((err) => this.onStreamError(err));
- this.sseConnection.onClose(() => this.onStreamClose());
- },
- // App:通过 renderjs 桥接,WebView 用原生 XHR 接收流式数据
- startViaRenderjs(url) {
- this.sseSignal = { action: 'start', url };
- },
- stopRenderjs() {
- this.sseSignal = { action: 'stop' };
- },
- // 三端共用的数据处理:think 块过滤 + markdown 累积渲染
- handleChunk(data) {
- this._receivedAny = true;
- this.isloading = false;
- if (data === 'yfkj@2025' || data === '[DONE]') {
- return;
- }
- // 处理 <think>...</think> 思考块:模板不展示思考内容,只保留 </think> 之后的正文。
- // (原实现里 think 用到的 mdParser 未初始化会抛错,导致后续正文也不渲染,这里整体丢弃思考块)
- if (this._isCollecting || data.indexOf('<think>') > -1) {
- if (data.indexOf('<think>') > -1) {
- this._isCollecting = true;
- this.thinking = true;
- }
- this._thinkContent += data;
- const endIdx = this._thinkContent.indexOf('</think>');
- if (endIdx === -1) {
- return; // 仍在思考块内,等待结束标记
- }
- data = this._thinkContent.slice(endIdx + '</think>'.length);
- this._thinkContent = '';
- this._isCollecting = false;
- this.thinking = false;
- if (!data) {
- return;
- }
- }
- // 累积正文并渲染为 markdown,用 rich-text 展示
- this.mdRawText += data;
- this.outputHtml = renderMarkdown(this.mdRawText);
- this.scrollToBottom();
- },
- onStreamError(err) {
- console.error('SSE Error:', err);
- this.clearSseTimer();
- // 已经渲染出内容时不弹错误,避免服务端正常关闭被误判为失败
- if (!this.mdRawText) {
- this.errorMsg = 'AI 分析失败,请稍后重试';
- }
- this.isloading = false;
- this.disconnectSSE();
- },
- onStreamClose() {
- // 流正常结束,仅清理超时定时器
- this.clearSseTimer();
- },
- // ===== renderjs → 逻辑层 回调(仅 App 会触发)=====
- onSseData(data) {
- this.handleChunk(data);
- },
- onSseError(msg) {
- // renderjs 流式失败(常见于跨域 CORS):若还没收到任何数据,降级到 sse.js 缓冲通道,
- // 保证至少能出结果(只是没有逐字效果)
- if (!this._receivedAny && !this._fallbackUsed) {
- this._fallbackUsed = true;
- this.stopRenderjs();
- this.startViaSseJs(this.sseUrl);
- return;
- }
- this.onStreamError(new Error(msg || 'stream error'));
- },
- onSseClose() {
- this.onStreamClose();
- },
- disconnectSSE() {
- if (this.isApp) {
- this.stopRenderjs();
- }
- if (this.sseConnection) {
- this.sseConnection.close();
- this.sseConnection = null;
- }
- },
- clearSseTimer() {
- if (this.sseTimer) {
- clearTimeout(this.sseTimer);
- this.sseTimer = null;
- }
- },
- formatTime(date) {
- return date.toLocaleTimeString([], {
- hour: '2-digit',
- minute: '2-digit'
- });
- },
- scrollToBottom() {
- // 小程序没有 DOM,不能用 $refs.scrollTop;改用 scroll-view 的 scroll-into-view 滚到底部锚点
- this.scrollIntoView = '';
- this.$nextTick(() => {
- this.scrollIntoView = 'chatBottomAnchor';
- });
- }
- },
- mounted() {
- this.scrollToBottom();
- if (this.pestMessage) {
- this.connectSSE(this.pestMessage);
- } else {
- this.isloading = false;
- }
- },
- beforeDestroy() {
- // 组件卸载时断开连接并清理定时器(uni-app 默认 Vue2,用 beforeDestroy)
- this.clearSseTimer();
- this.disconnectSSE();
- }
- };
- </script>
- <script module="sse" lang="renderjs">
- // App 端:在 WebView 层用原生 XMLHttpRequest 接收 SSE 流式数据。
- // 通过 readyState=3 实时读取 responseText 增量并回传逻辑层,实现逐字输出。
- // (plus.net.XMLHttpRequest 会被缓冲、无法实时,故改用 renderjs。)
- export default {
- data() {
- return {
- xhr: null,
- buffer: '', // SSE 事件解析缓冲
- processed: 0 // responseText 已处理长度
- };
- },
- methods: {
- // 逻辑层改变 sseSignal 时触发(:change:sseSignal="sse.onSignalChange")
- onSignalChange(signal) {
- if (!signal) return;
- if (signal.action === 'start' && signal.url) {
- this.start(signal.url);
- } else if (signal.action === 'stop') {
- this.stop();
- }
- },
- start(url) {
- this.stop();
- this.buffer = '';
- this.processed = 0;
- const xhr = new XMLHttpRequest();
- xhr.open('GET', url, true);
- try { xhr.setRequestHeader('Accept', 'text/event-stream'); } catch (e) {}
- const self = this;
- xhr.onreadystatechange = function () {
- // readyState 3(LOADING) / 4(DONE) 都读取增量,3 时实时推送即为逐字流式
- if (xhr.readyState === 3 || xhr.readyState === 4) {
- const full = xhr.responseText || '';
- if (full.length > self.processed) {
- const delta = full.substring(self.processed);
- self.processed = full.length;
- self.parseAndSend(delta);
- }
- }
- if (xhr.readyState === 4) {
- self.$ownerInstance.callMethod('onSseClose');
- }
- };
- xhr.onerror = function () {
- self.$ownerInstance.callMethod('onSseError', 'xhr error');
- };
- xhr.send();
- this.xhr = xhr;
- },
- // SSE 协议解析:按空行(\n\n)切事件,提取 data: 字段后回传逻辑层
- parseAndSend(text) {
- this.buffer += text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
- let idx;
- while ((idx = this.buffer.indexOf('\n\n')) !== -1) {
- const raw = this.buffer.slice(0, idx);
- this.buffer = this.buffer.slice(idx + 2);
- const lines = raw.split('\n');
- let dataStr = '';
- for (let i = 0; i < lines.length; i++) {
- if (lines[i].indexOf('data:') === 0) {
- dataStr += lines[i].slice(5).replace(/^ /, '');
- }
- }
- if (dataStr !== '') {
- this.$ownerInstance.callMethod('onSseData', dataStr.replace(/\\n/g, '\n'));
- }
- }
- },
- stop() {
- if (this.xhr) {
- try { this.xhr.abort(); } catch (e) {}
- this.xhr = null;
- }
- }
- },
- beforeDestroy() {
- this.stop();
- }
- };
- </script>
- <style scoped lang="less">
- .el-collapse {
- background: #f6f6f6;
- color: #a4aab2;
- border-radius: 8rpx;
- }
- ::v-deep .el-collapse-item__header {
- padding: 0 16rpx;
- height: 72rpx;
- background: #f6f6f6 !important;
- border-radius: 8rpx;
- }
- ::v-deep .el-collapse-item__wrap {
- background: #f6f6f6 !important;
- color: #a4aab2 !important;
- border-radius: 8rpx;
- }
- ::v-deep .el-collapse-item__content {
- color: #a4aab2 !important;
- border-radius: 8rpx;
- }
- .deep-thinking {
- padding: 0 52rpx;
- border-radius: 8rpx;
- }
- .deepseek-chat-container {
- position: relative;
- display: flex;
- flex-direction: column;
- height: calc(100vh - 640rpx);
- // max-width: 1086px;
- margin: 0 auto;
- border-radius: 8rpx;
- // box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
- overflow: hidden;
- font-family: 'Segoe UI', Arial, sans-serif;
- // background-color: #f9f9f9;
- }
- .chat-messages {
- flex: 1;
- overflow-y: auto;
- height: 100%;
- // background-color: #ffffff;
- }
- .message {
- display: flex;
- margin-bottom: 24rpx;
- }
- .message-content {
- max-width: 100%;
- }
- .loading-hint {
- padding: 16rpx;
- color: #999;
- font-size: 26rpx;
- }
- .error-hint {
- color: #e64340;
- }
- .message-text {
- padding: 16rpx;
- border-radius: 18rpx;
- line-height: 1.4;
- word-wrap: break-word;
- padding-bottom: 60rpx;
- }
- .message.bot .message-text {
- background-color: #fff;
- border-top-left-radius: 8rpx;
- color: #5c5c5c;
- }
- .message.user .message-text {
- color: white;
- border-top-right-radius: 8rpx;
- }
- .message-time {
- font-size: 22rpx;
- color: #777;
- margin-top: 8rpx;
- text-align: right;
- }
- .message.user .message-time {
- text-align: left;
- }
- .typing-indicator {
- display: flex;
- padding: 12rpx 30rpx;
- background-color: #f6f6f6;
- border-radius: 18rpx;
- border-top-left-radius: 8rpx;
- }
- .typing-indicator span {
- width: 16rpx;
- height: 16rpx;
- margin: 0 2rpx;
- background-color: #999;
- border-radius: 50%;
- display: inline-block;
- animation: bounce 1.4s infinite ease-in-out both;
- }
- .typing-indicator span:nth-child(1) {
- animation-delay: -0.32s;
- }
- .typing-indicator span:nth-child(2) {
- animation-delay: -0.16s;
- }
- @keyframes bounce {
- 0%,
- 80%,
- 100% {
- transform: scale(0);
- }
- 40% {
- transform: scale(1);
- }
- }
- /* Markdown样式 - 使用深度选择器 */
- ::v-deep .markdown-output {
- /* 标题样式 */
- h1, h2, h3, h4, h5, h6 {
- margin-top: 32rpx;
- margin-bottom: 16rpx;
- font-weight: 600;
- line-height: 1.25;
- }
- h1 { font-size: 1.8em; }
- h2 { font-size: 1.5em; }
- h3 { font-size: 1.25em; }
- h4 { font-size: 1em; }
- h5 { font-size: 0.875em; }
- h6 { font-size: 0.85em; }
- /* 列表样式 */
- ul, ol {
- padding-left: 2em;
- margin: 16rpx 0;
- list-style-type: disc;
- }
- ol {
- list-style-type: decimal;
- }
- li {
- margin: 8rpx 0;
- line-height: 1.5;
- }
- /* 粗体斜体 */
- strong {
- font-weight: 600;
- }
- em {
- font-style: italic;
- }
- /* 代码块 */
- pre {
- background-color: #f6f8fa;
- padding: 32rex;
- border-radius: 12rpx;
- overflow-x: auto;
- margin: 16rpx 0;
- }
- code {
- background-color: #f6f8fa;
- padding: 4rpx 8rpx;
- border-radius: 6rpx;
- font-family: monospace;
- }
- /* 段落 */
- p {
- margin: 16rpx 0;
- line-height: 1.5;
- }
- }
- .message-input {
- padding: 20rpx 30rpx;
- border-radius: 8rpx;
- background-color: #f6f6f6;
- &.footer {
- position: fixed;
- bottom: 20rpx;
- left: 0;
- right: 0rpx;
- margin-top: 40rpx;
- font-size: 24rpx;
- color: #999;
- text-align: center;
- }
- }
- </style>
|