184 lines
5.5 KiB
JavaScript
184 lines
5.5 KiB
JavaScript
// 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' });
|
|
},
|
|
});
|