Files
smartDriveEEUniApp/pages-subpackage/ai_qa/general_qa/general_qa.vue
2026-04-04 09:43:01 +08:00

1460 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="qa-page">
<!-- 顶部导航参考示例左侧菜单标题新对话右侧加号 -->
<view class="qa-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="qa-header-bar">
<view class="qa-header-left" @click="openMainList">
<view class="qa-header-menu-line"></view>
<view class="qa-header-menu-line qa-header-menu-line-short"></view>
</view>
<text class="qa-header-title">新对话</text>
<view class="qa-header-right" @click="startNewSession">
<text class="qa-header-plus"></text>
</view>
</view>
</view>
<!-- AiQaMain 列表面板 -->
<view v-if="showMainList" class="main-list-mask" @click="closeMainList"></view>
<view v-if="showMainList" class="main-list-drawer">
<view class="main-list-drawer-header">
<text class="main-list-drawer-title">对话列表</text>
<view class="main-list-drawer-close" @click.stop="closeMainList">
<text>关闭</text>
</view>
</view>
<scroll-view class="main-list-scroll" scroll-y="true">
<view v-if="mainListLoading" class="main-list-loading">加载中...</view>
<view v-else>
<view
v-for="g in groupedAiQaMainList"
:key="g.key"
class="main-list-group"
>
<view class="main-list-group-title">{{ g.label }}</view>
<view
v-for="item in g.items"
:key="getMainItemKey(item)"
class="main-list-item"
@click.stop="selectMainItem(item)"
>
<text class="main-list-item-text">{{ getMainItemTitle(item) }}</text>
</view>
</view>
<view v-if="!aiQaMainList || aiQaMainList.length === 0" class="main-list-empty">
暂无数据
</view>
</view>
</scroll-view>
</view>
<!-- 中间主体区域图标 + 提示文案 -->
<view class="qa-main" v-if="chatMessages.length === 0">
<view class="qa-main-icon-wrapper">
<text class="qa-main-icon">Q</text>
</view>
<text class="qa-main-text" selectable="true">今天有什么可以帮到你</text>
</view>
<!-- 对话内容 -->
<scroll-view v-else class="qa-chat" scroll-y="true">
<view class="qa-chat-inner">
<view
v-for="(m, i) in chatMessages"
:key="i"
class="qa-msg"
:class="m.role === 'user' ? 'qa-msg--user' : 'qa-msg--ai'"
>
<view class="qa-msg-actions" :class="m.role === 'ai' ? 'qa-msg-actions--left' : 'qa-msg-actions--right'">
<view class="qa-copy-btn" @click.stop="copyText(m.text)">
<text class="qa-copy-btn-icon"></text>
</view>
</view>
<!-- 用户消息仍按纯文本展示 -->
<text
v-if="m.role === 'user'"
class="qa-msg-text"
selectable="true"
>{{ m.text }}</text>
<!-- AI 消息支持 Markdown 渲染保留纯文本兜底 -->
<view
v-else
class="qa-msg-text qa-msg-text-markdown"
>
<rich-text
:nodes="renderMarkdownNodes(m.text)"
></rich-text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部输入区域 -->
<view class="qa-input-wrapper" :style="{ paddingBottom: safeBottom + 'px' }">
<view class="qa-input-row">
<view class="qa-input-box">
<textarea
class="qa-textarea"
v-model="message"
placeholder="发送消息或按住说话"
placeholder-class="qa-input-placeholder"
:auto-height="true"
:maxlength="-1"
></textarea>
</view>
<view class="qa-input-send" @click="onSend">
<uni-icons type="top" size="18" color="#fff"></uni-icons>
</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>
</view>
</view>
</view>
</template>
<script>
import { post, get } from '@/common/request.js'
import { getApiUrl } from '@/common/config.js'
import { store } from '@/common/store.js'
function firstNonEmptyTrimmed(...parts) {
for (const p of parts) {
if (p === undefined || p === null) continue;
const s = String(p).trim();
if (s) return s;
}
return '';
}
function getSubmitUserContext() {
try {
const login = uni.getStorageSync('backend-login-response') || {};
const ui = store.userInfo || {};
const loginAccount = firstNonEmptyTrimmed(login.userName, ui.username);
const userName = firstNonEmptyTrimmed(
login.realName,
login.name,
login.nickName,
ui.nickname,
login.email,
ui.email
);
return { loginAccount, userName };
} catch (e) {
return { loginAccount: '', userName: '' };
}
}
function getSystemInfo() {
try {
const info = uni.getSystemInfoSync();
return {
statusBarHeight: info.statusBarHeight || 20,
safeAreaInsets: info.safeAreaInsets || { bottom: 0 }
};
} catch (e) {
return {
statusBarHeight: 20,
safeAreaInsets: { bottom: 0 }
};
}
}
/**
* AiQaItem.answerText 可能是 JsonNode对象或字符串形式的 JSON。
* 这里尽量兼容后端返回的字段名,并返回“可展示”的答案内容(对象/字符串都允许,展示时再 normalizeText
*/
function extractAnswerFromAnswerText(answerText) {
if (answerText === undefined || answerText === null) return '';
// 字符串:尝试解析 JSON否则当作普通文本
if (typeof answerText === 'string') {
const s = answerText.trim();
if (!s) return '';
if (
(s.startsWith('{') && s.endsWith('}')) ||
(s.startsWith('[') && s.endsWith(']'))
) {
try {
const parsed = JSON.parse(s);
return extractAnswerFromAnswerText(parsed);
} catch (e) {
// ignore parse error
}
}
return s;
}
// 对象:优先取常见答案字段,否则原样返回
if (typeof answerText === 'object') {
return (
answerText.answer ??
answerText.content ??
answerText.response ??
answerText.result ??
answerText.message ??
answerText
);
}
return String(answerText);
}
/**
* 历史记录里有时会把“问题类型: xxx / 用户问题: xxx”拼在一起展示。
* 这里仅做展示清洗:去掉这些标签,保留最终问题内容。
*/
function stripQuestionTypePrefix(text) {
const raw = text === undefined || text === null ? '' : String(text);
let s = raw.trim();
if (!s) return '';
// 去掉“问题类型xxx”支持中英文冒号、空格/换行容忍)
s = s.replace(/^\s*问题类型\s*[:]\s*[^\r\n]*\s*/g, '');
// 去掉“用户问题xxx”中的“用户问题”标签如果存在
s = s.replace(/^\s*用户问题\s*[:]\s*/g, '');
// 有些内容可能是两行:问题类型 + 用户问题,处理完后再次 trim
return s.trim();
}
export default {
data() {
const systemInfo = getSystemInfo();
return {
statusBarHeight: systemInfo.statusBarHeight,
safeBottom: systemInfo.safeAreaInsets.bottom || 0,
message: '',
sending: false,
useStreamOutput: true,
chatMessages: [],
sessionParentId: '',
streamRequestTask: null,
streamAiMsgIndex: -1,
streamBuffer: '',
streamTextDecoder: null,
streamPendingBytes: [],
// AiQaMain 列表面板状态
showMainList: false,
mainListLoading: false,
aiQaMainList: []
};
},
computed: {
groupedAiQaMainList() {
const groups = [
{ key: 'today', label: '今天', items: [] },
{ key: 'yesterday', label: '昨天', items: [] },
{ key: 'seven', label: '7天内', items: [] },
{ key: 'thirty', label: '30天内', items: [] },
{ key: 'older', label: '更早', items: [] }
];
const MS_DAY = 24 * 60 * 60 * 1000;
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const parseDateToMs = (v) => {
if (v === undefined || v === null) return null;
const raw = String(v).trim();
if (!raw) return null;
// 数字时间戳(秒/毫秒兜底)
if (/^\d+$/.test(raw)) {
const n = Number(raw);
if (!n) return null;
// 10 位通常是秒
const ms = raw.length === 10 ? n * 1000 : n;
if (ms > 0) return ms;
}
// yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss
const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/);
if (m) {
const y = Number(m[1]);
const mo = Number(m[2]) - 1;
const d = Number(m[3]);
const hh = m[4] ? Number(m[4]) : 0;
const mi = m[5] ? Number(m[5]) : 0;
const ss = m[6] ? Number(m[6]) : 0;
return new Date(y, mo, d, hh, mi, ss).getTime();
}
const dt = new Date(raw);
const t = dt.getTime();
return Number.isNaN(t) ? null : t;
};
const list = Array.isArray(this.aiQaMainList) ? this.aiQaMainList : [];
list.forEach((item) => {
const timeMs =
parseDateToMs(item.createTime) ||
parseDateToMs(item.gmtCreate) ||
parseDateToMs(item.create_time) ||
parseDateToMs(item.time) ||
null;
if (!timeMs) return;
const diffDays = Math.floor((todayStart - timeMs) / MS_DAY);
const idx = diffDays === 0 ? 0 : diffDays === 1 ? 1 : diffDays <= 6 ? 2 : diffDays <= 29 ? 3 : 4;
groups[idx].items.push(item);
});
// 过滤空组,并保持顺序
return groups.filter((g) => g.items.length > 0);
}
},
methods: {
goBack() {
uni.navigateBack();
},
openMainList() {
this.showMainList = true;
this.fetchAiQaMainList(true);
},
closeMainList() {
this.showMainList = false;
},
async fetchAiQaMainList(force = false) {
if (this.mainListLoading) return;
if (!force && Array.isArray(this.aiQaMainList) && this.aiQaMainList.length > 0) return;
this.mainListLoading = true;
try {
const login = uni.getStorageSync('backend-login-response') || {};
const salesId = login.userId ? String(login.userId).trim() : '';
// 后端要求:登录账号传到 salesPhone用于匹配 AiQaMain.salesPhone
// 这里优先取 login.userNameuserName 为空再用手机号兜底。
const salesPhone = firstNonEmptyTrimmed(login.userName, login.phone);
// salesName 暂不参与过滤(避免误筛导致列表为空)
const salesName = '';
const url = getApiUrl('/api/aiQaMain/list');
const res = await get(url, {
current: 1,
size: 50,
salesId,
salesPhone,
salesName
});
const body = res && res.data ? res.data : null;
const success = body && body.success === true;
if (!success) {
const msg = body?.message || '请求失败';
uni.showToast({ title: msg, icon: 'none' });
this.aiQaMainList = [];
return;
}
this.aiQaMainList = body.data || [];
} catch (e) {
console.error('[general_qa] fetchAiQaMainList error:', e);
uni.showToast({ title: '加载失败', icon: 'none' });
this.aiQaMainList = [];
} finally {
this.mainListLoading = false;
}
},
getMainItemKey(item) {
const key = firstNonEmptyTrimmed(
item.id,
item.mainId,
item.parentId,
item.qaMainId,
item.questionId
);
return key || JSON.stringify(item);
},
getMainItemTitle(item) {
return firstNonEmptyTrimmed(
item.questionTitle,
item.title,
item.questionContent,
item.question,
item.content,
item.summary,
item.name,
item.id
);
},
async selectMainItem(item) {
const parentId = firstNonEmptyTrimmed(
item.id, // AiQaMain.id优先
item.parentId, // 兜底:如果后端把字段直接展开了
item.mainId,
item.qaMainId,
item.sessionParentId
);
uni.hideLoading();
this.mainListLoading = false;
this.showMainList = false;
this.chatMessages = [];
this.message = '';
this.sending = false;
this.sessionParentId = parentId ? String(parentId) : '';
if (!this.sessionParentId) return;
// 拉取该会话下的历史对话明细,并恢复到聊天框
try {
uni.showLoading({ title: '加载历史...', mask: true });
const url = getApiUrl('/api/aiQaItem/listByParentId');
const res = await get(url, { parentId: this.sessionParentId });
uni.hideLoading();
const body = res && res.data ? res.data : null;
const success = body && body.success === true;
if (!success) {
const msg = body?.message || '查询失败';
uni.showToast({ title: msg, icon: 'none' });
return;
}
const list = Array.isArray(body.data) ? body.data : [];
// 后端按 createTime desc 返回,聊天展示一般按时间正序
const ordered = list.slice().reverse();
const restored = [];
ordered.forEach((qItem) => {
const questionText = firstNonEmptyTrimmed(
qItem.questSrc,
qItem.quest_src,
qItem.questionContent,
qItem.question,
qItem.title
);
if (questionText) {
const cleaned = stripQuestionTypePrefix(this.normalizeText(questionText));
if (cleaned) {
restored.push({ role: 'user', text: cleaned });
}
}
const answerText = extractAnswerFromAnswerText(
qItem.answerText ?? qItem.answer_text
);
if (answerText !== undefined && answerText !== null && String(answerText).trim()) {
restored.push({ role: 'ai', text: this.normalizeText(answerText) });
}
});
this.chatMessages = restored;
} catch (e) {
uni.hideLoading();
console.error('[general_qa] listByParentId error:', e);
uni.showToast({ title: '加载失败,请重试', icon: 'none' });
}
},
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 ??
parsed.data ??
parsed.result ??
parsed.message ??
parsed.output ??
parsed.token ??
'';
// 若对象结构未知,至少回退到原始行,避免“有返回但前端无内容”
if ((textToAppend === undefined || textToAppend === null || String(textToAppend) === '') && raw) {
textToAppend = raw;
}
}
} 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, { timeout: 120000 })
.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;
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' });
},
fail: () => {
uni.showToast({ title: '复制失败', icon: 'none' });
}
});
},
normalizeText(value) {
if (value === undefined || value === null) return '';
if (typeof value === 'string') {
// 兼容后端把换行转义成 \\n 的情况
return value.replace(/\\n/g, '\n');
}
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value);
}
},
/**
* 非常轻量的 Markdown 转换,仅支持:
* - 换行 => <br/>
* - **加粗** => <strong>
* - *斜体* => <em>
* - 无序列表行 "- " => <ul><li>
* - 代码块 ``` => <pre><code>
* 复杂 Markdown 语法暂不完全覆盖,但足够保证结构清晰。
*/
parseInlineMarkdownToNodes(line) {
const text = String(line || '');
const nodes = [];
const tokenRe = /(\*\*[^*]+\*\*|`[^`\n]+`|\*[^*\n]+\*)/g;
let lastIndex = 0;
let match = null;
while ((match = tokenRe.exec(text)) !== null) {
const idx = match.index;
if (idx > lastIndex) {
nodes.push({ type: 'text', text: text.slice(lastIndex, idx) });
}
const token = match[0];
if (token.startsWith('**') && token.endsWith('**')) {
nodes.push({
name: 'strong',
attrs: { style: 'font-weight:600;' },
children: [{ type: 'text', text: token.slice(2, -2) }]
});
} else if (token.startsWith('`') && token.endsWith('`')) {
nodes.push({
name: 'span',
attrs: {
style: 'font-family:monospace;background:#f2f2f4;padding:1px 4px;border-radius:3px;'
},
children: [{ type: 'text', text: token.slice(1, -1) }]
});
} else if (token.startsWith('*') && token.endsWith('*')) {
nodes.push({
name: 'em',
attrs: { style: 'font-style:italic;' },
children: [{ type: 'text', text: token.slice(1, -1) }]
});
} else {
nodes.push({ type: 'text', text: token });
}
lastIndex = idx + token.length;
}
if (lastIndex < text.length) {
nodes.push({ type: 'text', text: text.slice(lastIndex) });
}
if (nodes.length === 0) {
nodes.push({ type: 'text', text: '' });
}
return nodes;
},
renderMarkdownNodes(text) {
try {
const raw = String(text || '');
const lines = raw.replace(/\r\n/g, '\n').split('\n');
const nodes = [];
let i = 0;
let inCode = false;
let codeLines = [];
while (i < lines.length) {
const line = lines[i];
// 代码块围栏
if (line.trim().startsWith('```')) {
if (!inCode) {
inCode = true;
codeLines = [];
} else {
inCode = false;
nodes.push({
name: 'div',
attrs: {
style: 'background:#f7f7f9;border-radius:6px;padding:8px 10px;font-family:monospace;white-space:pre-wrap;word-break:break-word;'
},
children: [{ type: 'text', text: codeLines.join('\n') }]
});
}
i += 1;
continue;
}
if (inCode) {
codeLines.push(line);
i += 1;
continue;
}
// 无序列表
if (/^\s*-\s+/.test(line)) {
const ulChildren = [];
while (i < lines.length && /^\s*-\s+/.test(lines[i])) {
const content = lines[i].replace(/^\s*-\s+/, '');
ulChildren.push({
name: 'li',
attrs: { style: 'margin:4px 0;' },
children: this.parseInlineMarkdownToNodes(content)
});
i += 1;
}
nodes.push({
name: 'ul',
attrs: { style: 'padding-left:18px;margin:6px 0;' },
children: ulChildren
});
continue;
}
// 空行
if (!line.trim()) {
nodes.push({ name: 'div', children: [{ type: 'text', text: ' ' }] });
i += 1;
continue;
}
// 普通段落
nodes.push({
name: 'div',
attrs: { style: 'line-height:1.6;margin:2px 0;word-break:break-word;' },
children: this.parseInlineMarkdownToNodes(line)
});
i += 1;
}
// 兜底:如果代码块未闭合,也照样展示
if (inCode && codeLines.length > 0) {
nodes.push({
name: 'div',
attrs: {
style: 'background:#f7f7f9;border-radius:6px;padding:8px 10px;font-family:monospace;white-space:pre-wrap;word-break:break-word;'
},
children: [{ type: 'text', text: codeLines.join('\n') }]
});
}
return nodes;
} catch (e) {
return [{ type: 'text', text: String(text || '') }];
}
},
onSend() {
const text = (this.message || '').trim();
if (!text) {
uni.showToast({ title: '请输入消息', icon: 'none' });
return;
}
if (this.sending) return;
this.sending = true;
// 角色消息先入栈,减少等待感
const userMsg = { role: 'user', text };
this.chatMessages.push(userMsg);
this.message = '';
uni.showLoading({ title: '生成中...', mask: true });
// 该页面对应“通用问答”
const questionType = 'general_qa';
const { loginAccount, userName } = getSubmitUserContext();
const payload = {
questionType,
questionContent: text,
loginAccount,
userName
};
// parentId第一次请求不传后端返回后再带上
if (this.sessionParentId) {
payload.parentId = this.sessionParentId;
}
if (this.useStreamOutput) {
this.sendByStream(payload);
} else {
this.sendByNormal(payload);
}
}
}
};
</script>
<style scoped>
.qa-page {
flex: 1;
min-height: 100vh;
background-color: #F5F5F5;
display: flex;
flex-direction: column;
}
.qa-header {
background-color: #FFFFFF;
border-bottom: 1rpx solid #EDEDED;
}
.qa-header-bar {
height: 88rpx;
padding: 0 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.qa-header-left {
width: 60rpx;
height: 60rpx;
justify-content: center;
align-items: flex-start;
display: flex;
flex-direction: column;
}
.qa-header-menu-line {
width: 26rpx;
height: 4rpx;
border-radius: 4rpx;
background-color: #333333;
margin-bottom: 6rpx;
}
.qa-header-menu-line-short {
width: 16rpx;
}
.qa-header-title {
flex: 1;
text-align: center;
font-size: 30rpx;
font-weight: 500;
color: #333333;
}
.qa-header-right {
width: 60rpx;
height: 60rpx;
align-items: center;
justify-content: center;
display: flex;
}
.qa-header-plus {
font-size: 38rpx;
color: #333333;
}
.qa-main {
flex: 1;
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
padding-bottom: 200rpx; /* 给底部固定输入框留空间 */
}
.qa-main-icon-wrapper {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background-color: #FFFFFF;
align-items: center;
justify-content: center;
display: flex;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.qa-main-icon {
font-size: 60rpx;
color: #2A68FF;
}
.qa-main-text {
font-size: 30rpx;
color: #333333;
}
.qa-chat {
flex: 1;
width: 100%;
padding-bottom: 200rpx; /* 给底部固定输入框留空间 */
}
.qa-chat-inner {
padding: 24rpx 24rpx 12rpx;
}
.qa-msg {
margin-bottom: 20rpx;
max-width: 88%;
position: relative;
}
.qa-msg-actions {
position: absolute;
top: 8rpx;
z-index: 2;
}
.qa-msg-actions--left {
left: 8rpx;
}
.qa-msg-actions--right {
right: 8rpx;
}
.qa-copy-btn {
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
background-color: #F4F6FA;
border: 1rpx solid #E5EAF3;
display: flex;
align-items: center;
justify-content: center;
}
.qa-copy-btn-icon {
font-size: 22rpx;
color: #8a8a8a;
line-height: 1;
}
.qa-msg--user {
margin-left: auto;
background-color: #EAF2FF;
border: 1rpx solid #D7E6FF;
}
.qa-msg--ai {
margin-right: auto;
background-color: #FFFFFF;
border: 1rpx solid #F0F0F0;
}
.qa-msg--user,
.qa-msg--ai {
padding: 18rpx 20rpx;
border-radius: 16rpx;
}
.qa-msg-text {
font-size: 26rpx;
color: #333333;
line-height: 1.6;
word-break: break-word;
user-select: text;
}
.qa-msg-text-markdown {
padding-top: 6rpx;
}
.md-root {
font-size: 26rpx;
line-height: 1.6;
color: #333333;
}
.md-root strong {
font-weight: 600;
}
.md-root em {
font-style: italic;
}
.md-root ul {
padding-left: 32rpx;
margin: 8rpx 0;
}
.md-root li {
margin: 4rpx 0;
}
.md-code-block {
background-color: #f7f7f9;
border-radius: 8rpx;
padding: 10rpx 14rpx;
font-family: monospace;
overflow-x: auto;
}
.md-inline-code {
font-family: monospace;
background-color: #f2f2f4;
padding: 2rpx 6rpx;
border-radius: 4rpx;
}
/* 复制按钮在角上绝对定位;气泡已有左右 padding再给正文加一点边距避免与 44rpx 按钮重叠 */
.qa-msg--ai .qa-msg-text {
padding-left: 40rpx;
}
.qa-msg--user .qa-msg-text {
padding-right: 40rpx;
}
.qa-input-wrapper {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
padding: 12rpx 20rpx 12rpx;
background-color: #FFFFFF;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04);
box-sizing: border-box;
}
/* AiQaMain 列表面板 */
.main-list-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.35);
z-index: 1999;
}
.main-list-drawer {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 560rpx;
background-color: #FFFFFF;
z-index: 2000;
border-right: 1rpx solid #F0F0F0;
display: flex;
flex-direction: column;
}
.main-list-drawer-header {
padding: 22rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1rpx solid #F0F0F0;
}
.main-list-drawer-title {
font-size: 32rpx;
font-weight: 600;
color: #333333;
}
.main-list-drawer-close {
font-size: 28rpx;
color: #666666;
}
.main-list-scroll {
flex: 1;
}
.main-list-loading,
.main-list-empty {
padding: 30rpx 24rpx;
font-size: 28rpx;
color: #888888;
}
.main-list-group {
padding: 18rpx 24rpx;
}
.main-list-group-title {
font-size: 28rpx;
color: #333333;
font-weight: 600;
margin-bottom: 10rpx;
}
.main-list-item {
background-color: #F7F7FA;
border: 1rpx solid #EFEFF4;
border-radius: 12rpx;
padding: 18rpx 16rpx;
margin-bottom: 14rpx;
}
.main-list-item-text {
font-size: 26rpx;
color: #333333;
line-height: 1.4;
word-break: break-word;
}
.qa-input-row {
flex-direction: row;
display: flex;
align-items: center;
}
.qa-input-box {
flex: 1;
min-height: 80rpx;
border-radius: 40rpx;
background-color: #F5F5F5;
padding: 0 32rpx;
display: flex;
align-items: center;
}
.qa-textarea {
width: 100%;
min-height: 88rpx; /* 默认两行2*44rpx */
max-height: 176rpx; /* 最大四行4*44rpx */
line-height: 44rpx;
font-size: 26rpx;
color: #333333;
padding: 18rpx 0;
box-sizing: border-box;
overflow-y: auto;
}
.qa-input-placeholder {
font-size: 26rpx;
color: #999999;
}
.qa-input-send {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
margin-left: 16rpx;
background-color: #2A68FF;
align-items: center;
justify-content: center;
display: flex;
}
.qa-input-actions {
margin-top: 12rpx;
flex-direction: row;
display: flex;
}
.qa-chip {
padding: 8rpx 20rpx;
border-radius: 999rpx;
background-color: #F5F5F5;
margin-right: 16rpx;
display: flex;
align-items: center;
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>