aI问答支持流式输出,效果非常好
This commit is contained in:
@@ -97,6 +97,10 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="qa-input-actions">
|
||||
<view class="qa-chip qa-chip-stream" :class="useStreamOutput ? 'qa-chip-stream--active' : ''" @click="toggleStreamOutput">
|
||||
<text class="qa-chip-stream-icon">∿</text>
|
||||
<text class="qa-chip-stream-text">流式</text>
|
||||
</view>
|
||||
<view class="qa-chip" @click="goBack">
|
||||
<uni-icons type="left" size="18" color="#666"></uni-icons>
|
||||
</view>
|
||||
@@ -219,8 +223,14 @@ export default {
|
||||
safeBottom: systemInfo.safeAreaInsets.bottom || 0,
|
||||
message: '',
|
||||
sending: false,
|
||||
useStreamOutput: false,
|
||||
chatMessages: [],
|
||||
sessionParentId: '',
|
||||
streamRequestTask: null,
|
||||
streamAiMsgIndex: -1,
|
||||
streamBuffer: '',
|
||||
streamTextDecoder: null,
|
||||
streamPendingBytes: [],
|
||||
|
||||
// AiQaMain 列表面板状态
|
||||
showMainList: false,
|
||||
@@ -440,12 +450,381 @@ export default {
|
||||
}
|
||||
},
|
||||
startNewSession() {
|
||||
this.abortStreamRequest();
|
||||
uni.hideLoading();
|
||||
this.chatMessages = [];
|
||||
this.sessionParentId = '';
|
||||
this.message = '';
|
||||
this.sending = false;
|
||||
},
|
||||
toggleStreamOutput() {
|
||||
this.useStreamOutput = !this.useStreamOutput;
|
||||
uni.showToast({
|
||||
title: this.useStreamOutput ? '已开启流式输出' : '已关闭流式输出',
|
||||
icon: 'none'
|
||||
});
|
||||
},
|
||||
getRequestHeaders() {
|
||||
const header = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
const token = uni.getStorageSync('backend-token') || '';
|
||||
if (token) {
|
||||
header.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const tenantId = uni.getStorageSync('backend-tenant-id') || '';
|
||||
if (tenantId) {
|
||||
header['X-Tenant-Id'] = tenantId;
|
||||
}
|
||||
const roleName = uni.getStorageSync('backend-role-name') || '';
|
||||
if (roleName) {
|
||||
header['X-Role-Name'] = roleName;
|
||||
}
|
||||
const scenario = uni.getStorageSync('backend-scenario') || '';
|
||||
if (scenario) {
|
||||
header['X-Scenario'] = scenario;
|
||||
}
|
||||
return header;
|
||||
},
|
||||
abortStreamRequest() {
|
||||
if (this.streamRequestTask && typeof this.streamRequestTask.abort === 'function') {
|
||||
try {
|
||||
this.streamRequestTask.abort();
|
||||
} catch (e) {
|
||||
// ignore abort error
|
||||
}
|
||||
}
|
||||
this.streamRequestTask = null;
|
||||
this.streamAiMsgIndex = -1;
|
||||
this.streamBuffer = '';
|
||||
this.streamTextDecoder = null;
|
||||
this.streamPendingBytes = [];
|
||||
},
|
||||
decodeUtf8Bytes(bytes) {
|
||||
if (!bytes || !bytes.length) return '';
|
||||
|
||||
// 优先使用 TextDecoder,支持分片流式解码(自动处理半包字符)
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
if (!this.streamTextDecoder) {
|
||||
this.streamTextDecoder = new TextDecoder('utf-8');
|
||||
}
|
||||
return this.streamTextDecoder.decode(bytes, { stream: true });
|
||||
}
|
||||
|
||||
// 兜底:无 TextDecoder 时尝试按 UTF-8 手动解码
|
||||
const pending = Array.isArray(this.streamPendingBytes) ? this.streamPendingBytes : [];
|
||||
const merged = pending.concat(Array.from(bytes));
|
||||
this.streamPendingBytes = [];
|
||||
|
||||
let i = 0;
|
||||
let out = '';
|
||||
while (i < merged.length) {
|
||||
const b1 = merged[i];
|
||||
if ((b1 & 0x80) === 0) {
|
||||
out += String.fromCharCode(b1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if ((b1 & 0xe0) === 0xc0) {
|
||||
if (i + 1 >= merged.length) break;
|
||||
const b2 = merged[i + 1];
|
||||
out += String.fromCharCode(((b1 & 0x1f) << 6) | (b2 & 0x3f));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ((b1 & 0xf0) === 0xe0) {
|
||||
if (i + 2 >= merged.length) break;
|
||||
const b2 = merged[i + 1];
|
||||
const b3 = merged[i + 2];
|
||||
out += String.fromCharCode(
|
||||
((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f)
|
||||
);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
if ((b1 & 0xf8) === 0xf0) {
|
||||
if (i + 3 >= merged.length) break;
|
||||
const b2 = merged[i + 1];
|
||||
const b3 = merged[i + 2];
|
||||
const b4 = merged[i + 3];
|
||||
const codePoint =
|
||||
((b1 & 0x07) << 18) |
|
||||
((b2 & 0x3f) << 12) |
|
||||
((b3 & 0x3f) << 6) |
|
||||
(b4 & 0x3f);
|
||||
out += String.fromCodePoint(codePoint);
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
// 非法字节,跳过
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (i < merged.length) {
|
||||
this.streamPendingBytes = merged.slice(i);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
decodeChunkData(raw) {
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
try {
|
||||
const u8 = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
||||
return this.decodeUtf8Bytes(u8);
|
||||
} catch (e) {
|
||||
return String(raw);
|
||||
}
|
||||
},
|
||||
appendStreamTextChunk(text) {
|
||||
if (text === undefined || text === null) return;
|
||||
const chunk = String(text);
|
||||
if (!chunk) return;
|
||||
const idx = this.streamAiMsgIndex;
|
||||
if (idx < 0 || !this.chatMessages[idx] || this.chatMessages[idx].role !== 'ai') return;
|
||||
this.chatMessages[idx].text += chunk;
|
||||
},
|
||||
handleStreamDataLine(line) {
|
||||
const raw = String(line || '').trim();
|
||||
if (!raw) return;
|
||||
if (raw === '[DONE]') return;
|
||||
|
||||
let textToAppend = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parentId = firstNonEmptyTrimmed(parsed.parentId, parsed.parent_id);
|
||||
if (parentId) {
|
||||
this.sessionParentId = String(parentId);
|
||||
}
|
||||
textToAppend =
|
||||
parsed.delta ??
|
||||
parsed.content ??
|
||||
parsed.answer ??
|
||||
parsed.text ??
|
||||
'';
|
||||
}
|
||||
} catch (e) {
|
||||
// 非 JSON 分片按纯文本处理
|
||||
}
|
||||
|
||||
if (textToAppend) {
|
||||
this.appendStreamTextChunk(this.normalizeText(textToAppend));
|
||||
}
|
||||
},
|
||||
flushStreamBuffer(force = false) {
|
||||
if (!this.streamBuffer) return;
|
||||
let data = this.streamBuffer;
|
||||
if (!force) {
|
||||
const lastLf = data.lastIndexOf('\n');
|
||||
if (lastLf < 0) return;
|
||||
this.streamBuffer = data.slice(lastLf + 1);
|
||||
data = data.slice(0, lastLf);
|
||||
} else {
|
||||
this.streamBuffer = '';
|
||||
}
|
||||
if (!data) return;
|
||||
|
||||
const lines = data.split(/\r?\n/);
|
||||
lines.forEach((line) => {
|
||||
const s = String(line || '').trim();
|
||||
if (!s) return;
|
||||
if (s.startsWith('data:')) {
|
||||
this.handleStreamDataLine(s.slice(5));
|
||||
return;
|
||||
}
|
||||
if (s.startsWith('event:') || s.startsWith('id:') || s.startsWith(':')) {
|
||||
return;
|
||||
}
|
||||
this.handleStreamDataLine(s);
|
||||
});
|
||||
},
|
||||
sendByNormal(payload) {
|
||||
const url = getApiUrl('/api/aiQaItem/askAI');
|
||||
post(url, payload)
|
||||
.then((res) => {
|
||||
uni.hideLoading();
|
||||
const body = res && res.data ? res.data : null;
|
||||
const success = body && body.success === true;
|
||||
|
||||
if (!success) {
|
||||
const msg = body?.message || '请求失败';
|
||||
this.chatMessages.push({ role: 'ai', text: msg });
|
||||
return;
|
||||
}
|
||||
|
||||
const aiData = body.data || {};
|
||||
const returnedParentId = aiData.parentId;
|
||||
if (returnedParentId !== undefined && returnedParentId !== null && String(returnedParentId).trim()) {
|
||||
this.sessionParentId = String(returnedParentId).trim();
|
||||
}
|
||||
|
||||
const rawAnswer =
|
||||
aiData.answer ??
|
||||
aiData.content ??
|
||||
aiData.response ??
|
||||
aiData.result ??
|
||||
aiData.message ??
|
||||
aiData;
|
||||
|
||||
this.chatMessages.push({ role: 'ai', text: this.normalizeText(rawAnswer) });
|
||||
})
|
||||
.catch((err) => {
|
||||
uni.hideLoading();
|
||||
console.error('[general_qa] askAI error:', err);
|
||||
this.chatMessages.push({ role: 'ai', text: err?.errMsg || '调用失败,请重试' });
|
||||
})
|
||||
.finally(() => {
|
||||
this.sending = false;
|
||||
});
|
||||
},
|
||||
async sendByStream(payload) {
|
||||
const url = getApiUrl('/api/aiQaItem/askAIStream');
|
||||
this.chatMessages.push({ role: 'ai', text: '' });
|
||||
this.streamAiMsgIndex = this.chatMessages.length - 1;
|
||||
this.streamBuffer = '';
|
||||
this.streamTextDecoder = null;
|
||||
this.streamPendingBytes = [];
|
||||
|
||||
// H5 环境:使用 fetch + ReadableStream 实现流式
|
||||
// #ifdef H5
|
||||
try {
|
||||
const headerObj = this.getRequestHeaders();
|
||||
const headers = new Headers();
|
||||
Object.keys(headerObj || {}).forEach((k) => {
|
||||
if (headerObj[k] !== undefined && headerObj[k] !== null) {
|
||||
headers.append(k, headerObj[k]);
|
||||
}
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const reader = res.body && res.body.getReader ? res.body.getReader() : null;
|
||||
if (!reader) {
|
||||
throw new Error('当前浏览器不支持流式读取');
|
||||
}
|
||||
|
||||
const pump = async () => {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
this.flushStreamBuffer(true);
|
||||
break;
|
||||
}
|
||||
const chunk = this.decodeUtf8Bytes(value);
|
||||
this.streamBuffer += chunk;
|
||||
this.flushStreamBuffer(false);
|
||||
}
|
||||
};
|
||||
|
||||
await pump();
|
||||
|
||||
const idx = this.streamAiMsgIndex;
|
||||
if (idx >= 0 && this.chatMessages[idx] && !String(this.chatMessages[idx].text || '').trim()) {
|
||||
this.chatMessages[idx].text = '未获取到流式内容';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[general_qa] askAIStream H5 error:', err);
|
||||
const idx = this.streamAiMsgIndex;
|
||||
if (idx >= 0 && this.chatMessages[idx]) {
|
||||
const raw = String(this.chatMessages[idx].text || '').trim();
|
||||
this.chatMessages[idx].text = raw || (err?.message || '调用失败,请重试');
|
||||
} else {
|
||||
this.chatMessages.push({ role: 'ai', text: err?.message || '调用失败,请重试' });
|
||||
}
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
this.sending = false;
|
||||
this.streamRequestTask = null;
|
||||
this.streamAiMsgIndex = -1;
|
||||
this.streamBuffer = '';
|
||||
this.streamTextDecoder = null;
|
||||
this.streamPendingBytes = [];
|
||||
}
|
||||
return;
|
||||
// #endif
|
||||
|
||||
// 非 H5:仍然使用 uni.request / wx.request + onChunkReceived
|
||||
const requestOptions = {
|
||||
url,
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
header: this.getRequestHeaders(),
|
||||
enableChunked: true,
|
||||
responseType: 'text',
|
||||
success: () => {
|
||||
this.flushStreamBuffer(true);
|
||||
const idx = this.streamAiMsgIndex;
|
||||
if (idx >= 0 && this.chatMessages[idx] && !String(this.chatMessages[idx].text || '').trim()) {
|
||||
this.chatMessages[idx].text = '未获取到流式内容';
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[general_qa] askAIStream error:', err);
|
||||
const idx = this.streamAiMsgIndex;
|
||||
if (idx >= 0 && this.chatMessages[idx]) {
|
||||
const raw = String(this.chatMessages[idx].text || '').trim();
|
||||
this.chatMessages[idx].text = raw || (err?.errMsg || '调用失败,请重试');
|
||||
} else {
|
||||
this.chatMessages.push({ role: 'ai', text: err?.errMsg || '调用失败,请重试' });
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
uni.hideLoading();
|
||||
this.sending = false;
|
||||
this.streamRequestTask = null;
|
||||
this.streamAiMsgIndex = -1;
|
||||
this.streamBuffer = '';
|
||||
this.streamTextDecoder = null;
|
||||
this.streamPendingBytes = [];
|
||||
}
|
||||
};
|
||||
|
||||
let task = null;
|
||||
// 微信小程序端优先使用原生 wx.request,确保 onChunkReceived 可用
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof wx !== 'undefined' && wx && typeof wx.request === 'function') {
|
||||
task = wx.request(requestOptions);
|
||||
} else {
|
||||
task = uni.request(requestOptions);
|
||||
}
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
task = uni.request(requestOptions);
|
||||
// #endif
|
||||
|
||||
this.streamRequestTask = task;
|
||||
if (task && typeof task.onChunkReceived === 'function') {
|
||||
task.onChunkReceived((res) => {
|
||||
try {
|
||||
const chunk = this.decodeChunkData(res.data);
|
||||
this.streamBuffer += chunk;
|
||||
this.flushStreamBuffer(false);
|
||||
} catch (e) {
|
||||
console.error('[general_qa] stream chunk parse error:', e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const idx = this.streamAiMsgIndex;
|
||||
uni.hideLoading();
|
||||
this.sending = false;
|
||||
this.streamRequestTask = null;
|
||||
this.streamAiMsgIndex = -1;
|
||||
this.streamBuffer = '';
|
||||
if (idx >= 0 && this.chatMessages[idx]) {
|
||||
this.chatMessages[idx].text = '当前运行环境不支持流式输出,请关闭流式后重试';
|
||||
} else {
|
||||
this.chatMessages.push({ role: 'ai', text: '当前运行环境不支持流式输出,请关闭流式后重试' });
|
||||
}
|
||||
}
|
||||
},
|
||||
copyText(text) {
|
||||
const content = (text || '').toString();
|
||||
if (!content.trim()) return;
|
||||
@@ -491,7 +870,6 @@ export default {
|
||||
// 该页面对应“通用问答”
|
||||
const questionType = 'general_qa';
|
||||
|
||||
const url = getApiUrl('/api/aiQaItem/askAI');
|
||||
const { loginAccount, userName } = getSubmitUserContext();
|
||||
const payload = {
|
||||
questionType,
|
||||
@@ -504,45 +882,11 @@ export default {
|
||||
payload.parentId = this.sessionParentId;
|
||||
}
|
||||
|
||||
post(url, payload)
|
||||
.then((res) => {
|
||||
uni.hideLoading();
|
||||
const body = res && res.data ? res.data : null;
|
||||
const success = body && body.success === true;
|
||||
|
||||
if (!success) {
|
||||
const msg = body?.message || '请求失败';
|
||||
this.chatMessages.push({ role: 'ai', text: msg });
|
||||
return;
|
||||
}
|
||||
|
||||
const aiData = body.data || {};
|
||||
|
||||
// 若后端返回 parentId,则保存并用于后续请求
|
||||
const returnedParentId = aiData.parentId;
|
||||
if (returnedParentId !== undefined && returnedParentId !== null && String(returnedParentId).trim()) {
|
||||
this.sessionParentId = String(returnedParentId).trim();
|
||||
}
|
||||
|
||||
// 兼容不同字段名:优先取常见答案字段,否则兜底 JSON
|
||||
const rawAnswer =
|
||||
aiData.answer ??
|
||||
aiData.content ??
|
||||
aiData.response ??
|
||||
aiData.result ??
|
||||
aiData.message ??
|
||||
aiData;
|
||||
|
||||
this.chatMessages.push({ role: 'ai', text: this.normalizeText(rawAnswer) });
|
||||
})
|
||||
.catch((err) => {
|
||||
uni.hideLoading();
|
||||
console.error('[general_qa] askAI error:', err);
|
||||
this.chatMessages.push({ role: 'ai', text: err?.errMsg || '调用失败,请重试' });
|
||||
})
|
||||
.finally(() => {
|
||||
this.sending = false;
|
||||
});
|
||||
if (this.useStreamOutput) {
|
||||
this.sendByStream(payload);
|
||||
} else {
|
||||
this.sendByNormal(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -877,4 +1221,28 @@ export default {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qa-chip-stream {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: #F1F4FA;
|
||||
}
|
||||
|
||||
.qa-chip-stream--active {
|
||||
background-color: #E7F0FF;
|
||||
}
|
||||
|
||||
.qa-chip-stream-icon {
|
||||
font-size: 24rpx;
|
||||
color: #2A68FF;
|
||||
line-height: 1;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.qa-chip-stream-text {
|
||||
font-size: 24rpx;
|
||||
color: #2A68FF;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user