Commit 6a0a201c by zhangxingmin

push

parent 15b5f869
...@@ -12,14 +12,12 @@ ...@@ -12,14 +12,12 @@
"preview": "vite preview", "preview": "vite preview",
"report": "npm run build --report" "report": "npm run build --report"
}, },
"repository": {
"type": "git",
"url": "https://gitee.com/y_project/RuoYi-Vue.git"
},
"dependencies": { "dependencies": {
"@ai-sdk/alibaba": "^1.0.17",
"@element-plus/icons-vue": "2.3.1", "@element-plus/icons-vue": "2.3.1",
"@vueup/vue-quill": "1.2.0", "@vueup/vue-quill": "1.2.0",
"@vueuse/core": "13.3.0", "@vueuse/core": "13.3.0",
"ai": "^6.0.168",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
"axios": "1.9.0", "axios": "1.9.0",
"clipboard": "2.0.11", "clipboard": "2.0.11",
...@@ -34,7 +32,7 @@ ...@@ -34,7 +32,7 @@
"jsencrypt": "3.3.2", "jsencrypt": "3.3.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^4.3.0", "marked": "^16.4.0",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
"pinia": "3.0.2", "pinia": "3.0.2",
......
...@@ -13,6 +13,51 @@ export function randList(data) { ...@@ -13,6 +13,51 @@ export function randList(data) {
} }
/** /**
* 获取完整回答(非流式)
*/
export async function getFullAnswer(question, timeout = 300000) {
const userStore = useUserStore();
const token = getToken();
const tenantId = userStore.currentTenant?.apiLoginTenantInfoResponse?.tenantBizId;
const headers = {
'Authorization': `Bearer ${token}`
};
if (tenantId) {
headers['X-Tenant-ID'] = tenantId;
}
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const url = `${baseUrl}/ai/api/api/ai/stream?question=${encodeURIComponent(question)}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
method: 'GET',
headers,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
let errorMessage = response.statusText;
try {
const errorBody = await response.json();
errorMessage = errorBody.msg || errorBody.message || errorMessage;
} catch (e) {}
throw new Error(errorMessage);
}
// 直接返回纯文本
return await response.text();
} finally {
clearTimeout(timeoutId);
}
}
/**
* 查询输出流信息(流式响应) * 查询输出流信息(流式响应)
* 增强错误处理:当HTTP状态非2xx时,解析错误响应体并抛出包含code属性的错误 * 增强错误处理:当HTTP状态非2xx时,解析错误响应体并抛出包含code属性的错误
*/ */
...@@ -30,7 +75,7 @@ export function getStream(question, timeout = 300000) { ...@@ -30,7 +75,7 @@ export function getStream(question, timeout = 300000) {
} }
const baseUrl = import.meta.env.VITE_APP_BASE_API const baseUrl = import.meta.env.VITE_APP_BASE_API
const url = `${baseUrl}/ai/api/api/ai/stream?question=${encodeURIComponent(question)}` const url = `${baseUrl}/ai/api/api/ai/stream-sse?question=${encodeURIComponent(question)}`;
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout) const timeoutId = setTimeout(() => controller.abort(), timeout)
...@@ -40,6 +85,7 @@ export function getStream(question, timeout = 300000) { ...@@ -40,6 +85,7 @@ export function getStream(question, timeout = 300000) {
headers, headers,
signal: controller.signal signal: controller.signal
}).then(async (response) => { }).then(async (response) => {
console.log(response)
clearTimeout(timeoutId) clearTimeout(timeoutId)
if (!response.ok) { if (!response.ok) {
// 尝试解析错误响应体 // 尝试解析错误响应体
...@@ -106,4 +152,72 @@ export async function getServiceCardList() { ...@@ -106,4 +152,72 @@ export async function getServiceCardList() {
console.error('获取服务卡片列表失败:', error) console.error('获取服务卡片列表失败:', error)
throw error throw error
} }
}
/**
* 启动流式生成,返回 sessionId
*/
export async function startStream(question) {
const userStore = useUserStore();
const token = getToken();
const tenantId = userStore.currentTenant?.apiLoginTenantInfoResponse?.tenantBizId;
const headers = {
'Authorization': `Bearer ${token}`
};
if (tenantId) {
headers['X-Tenant-ID'] = tenantId;
}
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const url = `${baseUrl}/ai/api/api/ai/start-stream`;
const formData = new FormData();
formData.append('question', question);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return data.sessionId;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* 轮询获取流式内容
*/
export async function pollContent(sessionId) {
const userStore = useUserStore();
const token = getToken();
const tenantId = userStore.currentTenant?.apiLoginTenantInfoResponse?.tenantBizId;
const headers = {
'Authorization': `Bearer ${token}`
};
if (tenantId) {
headers['X-Tenant-ID'] = tenantId;
}
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const url = `${baseUrl}/ai/api/api/ai/stream-content?sessionId=${encodeURIComponent(sessionId)}`;
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json(); // { content: "...", finished: true/false }
} }
\ No newline at end of file
...@@ -108,12 +108,85 @@ ...@@ -108,12 +108,85 @@
</template> </template>
<script> <script>
import { randList, getStream, getServiceCardList } from '@/api/ai/ai' import { randList, getFullAnswer, getServiceCardList, startStream, pollContent } from '@/api/ai/ai';
import { marked } from 'marked' import { marked } from 'marked'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css' import 'highlight.js/styles/github-dark.css'
// 配置 marked,增强安全性 // ==================== 强化预处理函数:确保表格正确渲染 + 段落分明 ====================
function preprocessMarkdown(text) {
if (!text) return text;
const lines = text.split('\n');
const result = [];
let inTable = false;
let tableRows = [];
const isTableRow = (line) => {
const trimmed = line.trim();
// 以 | 开头或结尾,且包含至少一个 |,认为是表格行
return (trimmed.startsWith('|') || trimmed.endsWith('|')) && trimmed.includes('|');
};
const fixTableRow = (row) => {
// 确保行以 | 开头,以 | 结尾
let fixed = row.trim();
if (!fixed.startsWith('|')) fixed = '| ' + fixed;
if (!fixed.endsWith('|')) fixed = fixed + ' |';
// 标准化分隔行中的横线
if (fixed.includes('---') || fixed.includes(':---')) {
fixed = fixed.replace(/:\-{3,}/g, ':---').replace(/\-{3,}:?/g, '---:').replace(/\-{3,}/g, '---');
fixed = fixed.replace(/\|/g, ' | ').replace(/\s+/g, ' ').replace(/\|\s*\|\s*/g, '|');
}
return fixed;
};
const flushTable = () => {
if (tableRows.length === 0) return;
// 修复每一行
const fixedRows = tableRows.map(row => fixTableRow(row));
// 确保表格前后有空行
if (result.length > 0 && result[result.length - 1].trim() !== '') {
result.push('');
}
result.push(...fixedRows);
result.push(''); // 表格后空行
tableRows = [];
};
for (let line of lines) {
const isRow = isTableRow(line);
if (isRow) {
if (!inTable) {
// 进入表格区域
inTable = true;
}
tableRows.push(line);
} else {
if (inTable) {
flushTable();
inTable = false;
}
result.push(line);
}
}
if (inTable) {
flushTable();
}
let processed = result.join('\n');
// 将单个换行转换为双换行(段落分隔),但保留表格区域的多行结构(表格行之间是单个\n,marked要求如此)
// 我们已在表格前后添加了空行,这里只需处理非表格区域
const nonTablePartRegex = /(?:^|\n\n)(?!\|)(?:.|\n(?!\n|$))+/g;
processed = processed.replace(nonTablePartRegex, (part) => {
return part.replace(/([^\n])\n([^\n])/g, '$1\n\n$2');
});
return processed;
}
// 配置 marked:关闭 breaks,因为我们已经手动处理了段落(用双换行生成 <p>
marked.setOptions({ marked.setOptions({
highlight: function(code, lang) { highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
...@@ -121,8 +194,9 @@ marked.setOptions({ ...@@ -121,8 +194,9 @@ marked.setOptions({
} }
return hljs.highlightAuto(code).value return hljs.highlightAuto(code).value
}, },
breaks: true, breaks: false, // 不自动转换单个换行,由我们的预处理负责
gfm: true, gfm: true,
tables: true,
mangle: false, mangle: false,
headerIds: false, headerIds: false,
}) })
...@@ -190,12 +264,69 @@ export default { ...@@ -190,12 +264,69 @@ export default {
} }
}, },
// 核心渲染:预处理 + marked 解析
// 在 formatMessage 方法中增加对表格的显式保护
formatMessage(content) { formatMessage(content) {
if (!content) return '' if (!content) return '';
return marked.parse(content) try {
// 1. 修复被压缩成一行的表格:在管道符之间合理插入换行
let fixedContent = content;
// 检测是否包含表格特征(连续管道符),且缺少换行
if (fixedContent.includes('|') && !fixedContent.includes('\n|')) {
// 将 "||" 视为两个单元格之间的分隔,但实际上是两行表格之间的分隔符缺失
// 更稳妥的方法:根据表头数量推断列数,然后按列数拆分后续数据行
const lines = fixedContent.split('\n');
const newLines = [];
for (let line of lines) {
// 如果这一行包含表格标记但没有任何换行(即整段表格被压缩在一行)
if (line.includes('|') && !line.includes('\n')) {
// 尝试识别表格头部分隔行(例如包含 :--- 或 ---)
const separatorMatch = line.match(/(?:\|[\s:]*[-:]+[\s:]*)+(?=\|)/);
if (separatorMatch) {
const separatorIndex = line.indexOf(separatorMatch[0]);
// 表头部分(分隔行之前的内容)
const headerPart = line.substring(0, separatorIndex).trim();
// 分隔行
const sepLine = separatorMatch[0];
// 数据部分(分隔行之后)
const dataPart = line.substring(separatorIndex + sepLine.length).trim();
// 将表头按 || 分割成单独的行(注意:|| 表示两行表格之间的连接)
// 但更常见的是 "||" 出现在数据行之间
// 重构表格:先将表头行和分隔行加入
if (headerPart) {
newLines.push(headerPart);
}
newLines.push(sepLine);
// 处理数据部分:按 "||" 分割成多行
const dataRows = dataPart.split('||').filter(row => row.trim() !== '');
for (let row of dataRows) {
newLines.push(row.trim());
}
} else {
// 没有分隔行,无法修复,原样保留
newLines.push(line);
}
} else {
newLines.push(line);
}
}
fixedContent = newLines.join('\n');
}
// 2. 调用原有的预处理函数
const preprocessed = preprocessMarkdown(fixedContent);
return marked.parse(preprocessed);
} catch (e) {
console.error('Markdown 渲染失败:', e);
return escapeHtml(content);
}
}, },
// 生成产品列表HTML // 生成产品列表 HTML
buildProductListHtml(productData) { buildProductListHtml(productData) {
if (!productData || !productData.list || !productData.list.length) { if (!productData || !productData.list || !productData.list.length) {
return '<div class="product-list-empty">暂无产品信息</div>' return '<div class="product-list-empty">暂无产品信息</div>'
...@@ -203,9 +334,7 @@ export default { ...@@ -203,9 +334,7 @@ export default {
let html = '<div class="product-list">' let html = '<div class="product-list">'
productData.list.forEach(card => { productData.list.forEach(card => {
// 获取卡片主图(取第一个子项的图片)
const mainImage = card.list && card.list[0]?.itemImg || '' const mainImage = card.list && card.list[0]?.itemImg || ''
// 获取卡片标识(用于购买跳转)
const cardCode = card.list && card.list[0]?.cardCode || '' const cardCode = card.list && card.list[0]?.cardCode || ''
const cardName = card.cardName || '' const cardName = card.cardName || ''
const price = card.price || '0' const price = card.price || '0'
...@@ -225,7 +354,6 @@ export default { ...@@ -225,7 +354,6 @@ export default {
return html return html
}, },
// 获取并展示产品列表
async fetchAndShowProducts(userQuestion) { async fetchAndShowProducts(userQuestion) {
this.isLoading = true this.isLoading = true
try { try {
...@@ -262,28 +390,18 @@ export default { ...@@ -262,28 +390,18 @@ export default {
} }
}, },
// 处理产品卡片点击(事件委托)
handleProductClick(event) { handleProductClick(event) {
const target = event.target const target = event.target
// 查找实际触发的购买按钮或卡片本身
let productElement = target.closest('.product-buy-btn') || target.closest('.product-card') let productElement = target.closest('.product-buy-btn') || target.closest('.product-card')
if (!productElement) return if (!productElement) return
// 获取产品标识
let productCode = productElement.dataset.productCode let productCode = productElement.dataset.productCode
let productName = productElement.dataset.productName
// 如果是按钮,从按钮上获取;如果是卡片,从卡片上获取
if (!productCode) { if (!productCode) {
const card = productElement.closest('.product-card') const card = productElement.closest('.product-card')
if (card) { if (card) productCode = card.dataset.productCode
productCode = card.dataset.productCode
productName = card.dataset.productName
}
} }
if (productCode) { if (productCode) {
// 跳转到购买页面(根据实际商城地址调整)
const buyUrl = `https://hoservice.ydhomeoffice.cn/ydMall/productDetail?cardCode=${productCode}` const buyUrl = `https://hoservice.ydhomeoffice.cn/ydMall/productDetail?cardCode=${productCode}`
window.open(buyUrl, '_blank') window.open(buyUrl, '_blank')
} else { } else {
...@@ -292,102 +410,61 @@ export default { ...@@ -292,102 +410,61 @@ export default {
}, },
async sendMessage() { async sendMessage() {
if (!this.question.trim() || this.isLoading) return if (!this.question.trim() || this.isLoading) return;
const userQuestion = this.question.trim() const userQuestion = this.question.trim();
this.messages.push({ role: 'user', content: userQuestion }) this.messages.push({ role: 'user', content: userQuestion });
this.question = '' this.question = '';
this.scrollToBottom() this.scrollToBottom();
// 正常流式回答 this.isLoading = true;
this.isLoading = true const assistantMessageIndex = this.messages.length;
const assistantMessageIndex = this.messages.length this.messages.push({ role: 'assistant', content: '', isProductList: false });
this.messages.push({ role: 'assistant', content: '', isProductList: false })
try { try {
const response = await getStream(userQuestion) // 1. 启动流式生成,获取 sessionId
if (!response.ok) { const { startStream } = await import('@/api/ai/ai');
throw new Error(`HTTP ${response.status}: ${response.statusText}`) const sessionId = await startStream(userQuestion);
}
// 2. 开始轮询
const reader = response.body.getReader() const pollInterval = 300; // 毫秒
const decoder = new TextDecoder('utf-8') const maxPolls = 600; // 最多轮询 600 次(3分钟)
let buffer = '' let pollCount = 0;
let accumulatedContent = ''
const poll = async () => {
while (true) { try {
const { done, value } = await reader.read() const { pollContent } = await import('@/api/ai/ai');
if (done) break const { content, finished } = await pollContent(sessionId);
buffer += decoder.decode(value, { stream: true }) // 检查敏感词通知标记
const lines = buffer.split('\n') if (pollCount === 0 && content.includes('__SENSITIVE_NOTIFICATION__')) {
buffer = lines.pop() || '' this.messages.pop();
await this.fetchAndShowProducts(userQuestion);
// 在 sendMessage 中,解析 SSE 时增加对 event 的处理 this.isLoading = false;
let currentEvent = null; return;
for (let line of lines) {
line = line.trim();
if (line === '') continue;
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
continue;
} }
let content = ''; this.messages[assistantMessageIndex].content = content;
if (line.startsWith('data:')) { this.scrollToBottom();
content = line.slice(5).trim();
if (content === '[DONE]') continue;
} else if (line.startsWith('message:')) {
content = line.slice(8).trim();
} else {
content = line;
}
if (content) { if (finished || pollCount >= maxPolls) {
if (currentEvent === 'sensitive_notification') { this.isLoading = false;
// 收到通知敏感词事件,移除刚添加的空消息,展示产品列表 return;
this.messages.pop();
await this.fetchAndShowProducts(userQuestion);
currentEvent = null;
// 结束流式处理,不再继续
return;
} else {
accumulatedContent += content;
this.messages[assistantMessageIndex].content = accumulatedContent;
this.scrollToBottom();
}
} }
pollCount++;
setTimeout(poll, pollInterval);
} catch (err) {
console.error('轮询出错', err);
this.messages[assistantMessageIndex].content = '❌ 获取内容失败';
this.isLoading = false;
} }
} };
if (buffer.trim()) { poll();
let content = buffer
if (buffer.startsWith('data:')) content = buffer.slice(5).trim()
else if (buffer.startsWith('message:')) content = buffer.slice(8).trim()
if (content && content !== '[DONE]') {
accumulatedContent += content
this.messages[assistantMessageIndex].content = accumulatedContent
this.scrollToBottom()
}
}
} catch (error) { } catch (error) {
console.error('流式请求失败:', error) console.error('启动流式生成失败:', error);
this.messages[assistantMessageIndex].content = '❌ 请求失败';
// 处理通知类型敏感词错误码 50002 this.isLoading = false;
if (error.code === 50002) {
// 移除刚添加的空助手消息
this.messages.pop()
// 调用产品列表展示
await this.fetchAndShowProducts(userQuestion)
} else {
// 其他错误正常显示错误消息
const errorMsg = error.name === 'AbortError' ? '请求超时,请稍后重试' : `错误:${error.message}`
this.messages[assistantMessageIndex].content = `❌ ${errorMsg}`
this.scrollToBottom()
}
} finally {
this.isLoading = false
} }
}, },
...@@ -402,7 +479,7 @@ export default { ...@@ -402,7 +479,7 @@ export default {
} }
} }
// HTML转义函数,防止XSS // HTML 转义函数
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return '' if (!str) return ''
return str return str
...@@ -851,6 +928,7 @@ function escapeHtml(str) { ...@@ -851,6 +928,7 @@ function escapeHtml(str) {
30% { transform: translateY(-8px); opacity: 1; } 30% { transform: translateY(-8px); opacity: 1; }
} }
/* Markdown 内容样式(包括表格) */
.message-content :deep(h1) { .message-content :deep(h1) {
font-size: 1.5em; font-size: 1.5em;
font-weight: 600; font-weight: 600;
...@@ -918,14 +996,18 @@ function escapeHtml(str) { ...@@ -918,14 +996,18 @@ function escapeHtml(str) {
font-style: italic; font-style: italic;
} }
/* 表格样式 - 关键修复 */
.message-content :deep(table) { .message-content :deep(table) {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin: 0.8em 0; margin: 1em 0;
font-size: 14px; font-size: 14px;
display: block;
overflow-x: auto;
} }
.message-content :deep(th), .message-content :deep(td) { .message-content :deep(th),
.message-content :deep(td) {
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
padding: 8px 12px; padding: 8px 12px;
text-align: left; text-align: left;
...@@ -964,6 +1046,7 @@ function escapeHtml(str) { ...@@ -964,6 +1046,7 @@ function escapeHtml(str) {
color: #ffe0b5; color: #ffe0b5;
} }
/* 滚动条样式 */
.chat-messages::-webkit-scrollbar { .chat-messages::-webkit-scrollbar {
width: 6px; width: 6px;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment