aI问答支持流式输出,效果非常好

This commit is contained in:
zhonghua.li
2026-03-31 21:04:53 +08:00
parent 60da0e2dd5
commit 22f951b514
163 changed files with 4646 additions and 8010 deletions

View File

@@ -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>