背景简介
在ai快速发展的现在,对于融入ai是所有项目的重点思考方向。此篇文章详细论述前端在接入ai模块遇到的各类问题和思考。
项目一:在某平台数据分析表格echarts图中插入ai根据数据分析生成固定模板数据报告模块,使用模型chatGpt,封装组件展示ai报告,兼容deepseek打字机效果。
项目二:在某平台加入菜单生成图像(使用模型gemini,按次数收费,参数包括比例,风格,模型切换),通过对话形式,生成图片,关联上下文,每次后台传递全对话给ai,前端限制最大对话次数。
项目一重点问题记录
1.缓存报告导致的接口区分调用,筛选变化筛选条件优先查缓存数据的方向,以此节约ai交互。埋点记录客户对ai报告的赞踩和评价
2.调用接口计算aitoken,传入分析数据,接口返回token个数,前端限制40000以内,请求ai报告接口,否则页面提示(优化筛选条件,简化报表结构)
3.几种错误处理和限制添加
- 大模型连接失败
- token个数超过
- 平台限制单人每天最大请求次数
4.对于ai返回最终markdown字符串解析(参考附录1).重点使用组件mrkdown-it组件解析。在使用过程中ai返回的部分标题或加粗存在格式的偶发遗漏。如:在三级标题###后未加入空格导致无解析的问题,需要在使用组件前处理
5.对流失数据响应fetch接口返回的数据拼接(参考附录2、3)
项目二重点问题记录
1.对上传图片的方式变更,原本为base64,后改为用图片服务器断点续传方式,花费了较长时间。上传后展示前端虚拟图片路径。点击任务,查询任务对话详情
2.返回结构需要兼容多图生成。模型可能会返回多图,原设计为一图,返回多图都应遍历展示
3.于图片尺寸上,大模型控制还存在问题,前端控制宽度即可
参考代码
附录1:markdown字符串解析
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
export function renderMarkdown(content) {
if (!content) return "";
// 预处理内容,转换可能的表格数据
// content = this.processTableData(content)
// 识别并保护表格格式
let preprocessedContent = content;
// 用一个标记保存表格区域,防止被后续处理破坏
const tableRegex = /(\|[^\n]*\|\s*\n\s*\|[\s:|\-\-\-\-]*\|[\s\S]*?(?=\n\s*\n|\n\s*$|$))/g;
const tableMarker = "{{TABLE_PLACEHOLDER_";
const tables = [];
// 首先识别表格并替换为标记
preprocessedContent = preprocessedContent.replace(tableRegex, function (match) {
const placeholder = `${tableMarker}${tables.length}}`;
tables.push(match);
return placeholder;
});
// 1. 处理<br>标签
preprocessedContent = preprocessedContent.replace(
/<br\s*\/?>/gi,
"{{BR_PLACEHOLDER}}"
);
// 2. 显式将\n转换为特殊标记(处理纯文本中的换行)
preprocessedContent = preprocessedContent.replace(
/\\n/g,
"{{NEWLINE_PLACEHOLDER}}"
);
// 3. 处理实际换行符 - 只针对非表格区域
preprocessedContent = preprocessedContent.replace(/\n/g, "\n\n"); // 确保单行换行在markdown中生效
// 恢复表格标记为原始表格内容
for (let i = 0; i < tables.length; i++) {
const marker = `${tableMarker}${i}}`;
preprocessedContent = preprocessedContent.replace(marker, tables[i]);
}
// 初始化 markdown-it 实例
const md = new MarkdownIt({
html: true, // 启用 HTML 标签以支持 <br> 等标签
xhtmlOut: false, // 使用 '/' 关闭单标签 (<br />)
breaks: true, // 转换段落里的 '\n' 到 <br>
linkify: true, // 自动将 URL 转换为链接
typographer: true, // 启用一些语言中立的替换 + 引号美化
// 启用表格支持
tables: true, // 启用表格语法
highlight: function (str, lang) {
// 代码高亮处理
if (lang && hljs.getLanguage(lang)) {
try {
return (
'<pre class="hljs"><code class="language-' +
lang +
'">' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
"</code></pre>"
);
} catch (e) {
console.error("代码高亮错误:", e);
}
}
// 如果语言未指定或者不支持,使用普通样式
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + "</code></pre>";
},
});
// 渲染 Markdown 内容
let renderedHtml = md.render(preprocessedContent);
// 将特殊标记转换回HTML标签
renderedHtml = renderedHtml.replace(/{{BR_PLACEHOLDER}}/g, "<br>");
renderedHtml = renderedHtml.replace(/{{NEWLINE_PLACEHOLDER}}/g, "<br>");
// 使用 DOMPurify 清理 HTML,防止 XSS 攻击,但允许 <br> 标签和表格标签
return DOMPurify.sanitize(renderedHtml, {
ALLOWED_TAGS: [
"br",
"p",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"pre",
"code",
"ul",
"ol",
"li",
"strong",
"em",
"strike",
"a",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"img",
"hr",
"div",
"span",
"sub",
"sup",
"kbd",
"mark",
],
ALLOWED_ATTR: [
"href",
"src",
"alt",
"class",
"target",
"rel",
"title",
"style",
"id",
"width",
"height",
"align",
"valign",
"colspan",
"rowspan",
"cellspacing",
"cellpadding",
"name",
"content",
"charset",
"lang",
"dir",
"hreflang",
],
ADD_ATTR: ["target"], // 允许链接使用target属性打开新窗口
FORBID_TAGS: [
"script",
"style",
"iframe",
"form",
"input",
"textarea",
"select",
"button",
],
FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onmouseout"], // 禁止所有事件处理程序
ALLOW_DATA_ATTR: false, // 禁止使用data-*属性以提高安全性
USE_PROFILES: { html: true }, // 使用HTML配置文件
});
}
附录2:请求模块
const response = await fetch(
`https://xx.com/run`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.selectedModel.apiKey}`,
Accept: "text/event-stream",
appkey: this.$store.getters.appkey,
gamekey: this.$store.getters.currentGame.gameKey,
cuid: this.$store.getters.userId,
},
body: JSON.stringify({
inputs: paramData,
response_mode: "streaming",
user: "user-123",
}),
signal: this.controller.signal,
}
);
console.log("response", response);
if (!response.ok) {
throw new Error(`API响应错误: ${response.status}`);
}
const reader = response.body.getReader();
const textDecoder = new TextDecoder();
// 处理流式响应
this.handleEventStream(reader, textDecoder, res.data);
附录3:接收流式数据
// 处理 EventSource 的流式响应
handleEventStream: function (reader, textDecoder, promptTokens) {
var self = this;
var fullReasoningContent = ""; // 思考过程累积
var fullContent = ""; // 回答结果累积
var isFirstChunk = true; // 标记是否为第一个数据块
var isInThinkMode = false; // 标记是否处于思考模式
var totalTokens = 0;
var taskId = "";
// 创建一个异步函数来处理流
async function processStream() {
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// 收到第一个数据块时清除网络延迟标志
// 这是为了在收到API第一个响应时立即取消"网络延迟"提示
if (isFirstChunk) {
if (self.networkDelayTimer) {
clearTimeout(self.networkDelayTimer);
}
self.isNetworkDelay = false;
isFirstChunk = false;
// 记录思考开始时间
if (!self.reasoningStartTime) {
self.reasoningStartTime = Date.now();
// 启动思考时长计时器
self.reasoningTimerInterval = setInterval(function () {
if (self.reasoningStartTime) {
const now = Date.now();
self.currentReasoningDuration = Math.round(
(now - self.reasoningStartTime) / 1000
);
}
}, 1000); // 每秒更新一次
}
}
// 解码收到的数据
const chunk = textDecoder.decode(value, { stream: true });
const lines = chunk.split("\n");
// 处理每一行数据
for (const line of lines) {
const data = line.startsWith("data: ")
? line.substring(6)
: line.startsWith("data:")
? line.substring(5)
: line;
if (data) {
if (
data === "[DONE]" ||
data.includes("node_finished") ||
data.includes("node_started") ||
data.includes("workflow_started")
) {
continue;
}
try {
// console.log("收到数据:", data);
const json = JSON.parse(data);
if (json.event == "workflow_finished") {
totalTokens = json.data.total_tokens;
taskId = json.task_id;
}
if (json.event != "text_chunk") {
continue;
}
json.data.content = json.data.text || "";
const content = json.data || "";
// 检查是否有思考内容和回答内容
var contentContent = content.content || "";
var reasoningContent = "";
var answerContent = "";
// 检查是否包含<think>标签(开始思考)
if (contentContent.indexOf("<think>") != -1) {
isInThinkMode = true;
// 提取<think>标签之后的内容
const thinkStartIndex = contentContent.indexOf("<think>") + 7;
reasoningContent = contentContent.substring(thinkStartIndex);
}
// 检查是否包含</think>标签(结束思考)
if (contentContent.indexOf("</think>") != -1) {
const thinkEndIndex = contentContent.indexOf("</think>");
reasoningContent = contentContent.substring(0, thinkEndIndex);
isInThinkMode = false;
}
if (isInThinkMode) {
reasoningContent = contentContent || "";
} else {
answerContent =
contentContent == "mark" ||
contentContent == "down" ||
contentContent == "markdown"
? ""
: contentContent || "";
// 去除文档内的```标签
answerContent = answerContent.replace(/```/, "");
}
// 判断当前阶段
if (isInThinkMode) {
// 思考阶段
self.isThinkingPhase = true;
fullReasoningContent += reasoningContent;
self.pendingReasoningContent = fullReasoningContent;
self.pendingChars += reasoningContent.length;
} else if (!isInThinkMode) {
// 回答阶段
// 如果之前在思考阶段,并且这是第一次转到回答阶段,记录思考结束时间
if (
self.isThinkingPhase &&
!self.reasoningEndTime &&
fullReasoningContent
) {
self.reasoningEndTime = Date.now();
// 结束倒计时
clearInterval(self.reasoningTimerInterval);
self.reasoningTimerInterval = null;
// 思考完成以后折叠当前的思考过程
self.isCurrentReasoningExpanded = false;
// 检查滚动条状态
// self.$nextTick(() => {
// self.checkScrollbarState();
// });
}
self.isThinkingPhase = false;
fullContent += answerContent;
self.pendingContent = fullContent;
self.pendingChars += answerContent.length;
}
if (self.isThinkingPhase) {
self.reasoningStreamContent = self.pendingReasoningContent;
} else {
self.streamContent = self.pendingContent;
}
// self.scrollToBottom();
// 检查滚动条状态,但不要太频繁
// if (self.pendingChars > 100) {
// self.$nextTick(() => {
// self.checkScrollbarState();
// });
// }
} catch (error) {
console.error("解析事件数据错误:", error);
}
}
}
}
// 最终处理
if (fullContent == "") {
self.messages = [];
self.messages.push({
role: "create",
errorCode: 2, // 1:发起,2:异常,3:次数用完
});
self.$emit("updateLoading", false);
return;
}
// 设置最终内容
let resultArr = fullContent.split("###").map((item) => {
return item.length > 0 && item[0] != " " ? " " + item : item;
});
fullContent = resultArr.join("###");
self.streamContent = fullContent;
self.reasoningStreamContent = fullReasoningContent;
// 如果没有记录思考结束时间,在这里记录
if (!self.reasoningEndTime && fullReasoningContent) {
self.reasoningEndTime = Date.now();
}
// 计算思考时长(秒)
let reasoningDuration = 0;
if (self.reasoningStartTime && self.reasoningEndTime) {
reasoningDuration = Math.round(
(self.reasoningEndTime - self.reasoningStartTime) / 1000
);
}
// 非深度思考思考全程时间
const allReasoningDuration = Math.round(
(Date.now() - self.reasoningStartTime) / 1000
);
clearInterval(self.reasoningTimerInterval);
// 上报成功和思考时间
// 添加AI回复到消息列表
var messageIndex = self.messages.length;
self.messages.push({
role: "assistant",
model: self.selectedModel.name,
content: fullContent,
reasoning_content: fullReasoningContent,
reasoningDuration: reasoningDuration,
allReasoningDuration: allReasoningDuration,
timestamp: Date.now(),
});
if (self.$store.getters.appkey && fullContent) {
self.saveCount();
self.saveCache();
}
// 默认收起思考过程
self.$set(self.expandedReasonings, messageIndex, false);
// 最终消息添加完成后检查滚动条状态
// self.$nextTick(() => {
// self.checkScrollbarState();
// // 延迟再次检测(以防渲染延迟)
// setTimeout(() => {
// self.checkScrollbarState();
// }, 300);
// });
} catch (error) {
if (error.name !== "AbortError") {
console.error("流处理错误:", error);
}
} finally {
// 清理
self.loading = false;
self.streamContent = "";
self.reasoningStreamContent = "";
self.isThinkingPhase = true; // 重置为思考阶段
if (self.renderTimer) clearInterval(self.renderTimer);
if (self.networkDelayTimer) clearTimeout(self.networkDelayTimer);
if (self.reasoningTimerInterval) clearInterval(self.reasoningTimerInterval);
self.reasoningTimerInterval = null;
self.isNetworkDelay = false;
self.controller = null;
self.reasoningStartTime = null;
self.reasoningEndTime = null;
self.currentReasoningDuration = 0;
self.scrollToBottom();
}
}
processStream();
},