// pages/wiki/chat/index.js import request from '../../../utils/request'; Page({ data: { messages: [], inputValue: '', isTyping: false, scrollAnchor: '', _counter: 0, quotaRemaining: -1, quotaLimit: 0, _pendingPrefill: '', }, onLoad(options) { if (options && options.fromHistory === '1') { const channel = this.getOpenerEventChannel(); channel.on('historyData', (data) => { const msgs = [ { id: 'h1', role: 'user', content: data.question }, { id: 'h2', role: 'ai', content: this._cleanMd(data.answer) }, ]; this.setData({ messages: msgs, _counter: 2 }, () => this.scrollToBottom()); }); } else if (options && options.prefillQuestion) { // Only save the question; actual send waits for quota check in onShow this._pendingPrefill = decodeURIComponent(options.prefillQuestion); this.setData({ inputValue: this._pendingPrefill }); } }, onShow() { this._fetchQuota(); }, _fetchQuota() { request.get('/plant/chat/quota').then(res => { this.setData({ quotaRemaining: res.remaining, quotaLimit: res.limit, }); // Auto-send prefill question only after quota is confirmed if (this._pendingPrefill) { const q = this._pendingPrefill; this._pendingPrefill = ''; if (res.remaining > 0) { this.onSend(); } else { wx.showToast({ title: '今日问答次数已用完,明天再来吧', icon: 'none', duration: 2500 }); } } }).catch(() => {}); }, goToHistory() { wx.navigateTo({ url: '/pages/wiki/chat/history/index' }); }, onQuickAsk(e) { const query = e.currentTarget.dataset.q; this.setData({ inputValue: query }, () => this.onSend()); }, onInput(e) { this.setData({ inputValue: e.detail.value }); }, onSend() { const query = this.data.inputValue.trim(); if (!query || this.data.isTyping) return; // 额度检查 if (this.data.quotaRemaining === 0) { wx.showToast({ title: '今日问答次数已用完,明天再来吧', icon: 'none', duration: 2500 }); return; } const uid = 'u' + (++this.data._counter); const aid = 'a' + (++this.data._counter); const len = this.data.messages.length; this.setData({ [`messages[${len}]`]: { id: uid, role: 'user', content: query }, [`messages[${len + 1}]`]: { id: aid, role: 'ai', content: '' }, inputValue: '', isTyping: true, }, () => { this.scrollToBottom(); this._streamRequest(query, aid); }); }, _streamRequest(query, aiMsgId) { let fullText = ''; request.stream('/plant/chat/stream', { query }, { onChunk: (res) => { const text = this._decode(res.data); // Detect non-SSE JSON error (e.g. quota exceeded returns {code:7, msg:"..."}) if (!text.startsWith('data: ')) { try { const json = JSON.parse(text); if (json.code && json.code !== 200 && json.msg) { this._updateAiMsg(aiMsgId, '⚠️ ' + json.msg); this.setData({ isTyping: false }); this._fetchQuota(); return; } } catch (_) { /* not JSON, continue SSE parsing */ } } const lines = text.split('\n'); for (const line of lines) { if (!line.startsWith('data: ')) continue; const chunk = line.substring(6); if (chunk === '[DONE]') { this.setData({ isTyping: false }); return; } if (chunk.startsWith('[ERROR]')) { fullText += '\n⚠️ ' + (chunk.substring(7) || '服务异常'); this._updateAiMsg(aiMsgId, fullText); this.setData({ isTyping: false }); return; } fullText += chunk; this._updateAiMsg(aiMsgId, fullText); } this.scrollToBottom(); }, onDone: () => { this.setData({ isTyping: false }); this.scrollToBottom(); // Delay: backend saves history async (go func) after stream ends setTimeout(() => this._fetchQuota(), 800); }, onError: () => { this._updateAiMsg(aiMsgId, '网络连接失败,请稍后重试'); this.setData({ isTyping: false }); }, }); }, _updateAiMsg(id, content) { const idx = this.data.messages.findIndex(m => m.id === id); if (idx !== -1) { this.setData({ [`messages[${idx}].content`]: this._cleanMd(content) }); } }, // Strip residual markdown symbols for clean display _cleanMd(text) { return text .replace(/^#{1,6}\s*/gm, '') // ### headers .replace(/\*\*(.+?)\*\*/g, '【$1】') // **bold** → 【bold】 .replace(/\*(.+?)\*/g, '$1') // *italic* .replace(/^[\-\*]\s+/gm, '· ') // - list → · list .replace(/^\d+\.\s+/gm, (m) => m) // keep numbered lists .replace(/`([^`]+)`/g, '$1') // `code` .replace(/^---+$/gm, '————') // --- → ———— .replace(/\n{3,}/g, '\n\n'); // collapse blank lines }, _decode(buffer) { try { return new TextDecoder('utf-8').decode(new Uint8Array(buffer)); } catch (e) { const bytes = new Uint8Array(buffer); let s = ''; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); try { return decodeURIComponent(escape(s)); } catch (_) { return s; } } }, scrollToBottom() { this.setData({ scrollAnchor: 'scroll-bottom' }); }, });