From 0715a16d91c41450a0e9de15b73c5facd63de390 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 24 Apr 2026 16:48:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=99=90=E5=88=B6=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8D=95=E6=97=A5=E6=8F=90=E9=97=AE=E6=AC=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/wiki/chat/index.js | 106 +++++++++++++++++++++++-------------- pages/wiki/chat/index.wxml | 3 ++ pages/wiki/chat/index.wxss | 17 ++++++ utils/request.js | 54 ++++++++++++++++++- 4 files changed, 138 insertions(+), 42 deletions(-) diff --git a/pages/wiki/chat/index.js b/pages/wiki/chat/index.js index dab7c83..db3448b 100644 --- a/pages/wiki/chat/index.js +++ b/pages/wiki/chat/index.js @@ -1,4 +1,5 @@ // pages/wiki/chat/index.js +import request from '../../../utils/request'; Page({ data: { @@ -7,6 +8,8 @@ Page({ isTyping: false, scrollAnchor: '', _counter: 0, + quotaRemaining: -1, + quotaLimit: 0, }, onLoad(options) { @@ -25,6 +28,19 @@ Page({ } }, + onShow() { + this._fetchQuota(); + }, + + _fetchQuota() { + request.get('/plant/chat/quota').then(res => { + this.setData({ + quotaRemaining: res.remaining, + quotaLimit: res.limit, + }); + }).catch(() => {}); + }, + goToHistory() { wx.navigateTo({ url: '/pages/wiki/chat/history/index' }); }, @@ -42,11 +58,16 @@ Page({ 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; - // Push user msg + empty AI msg at once this.setData({ [`messages[${len}]`]: { id: uid, role: 'user', content: query }, [`messages[${len + 1}]`]: { id: aid, role: 'ai', content: '' }, @@ -59,53 +80,58 @@ Page({ }, _streamRequest(query, aiMsgId) { - const token = wx.getStorageSync('token'); - const baseUrl = 'http://192.168.0.184:8889'; - const url = `${baseUrl}/plant/chat/stream?query=${encodeURIComponent(query)}`; let fullText = ''; - const task = wx.request({ - url, - method: 'GET', - enableChunked: true, - header: { - 'Authorization': `Bearer ${token}`, - 'Accept': 'text/event-stream', - }, - success: () => { - this.setData({ isTyping: false }); + 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(); }, - fail: () => { + 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 }); }, }); - - task.onChunkReceived((res) => { - const text = this._decode(res.data); - 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(); - }); }, _updateAiMsg(id, content) { diff --git a/pages/wiki/chat/index.wxml b/pages/wiki/chat/index.wxml index 052cd17..9815b07 100644 --- a/pages/wiki/chat/index.wxml +++ b/pages/wiki/chat/index.wxml @@ -85,6 +85,9 @@ + + 今日剩余 {{quotaRemaining}}/{{quotaLimit}} 次 + diff --git a/pages/wiki/chat/index.wxss b/pages/wiki/chat/index.wxss index 6f744b4..13f5bb5 100644 --- a/pages/wiki/chat/index.wxss +++ b/pages/wiki/chat/index.wxss @@ -263,3 +263,20 @@ .safe-bottom { height: calc(14rpx + env(safe-area-inset-bottom)); } + +/* Quota Bar */ +.quota-bar { + text-align: center; + padding: 6rpx 0 2rpx; +} + +.quota-text { + font-size: 22rpx; + color: #90A4AE; + font-weight: 500; +} + +.quota-text.warn { + color: #EF4444; + font-weight: 600; +} diff --git a/utils/request.js b/utils/request.js index ec4f0e2..c085f83 100644 --- a/utils/request.js +++ b/utils/request.js @@ -266,12 +266,62 @@ class WxRequest { post(url, data = {}, header = {}) { return this.request({ url, method: 'POST', data, header }); } + + /** + * SSE stream request with chunked transfer. + * Reuses baseUrl + token interceptor. + * @param {string} url API path, e.g. '/plant/chat/stream' + * @param {Object} data Query params (GET) or body (POST) + * @param {Object} callbacks { onChunk, onDone, onError } + * @returns {Object} wx.request task (call task.abort() to cancel) + */ + stream(url, data = {}, callbacks = {}) { + const fullUrl = url.startsWith('http') + ? url + : this.baseUrl + url; + + // Build query string for GET + const qs = Object.keys(data) + .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(data[k])}`) + .join('&'); + const reqUrl = qs ? `${fullUrl}?${qs}` : fullUrl; + + // Resolve token via same logic as interceptor + let token = wx.getStorageSync('token'); + + const mergedHeader = { + ...this.header, + 'Accept': 'text/event-stream', + }; + if (token) { + mergedHeader['Authorization'] = `Bearer ${token}`; + } + + const task = wx.request({ + url: reqUrl, + method: 'GET', + enableChunked: true, + header: mergedHeader, + success: () => { + if (callbacks.onDone) callbacks.onDone(); + }, + fail: (err) => { + if (callbacks.onError) callbacks.onError(err); + }, + }); + + if (callbacks.onChunk) { + task.onChunkReceived(callbacks.onChunk); + } + + return task; + } } // Initialize with default instance const request = new WxRequest({ - baseUrl: 'http://192.168.0.184:8889', - //baseUrl: 'https://go.sundynix.cn/api', + //baseUrl: 'http://192.168.0.184:8889', + baseUrl: 'https://go.sundynix.cn/api', header: { 'Content-Type': 'application/json' }