(万字长文)接入讯飞星火大模型的问题解决方案,一套带走,拿来即用

最近在尝试接入讯飞星火的时候,本以为很顺利,结果出现了一堆bug,什么解析格式不对呀各种问题,我这里分享一下我的解决方案

想看完整代码的直接跳转即可,前面是一些组件化的说明

首先是链接讯飞星火的模型,首先是进入到讯飞星火官网、

进入到开发者平台,创建一个属于自己的模型,拿到app的id和秘钥

创建好之后,根据自己的http网址和密码访问api,代码如下,可直接拿来使用

async function callSparkStream(apiUrl, apiPassword, data) {
  content.value = ""; // 清空输入框内容
  const headers = {
    Authorization: `Bearer ${apiPassword}`,
    "Content-Type": "application/json",
  };

  try {
    const response = await fetch(apiUrl, {
      method: "POST",
      headers,
      body: JSON.stringify(data),
    });

    if (!response.body) {
      console.error("No streaming data returned!");
      return;
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let buffer = "";
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        results.value.push(parsedStr.value); // 保存到历史记录
        str.value = ""; // 清空当前内容
        break;
      }

      buffer += decoder.decode(value, { stream: true });

      const lines = buffer.split("\n");
      buffer = lines.pop();

      for (const line of lines) {
        if (line.trim() === "") continue;
        try {
          const parsedData = JSON.parse(line.replace(/^data: /, "").trim());
          console.log("parsedData:", parsedData);
          if (parsedData.choices && parsedData.choices[0]?.delta?.content) {
            const content = parsedData.choices[0].delta.content;
            console.log("解析出来的内容:", content);
            await renderText(content);
          }
        } catch (error) {
          console.error("解析失败:", line);
        }
      }
    }
  } catch (error) {
    console.error("请求失败:", error.message);
  }
}

这个是调用大模型api,用的是流式接受

下面是调用这个方法的封装,拿来之后要先输入自己的网址和秘钥

const onSendRequest = (content) => {
  // 下面这一行是跟我的html结合不用管
  isShowdialog.value = true; // 显示对话框
  const apiUrl = ""; // 换成自己的
  const apiPassword = "";
  const requestData = {
    model: "4.0Ultra",
    messages: [
      {
        role: "user",
        content: content,
      },
    ],
    stream: true,
  };
  // 调用 AI 接口
  callSparkStream(apiUrl, apiPassword, requestData);
};

然后你就会发现,返回来的格式是md,而且怎么给用户一种流式传输的感觉呢,那么就要用到我们下面的解析拿到的数据并逐字解析,这里面真的是一堆坑,赶时间的话直接copy,建议还是自己写一下

首先是先加入解析公式的MathJax

function loadMathJax() {
  if (!window.MathJax) {
    console.log("MathJax 加载中...");
    const script = document.createElement("script");

    script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
    script.async = true;
    document.head.appendChild(script);

    script.onload = () => {
      console.log("MathJax 加载成功");
    };
  }
}

注意在挂载和更新的时候调用它

// #region 生命周期
onMounted(() => {
  loadMathJax(); // 加载 MathJax
});

onUpdated(() => {
  nextTick(() => {
    if (window.MathJax) {
      MathJax.typesetPromise(); // 更新时重新渲染公式
    }
  });
});
// #endregion

然后是逐个字符的解析并调用转换md的方法如下

// 逐个字符解析
function renderText(content) {
  return new Promise((resolve) => {
    let i = 0;
    const interval = setInterval(() => {
      console.log("当前字符:", content[i]);
      str.value += content[i]; // 实时追加字符到显示内容
      i++;

      if (i >= content.length) {
        clearInterval(interval);
        resolve();
        //  解析 Markdown 内容
        parsedStr.value = parseMarkdown(str.value);
        // parsedStr.value = str.value;
        console.log("解析后的 Markdown 内容:", parsedStr.value);
        // 实时检测 <p> 标签并渲染公式
        nextTick(() => {
          const paragraphs = document.querySelectorAll(".current p"); // 找到所有 <p> 标签
          paragraphs.forEach((p) => {
            MathJax.typesetPromise([p]).catch((err) => {
              console.error("MathJax 渲染错误:", err.message);
            });
          });
        });
      }
    }, 10); // 每 10ms 渲染一个字符
  });
}

然后这个是将拿到的md转换为html格式,这里面点是真的多,用的是marked,这里面主要是防止md将表明公式的 \ 当成转义字符,通过正则表达式匹配,而且这里面返回的单引号和双引号可能不是很整齐,所以像我这样都转义一下就好了

function parseMarkdown(content) {
  const formulaMap = [];
  let formulaIndex = 0;

  const normalizedContent = content.replace(/&amp;#39;/g, "'");
  const protectedContent = normalizedContent
    .replace(
      /\\\((.+?)\\\)|\\\[(.+?)\\\]|(\$\$[\s\S]+?\$\$)|\$(.+?)\$/g,
      (match, ...groups) => {
        if (groups[3]) {
          const inlineFormula = groups[3].trim();
          const protectedFormula = `\\(${inlineFormula}\\)`;
          formulaMap.push(protectedFormula);
        } else {
          formulaMap.push(match);
        }
        return `@@FORMULA${formulaIndex++}@@`;
      }
    )
    .replace(/\\\\/g, () => {
      formulaMap.push("\\\\");
      return `@@FORMULA${formulaIndex++}@@`;
    })
    .replace(/\\[\[\]]/g, (match) => {
      formulaMap.push(match);
      return `@@FORMULA${formulaIndex++}@@`;
    });

  // 在正式转换之前处理代码块中的内容
  const preProcessedContent = protectedContent.replace(
    /```([\s\S]*?)```/g,
    (match, codeBlockContent) => {
      const processedCodeBlock = codeBlockContent
        // 只匹配单引号(跳过双引号)
        .replace(/(?<!')'(?!')/g, "\\'") // 替换单独的单引号为 \'
        .replace(/\\\\/g, "\\"); // 替换双反斜杠为单反斜杠
      return `\`\`\`${processedCodeBlock}\`\`\``;
    }
  );

  let htmlContent = marked(preProcessedContent);

  // 防止 HTML 实体自动转换,统一替换全文内容中的 HTML 实体
  htmlContent = htmlContent
    .replace(/&quot;/g, '"') // 替换所有 &quot;
    .replace(/&#39;/g, "'") // 替换所有 &#39;
    .replace(/&amp;/g, "&") // 替换所有 &amp;
    .replace(/\\&#39;/g, "'") // 替换 \&#39; 为 '
    .replace(/\\'/g, "'"); // 替换所有 \' 为 '

  htmlContent = htmlContent.replace(
    /<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
    (match, language, code) => {
      const highlightedCode = hljs.highlight(language, code, true).value;

      return `
        <div class="code-block-container">
          <div class="code-block-header">
            <span class="code-language">${language.toUpperCase()}</span>
            <button class="copy-button" onclick="copyToClipboard(this)">Copy</button>
          </div>
          <pre class="code-block"><code class="language-${language}">${highlightedCode}</code></pre>
        </div>
      `;
    }
  );
  htmlContent = htmlContent.replace(/@@FORMULA(\d+)@@/g, (match, index) => {
    return formulaMap[index];
  });

  return htmlContent;
}

可以注意到我这里面有代码块还有copy按钮,这个copy功能代码如下,加进去即可

window.copyToClipboard = (button) => {
  const codeBlock =
    button.parentElement.nextElementSibling.querySelector("code");
  if (codeBlock) {
    const codeText = codeBlock.innerText;
    navigator.clipboard.writeText(codeText).then(
      () => {
        button.textContent = "Copied!";
        setTimeout(() => {
          button.textContent = "Copy";
        }, 2000);
      },
      (err) => {
        console.error("Failed to copy text: ", err);
      }
    );
  }
};

最后功能代码都完成了,剩下的就是样式了,那么直接看完整代码如下

这里用的vue3,template部分

<template>
  <div>
    <div class="options-container">
      <input type="text" v-model="content" />
      <button :disabled="!content.trim()" @click="onSendRequest(content)">
        发送请求
      </button>
    </div>
    <div class="result">
      <div v-if="results.length >= 2 || isShowdialog">
        <div
          class="preview"
          v-for="(result, index) in results.slice(0, -1)"
          :key="index"
          v-html="result"
        ></div>
      </div>
      <!-- 渲染解析后的 Markdown 内容 -->
      <div
        v-if="parsedStr.trim() != ''"
        class="current"
        v-html="parsedStr"
      ></div>
    </div>

  </div>
</template>

js部分

<script setup>
/* eslint-disable */
/* global MathJax */

import { onMounted, ref, nextTick, onUpdated } from "vue";
import { marked } from "marked";
import hljs from "highlight.js";
import "highlight.js/styles/github.css";

const isShowdialog = ref(false); // 控制对话框显示
const content = ref(""); // 用户输入的内容
const str = ref(""); // 动态显示的内容
const parsedStr = ref(""); // 解析后的 Markdown 内容
const results = ref([]); // 存储解析后的历史记录

// #region 加载 MathJax 的函数
function loadMathJax() {
  if (!window.MathJax) {
    console.log("MathJax 加载中...");
    const script = document.createElement("script");

    script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
    script.async = true;
    document.head.appendChild(script);

    script.onload = () => {
      console.log("MathJax 加载成功");
    };
  }
}
// #endregion

// #region Markdown 转 HTML 并处理代码块的函数
function parseMarkdown(content) {
  const formulaMap = [];
  let formulaIndex = 0;

  const normalizedContent = content.replace(/&amp;#39;/g, "'");
  const protectedContent = normalizedContent
    .replace(
      /\\\((.+?)\\\)|\\\[(.+?)\\\]|(\$\$[\s\S]+?\$\$)|\$(.+?)\$/g,
      (match, ...groups) => {
        if (groups[3]) {
          const inlineFormula = groups[3].trim();
          const protectedFormula = `\\(${inlineFormula}\\)`;
          formulaMap.push(protectedFormula);
        } else {
          formulaMap.push(match);
        }
        return `@@FORMULA${formulaIndex++}@@`;
      }
    )
    .replace(/\\\\/g, () => {
      formulaMap.push("\\\\");
      return `@@FORMULA${formulaIndex++}@@`;
    })
    .replace(/\\[\[\]]/g, (match) => {
      formulaMap.push(match);
      return `@@FORMULA${formulaIndex++}@@`;
    });

  // 在正式转换之前处理代码块中的内容
  const preProcessedContent = protectedContent.replace(
    /```([\s\S]*?)```/g,
    (match, codeBlockContent) => {
      const processedCodeBlock = codeBlockContent
        // 只匹配单引号(跳过双引号)
        .replace(/(?<!')'(?!')/g, "\\'") // 替换单独的单引号为 \'
        .replace(/\\\\/g, "\\"); // 替换双反斜杠为单反斜杠
      return `\`\`\`${processedCodeBlock}\`\`\``;
    }
  );

  let htmlContent = marked(preProcessedContent);

  // 防止 HTML 实体自动转换,统一替换全文内容中的 HTML 实体
  htmlContent = htmlContent
    .replace(/&quot;/g, '"') // 替换所有 &quot;
    .replace(/&#39;/g, "'") // 替换所有 &#39;
    .replace(/&amp;/g, "&") // 替换所有 &amp;
    .replace(/\\&#39;/g, "'") // 替换 \&#39; 为 '
    .replace(/\\'/g, "'"); // 替换所有 \' 为 '

  htmlContent = htmlContent.replace(
    /<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
    (match, language, code) => {
      const highlightedCode = hljs.highlight(language, code, true).value;

      return `
        <div class="code-block-container">
          <div class="code-block-header">
            <span class="code-language">${language.toUpperCase()}</span>
            <button class="copy-button" onclick="copyToClipboard(this)">Copy</button>
          </div>
          <pre class="code-block"><code class="language-${language}">${highlightedCode}</code></pre>
        </div>
      `;
    }
  );
  htmlContent = htmlContent.replace(/@@FORMULA(\d+)@@/g, (match, index) => {
    return formulaMap[index];
  });

  return htmlContent;
}

// #endregion

//  #region 实时渲染内容并检测公式的函数
function renderText(content) {
  return new Promise((resolve) => {
    let i = 0;
    const interval = setInterval(() => {
      console.log("当前字符:", content[i]);
      str.value += content[i]; // 实时追加字符到显示内容
      i++;

      if (i >= content.length) {
        clearInterval(interval);
        resolve();
        //  解析 Markdown 内容
        parsedStr.value = parseMarkdown(str.value);
        // parsedStr.value = str.value;
        console.log("解析后的 Markdown 内容:", parsedStr.value);
        // 实时检测 <p> 标签并渲染公式
        nextTick(() => {
          const paragraphs = document.querySelectorAll(".current p"); // 找到所有 <p> 标签
          paragraphs.forEach((p) => {
            MathJax.typesetPromise([p]).catch((err) => {
              console.error("MathJax 渲染错误:", err.message);
            });
          });
        });
      }
    }, 10); // 每 10ms 渲染一个字符
  });
}
// #endregion
renderText("$ax^2+by=c$");
//#region 调用 AI 接口主函数
async function callSparkStream(apiUrl, apiPassword, data) {
  content.value = ""; // 清空输入框内容
  const headers = {
    Authorization: `Bearer ${apiPassword}`,
    "Content-Type": "application/json",
  };

  try {
    const response = await fetch(apiUrl, {
      method: "POST",
      headers,
      body: JSON.stringify(data),
    });

    if (!response.body) {
      console.error("No streaming data returned!");
      return;
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let buffer = "";
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        results.value.push(parsedStr.value); // 保存到历史记录
        str.value = ""; // 清空当前内容
        break;
      }

      buffer += decoder.decode(value, { stream: true });

      const lines = buffer.split("\n");
      buffer = lines.pop();

      for (const line of lines) {
        if (line.trim() === "") continue;
        try {
          const parsedData = JSON.parse(line.replace(/^data: /, "").trim());
          console.log("parsedData:", parsedData);
          if (parsedData.choices && parsedData.choices[0]?.delta?.content) {
            const content = parsedData.choices[0].delta.content;
            console.log("解析出来的内容:", content);
            await renderText(content);
          }
        } catch (error) {
          console.error("解析失败:", line);
        }
      }
    }
  } catch (error) {
    console.error("请求失败:", error.message);
  }
}
// #endregion

// #region 发送请求的函数
const onSendRequest = (content) => {
  isShowdialog.value = true; // 显示对话框
  const apiUrl = "";
  const apiPassword = "";
  const requestData = {
    model: "4.0Ultra",
    messages: [
      {
        role: "user",
        content: content,
      },
    ],
    stream: true,
  };
  // 调用 AI 接口
  callSparkStream(apiUrl, apiPassword, requestData);
};
// #endregion

// #region 复制代码块到剪贴板
window.copyToClipboard = (button) => {
  const codeBlock =
    button.parentElement.nextElementSibling.querySelector("code");
  if (codeBlock) {
    const codeText = codeBlock.innerText;
    navigator.clipboard.writeText(codeText).then(
      () => {
        button.textContent = "Copied!";
        setTimeout(() => {
          button.textContent = "Copy";
        }, 2000);
      },
      (err) => {
        console.error("Failed to copy text: ", err);
      }
    );
  }
};
// #endregion

// #region 生命周期
onMounted(() => {
  loadMathJax(); // 加载 MathJax
});

onUpdated(() => {
  nextTick(() => {
    if (window.MathJax) {
      MathJax.typesetPromise(); // 更新时重新渲染公式
    }
  });
});
// #endregion
</script>

css部分

<style lang="scss">
.result {
  position: relative;
  width: 500px;
}
.options-container {
  margin-bottom: 20px;
  display: flex;
  align-items: center;
  gap: 10px;

  label {
    font-size: 16px;
    color: #333;
  }

  input {
    flex: 1;
    padding: 5px;
    font-size: 14px;
    border-radius: 5px;
    border: 1px solid #ddd;
  }

  button {
    padding: 8px 15px;
    font-size: 14px;
    background-color: #4caf50;
    color: white;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    transition: background-color 0.3s;

    &:hover {
      background-color: #45a049;
    }

    &:disabled {
      background-color: #ccc;
      cursor: not-allowed;
    }
  }
}
.preview {
  padding: 10px;
  margin: 20px;
  border: 2px solid rebeccapurple;
}
.current {
  padding: 10px;
  margin: 20px;
  border: 2px solid rebeccapurple;
}
.code-block-container {
  position: relative;
  background-color: #2d2d2d;
  border-radius: 5px;
  margin-bottom: 15px;
  font-family: "Courier New", monospace;
  overflow: hidden;
  width: 100%;

  /* 代码块标题栏 */
  .code-block-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    background-color: rgb(81, 79, 79); /* 浅灰色背景 */
    padding: 8px 12px;
    border-bottom: 1px solid #ddd;
    font-size: 14px;
    color: white;

    .code-language {
      font-weight: bold;
      color: #e4e2e2; /* 语言文字颜色 */
    }

    .copy-button {
      background-color: #4caf50; /* 按钮绿色 */
      color: white;
      border: none;
      border-radius: 3px;
      padding: 5px 10px;
      cursor: pointer;
      font-size: 12px;
      position: absolute; /* 固定到右上角 */
      top: 5px;
      right: 5px;
      transition: background-color 0.3s;

      &:hover {
        background-color: #45a049;
      }
    }
  }

  /* 代码块样式 */
  .code-block {
    background-color: #2d2d2d; /* 黑色背景 */
    color: white; /* 白色文字 */
    border-radius: 0 0 5px 5px; /* 底部圆角 */
    overflow-x: auto; /* 横向滚动条 */
    padding: 15px;
    white-space: pre; /* 禁止自动换行 */
  }
}
</style>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值