实现chatgpt的几个要素

327 阅读2分钟

1. 向上滚动节流获取对话历史。

import { throttle } from "lodash";

const scrollHandle = throttle(function (event) {
  const element = event.target;
  // 处理切换对话的问题
  if (element.scrollHeight <= element.offsetHeight) {
    return;
  }

  // 当触顶时请求
  if (element.scrollTop <= 100) {
    // 请求逻辑
    const preScrollHeight = element.scrollHeight;
    // 下次渲染 await $nextTick(),拉取数据后保持原位
    element.scrollTo(0, element.scrollHeight - preScrollHeight);
  }
}, 1000);

2. 使用 Clipborad 复制文本。

import ClipboardJS from "clipboard";

// 这里是创建一个按钮,每次通过 buttonElement.click() 方式触发
const clipboard = new ClipboardJS("#copy-btn", {
  text: () => `你要复制的文本`,
});

clipboard.on("success", () => {
  console.log("复制成功");
});

clipboard.on("error", () => {
  console.log("复制失败");
});

3. AbortController 终止 fetch 请求

let abortController = new AbortController();

// 比如在销毁之前、切换对话时使用。
const cancelFetchHandle = () => {
  if (abortController) {
    abortController.abort();
  }
};

const fetchRequest = async () => {
  cancelFetchHandle();
  abortController = new AbortController();

  try {
    const response = await fetch(`url`, {
      method: "get",
      headers: {
        Authorization: `token`,
      },
      signal: abortController.signal,
    });
  } catch (error) {
    console.error(error);
  } finally {
    abortController = null;
  }
};

终止 axios 请求

import axios from 'axios';

const cancelSource   = axios.CancelToken.source();

const axiosRequest = async () => {
    try {
        // request 即 axios.create() 实例
        const response = await request.get(`url`, { cancelToken: cancelSource.token });
    } catch (error) {
        console.error(error);
    }
}

// 保持统一
let abortController = { abort: (reason = 'abort') => cancelSource.cancel(reason) };

4. 对话流

yarn add @fortaine/fetch-event-source

本段代码来自 ChatGPT-Next-Web

import {
  fetchEventSource,
  EventStreamContentType,
} from "@fortaine/fetch-event-source";

const prettyObject = (msg) => {
  const obj = msg;
  if (typeof msg !== "string") {
    msg = JSON.stringify(msg, null, "  ");
  }
  if (msg === "{}") {
    return obj.toString();
  }
  if (msg.startsWith("```json")) {
    return msg;
  }
  return ["```json", msg, "```"].join("\n");
};

const fetchRequest = (request) => {
  // 超时时间
  const REQUEST_TIMEOUT_MS = 60000;
  const abortAnswerController = new AbortController();
  const requestTimeoutId = setTimeout(
    () => abortAnswerController.abort(),
    REQUEST_TIMEOUT_MS
  );

  // status 表示是否正在获取回答
  let status = 0;
  let responseText = "";
  let remainText = "";
  let finished = false;
  let started = false;

  // animate response to make it looks smooth
  const animateResponseText = () => {
    if (
      (finished && remainText.length <= 0) ||
      abortAnswerController.signal.aborted
    ) {
      return;
    }
    const fetchCount = Math.max(1, Math.round(remainText.length / 60));
    const fetchText = remainText.slice(0, fetchCount);
    responseText += fetchText;
    remainText = remainText.slice(fetchCount);

    // replace you answer
    // 注意宏任务、微任务问题
    responseText;

    requestAnimationFrame(animateResponseText);
  };

  const chatPath = `chat-request-url`;
  const chatPayload = {
    method: "POST",
    headers: {
      Authorization: localStorage.getItem("token"),
    },
    signal: abortAnswerController.signal,
    body: JSON.stringify({ request }),
  };

  const finish = () => {
    status = 0;
    finished = true;
  };

  const error = (e) => {
    status = 0;
    finished = true;
    console.error(e);
  };

  status = 1;
  fetchEventSource(chatPath, {
    ...chatPayload,
    async onopen(res) {
      clearTimeout(requestTimeoutId);
      const contentType = res.headers.get("content-type");

      if (contentType?.startsWith("text/plain")) {
        responseText = await res.clone().text();
        return finish();
      }

      if (
        !res.ok ||
        !res.headers
          .get("content-type")
          ?.startsWith(EventStreamContentType) ||
        res.status !== 200
      ) {
        const responseTexts = [responseText];
        let extraInfo = await res.clone().text();
        try {
          const resJson = await res.clone().json();
          extraInfo = prettyObject(resJson);
        } catch {}

        if (res.status === 401) {
          responseTexts.push("登录已过期。");
        }

        if (extraInfo) {
          responseTexts.push(extraInfo);
        }

        responseText = responseTexts.join("\n\n");

        return finish();
      }
    },
    onmessage(msg) {
      if (msg.data === "[DONE]" || finished) {
        return finish();
      }
      const text = msg.data;
      try {
        const json = JSON.parse(text);
        const choices = json.choices;
        const delta = choices[0]?.delta?.content;
        if (delta) {
          remainText += delta;

          if (!started) {
            // start animaion
            animateResponseText();
            started = true;
          }
        }
      } catch (e) {
        console.error("[Request] parse error", text, msg);
      }
    },
    onclose() {
      finish();
    },
    onerror(e) {
      error(e);
    },
    openWhenHidden: true,
  });
};

5. markdown

import MarkdownIt from "markdown-it";

const md = new MarkdownIt();

const renderAsMarkdown = (text = "") => {
  return md.render(text);
};

样式 github-markdown-css 直接下载引入进来,或者直接找个 gihub 项目把 Markdown.scss、HighLight.scss 拷贝过来

<div class="markdown-body"></div>

6. 获取回答时光标输入效果

.answer-animation {
  & > :last-child::after {
    display: inline-block;
    content: "";
    width: 3px;
    height: 14px;
    transform: translate(4px, 2px) scaleY(1.3);
    background-color: #666;
    animation: blink 0.6s infinite;
  }

  @keyframes blink {
    from,
    to {
      opacity: 0;
    }
    50% {
      opacity: 1;
    }
  }
}