构建免费的 LLM API - Cloudflare

Jul 14, 2024 · 2839 words · 6 min ·   #Blog #Cloudflare

AI摘要

正在生成中……



构建免费的LLM API#

很久之前就想把原来博客的AI总结给换了,毕竟数据在别人那,总觉得有点膈应,他们的API卖的其实很贵,基本是ChatGPT原价的10倍,生成的数据还可以二次销售。从Cloudflare发布AI功能,就一直在关注,但是一直没能在自己的博客里实现。

直到看到了这个文章:使用Cloudflare Workers制作博客AI摘要 | Mayx的博客,Cloudflare真不愧为赛博活菩萨。作者鄙视的那个项目正是我之前在用的🤣。

仔细研究了一下这篇文章和 FloatSheep/Qwen-Post-Summary: 使用 Cloudflare Worker AI 的通义千问模型为你的文章生成摘要,他们构建的逻辑基本和之前我用的那个一样,区别就是现在免费了。

Cloudflare的workers、D1数据库、workers AI对于个人日常的使用提供了一个非常便利的纯免费构建框架,理论上利用Cloudflare的这些免费服务可以复刻市面上70%的项目。

现在就可以自己在数据库里修改了,也可以自定义摘要的Prompt:

cloudflare-d1

这个项目的思路正好可以扩展上次的 RSS 订阅和本地 LLM 结合的初步尝试 - 流动知识检索 | Vandee’s Blog流程,增加非本地LLM的实现。

实际上,Cloudflare上自己构建的这个worker就是一个可以自定义的LLM-API了。

这就可以做许多事情了,例如复现扣子的聊天助手,在网页上接入自己的数据库做RAG问答。

下一个项目就是在现在的数字花园里加上这个。

这个例子充分说明了信息差和知识是怎么转换为实际的财富的。心疼我在原来博客摘要里消费的10块大洋🤣。

Code#

方法也很简单,首先在D1里创建一个数据库,然后创建一个Workers,在变量里绑定AI和新建的D1数据库,名字要起成blog_summary,如果想换名字就要改代码,里面建一张叫做blog_summary的表,需要有3个字段,分别是id、content、summary,都是text类型。

由于hugo本身不支持Liquid和jQuery,于是根据这篇博客的代码做了一些修改,前端用纯JavaScript实现,这样其他的博客也可以使用。加了个打字机效果,其他的基本照搬了🤣。

另外,在Hugo里,最好在 layouts/partiails/ 加入前端的代码页面例如summary.html,然后根据自己的博客结构,在single.html的合适位置插入{{ partial "summary.html" . }}

下面是具体代码参考,CSS部分有闲心死再弄,还有许多细节可以优化:

前端JavaScript代码#

<b>AI摘要</b>
<p id="aitext">正在生成中……</p>
<p id="ai-output"></p>
<script>
  async function sha(str) {
    const encoder = new TextEncoder();
    const data = encoder.encode(str);
    const hashBuffer = await crypto.subtle.digest("SHA-256", data);
    const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
    const hashHex = hashArray
      .map((b) => b.toString(16).padStart(2, "0"))
      .join(""); // convert bytes to hex string
    return hashHex;
  }

  function cleanText(text) {
    // 移除 HTML 标签
    let cleanedText = text.replace(/<[^>]*>?/gm, '');
    // 移除多余的空格和换行
    cleanedText = cleanedText.replace(/\s+/g, ' ').trim();
    return cleanedText;
  }

  async function typeWriter(text, elementId) {
    document.getElementById("aitext").style.display = "none";
    let index = 0;
    const element = document.getElementById(elementId);
    const writeLetter = () => {
      if (index < text.length) {
        element.textContent += text.charAt(index);
        index++;
        setTimeout(writeLetter, 30); // 调整时间来控制打字速度
      }
    };
    writeLetter();
  }

  async function ai_gen() {
    // 获取页面标题
    var postTitle = document.title;
    // 获取页面内容并清理
    var postContentRaw = document.getElementById('content').innerText;
    var postContent = cleanText(postContentRaw);

    // 创建包含标题和内容的对象
    var postData = {
      title: postTitle,
      content: postContent
    };

    // 将对象转换为JSON字符串
    var postContentJson = JSON.stringify(postData);

    // 创建签名
    var postContentSign = await sha(postContentJson);

    var outputContainer = document.getElementById("ai-output");

    // 构建请求URL
    const checkUploadedUrl = `https://your.workers.dev/is_uploaded?id=${encodeURIComponent(location.href)}&sign=${postContentSign}`;
    const getSummaryUrl = `https://your.workers.dev/get_summary?id=${encodeURIComponent(location.href)}&sign=${postContentSign}`;

    // 检查文章是否已上传并获取摘要
    try {
      let response = await fetch(checkUploadedUrl);
      if (!response.ok) {
        throw new Error(`Check uploaded error: status ${response.status}`);
      }
      let uploaded = await response.text();
      
      if (uploaded === "yes") {
        // 如果已上传,获取摘要
        response = await fetch(getSummaryUrl);
        if (!response.ok) {
          throw new Error(`Get summary error: status ${response.status}`);
        }
        let summaryText = await response.text();
        // 使用打字机效果显示摘要
        typeWriter(summaryText, 'ai-output');
      } else {
        // 如果文章未上传,上传文章内容
        let uploadBlogUrl = new URL("https://your.workers.dev/upload_blog");
        uploadBlogUrl.search = new URLSearchParams({ id: encodeURIComponent(location.href) });
        response = await fetch(uploadBlogUrl, {
          method: 'POST',
          headers: {
            "Content-Type": "application/json"
          },
          body: postContentJson
        });
        if (!response.ok) {
          throw new Error(`Upload blog error: status ${response.status}`);
        }
        // 等待上传完成再获取摘要
        await new Promise(r => setTimeout(r, 1000)); // 等待1秒,这里可以根据实际情况调整等待时间
        response = await fetch(getSummaryUrl);
        if (!response.ok) {
          throw new Error(`Get summary after upload error: status ${response.status}`);
        }
        summaryText = await response.text();
        typeWriter(summaryText, 'ai-output');
      }
    } catch (error) {
      console.error('Error:', error);
      outputContainer.textContent = 'Error: ' + error.message;
    }
  }

  // 确保DOM加载完成后执行ai_gen函数
  document.addEventListener('DOMContentLoaded', ai_gen);
</script>

原作者的worker代码#

async function sha(str) {
  const encoder = new TextEncoder();
  const data = encoder.encode(str);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, "0"))
    .join(""); // convert bytes to hex string
  return hashHex;
}
async function md5(str) {
  const encoder = new TextEncoder();
  const data = encoder.encode(str);
  const hashBuffer = await crypto.subtle.digest("MD5", data);
  const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, "0"))
    .join(""); // convert bytes to hex string
  return hashHex;
}

export default {
  async fetch(request, env, ctx) {
    const db = env.blog_summary;
    const url = new URL(request.url);
    const query = decodeURIComponent(url.searchParams.get('id'));
    const commonHeader = {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': "*",
      'Access-Control-Allow-Headers': "*",
      'Access-Control-Max-Age': '86400',
    }
    if (query == "null") {
      return new Response("id cannot be none", {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': "*",
          'Access-Control-Allow-Headers': "*",
          'Access-Control-Max-Age': '86400',
        }
      });
    }
    if (url.pathname.startsWith("/summary")) {
      let result = await db.prepare(
        "SELECT content FROM blog_summary WHERE id = ?1"
      ).bind(query).first("content");
      if (!result) {
        return new Response("No Record", {
          headers: commonHeader
        });
      }

      const messages = [
        {
          role: "system", content: `
          你是一个专业的文章摘要助手。你的主要任务是对各种文章进行精炼和摘要,帮助用户快速了解文章的核心内容。你读完整篇文章后,能够提炼出文章的关键信息,以及作者的主要观点和结论。
          技能
            精炼摘要:能够快速阅读并理解文章内容,提取出文章的主要关键点,用简洁明了的中文进行阐述。
            关键信息提取:识别文章中的重要信息,如主要观点、数据支持、结论等,并有效地进行总结。
            客观中立:在摘要过程中保持客观中立的态度,避免引入个人偏见。
          约束
            输出内容必须以中文进行。
            必须确保摘要内容准确反映原文章的主旨和重点。
            尊重原文的观点,不能进行歪曲或误导。
            在摘要中明确区分事实与作者的意见或分析。
          提示
            不需要在回答中注明摘要(不需要使用冒号),只需要输出内容。
          格式
            你的回答格式应该如下:
              这篇文章介绍了<这里是内容>
          ` },
        { role: "user", content: result.substring(0, 5000) }
      ]

      const stream = await env.AI.run('@cf/qwen/qwen1.5-14b-chat-awq', {
        messages,
        stream: true,
      });

      return new Response(stream, {
        headers: {
          "content-type": "text/event-stream; charset=utf-8",
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': "*",
          'Access-Control-Allow-Headers': "*",
          'Access-Control-Max-Age': '86400',
        }
      });
    } else if (url.pathname.startsWith("/get_summary")) {
      const orig_sha = decodeURIComponent(url.searchParams.get('sign'));
      let result = await db.prepare(
        "SELECT content FROM blog_summary WHERE id = ?1"
      ).bind(query).first("content");
      if (!result) {
        return new Response("no", {
          headers: commonHeader
        });
      }
      let result_sha = await sha(result);
      if (result_sha != orig_sha) {
        return new Response("no", {
          headers: commonHeader
        });
      } else {
        let resp = await db.prepare(
          "SELECT summary FROM blog_summary WHERE id = ?1"
        ).bind(query).first("summary");
        if (resp) {
          return new Response(resp, {
            headers: commonHeader
          });
        } else {
          const messages = [
            {
              role: "system", content: `
          你是一个专业的文章摘要助手。你的主要任务是对各种文章进行精炼和摘要,帮助用户快速了解文章的核心内容。你读完整篇文章后,能够提炼出文章的关键信息,以及作者的主要观点和结论。
          技能
            精炼摘要:能够快速阅读并理解文章内容,提取出文章的主要关键点,用简洁明了的中文进行阐述。
            关键信息提取:识别文章中的重要信息,如主要观点、数据支持、结论等,并有效地进行总结。
            客观中立:在摘要过程中保持客观中立的态度,避免引入个人偏见。
          约束
            输出内容必须以中文进行。
            必须确保摘要内容准确反映原文章的主旨和重点。
            尊重原文的观点,不能进行歪曲或误导。
            在摘要中明确区分事实与作者的意见或分析。
          提示
            不需要在回答中注明摘要(不需要使用冒号),只需要输出内容。
          格式
            你的回答格式应该如下:
              这篇文章介绍了<这里是内容>
          ` },
            { role: "user", content: result.substring(0, 5000) }
          ]

          const answer = await env.AI.run('@cf/qwen/qwen1.5-14b-chat-awq', {
            messages,
            stream: false,
          });
          resp = answer.response
          await db.prepare("UPDATE blog_summary SET summary = ?1 WHERE id = ?2")
            .bind(resp, query).run();
          return new Response(resp, {
            headers: commonHeader
          });
        }
      }
    } else if (url.pathname.startsWith("/is_uploaded")) {
      const orig_sha = decodeURIComponent(url.searchParams.get('sign'));
      let result = await db.prepare(
        "SELECT content FROM blog_summary WHERE id = ?1"
      ).bind(query).first("content");
      if (!result) {
        return new Response("no", {
          headers: commonHeader
        });
      }
      let result_sha = await sha(result);
      if (result_sha != orig_sha) {
        return new Response("no", {
          headers: commonHeader
        });
      } else {
        return new Response("yes", {
          headers: commonHeader
        });
      }
    } else if (url.pathname.startsWith("/upload_blog")) {
      if (request.method == "POST") {
        const data = await request.text();
        let result = await db.prepare(
          "SELECT content FROM blog_summary WHERE id = ?1"
        ).bind(query).first("content");
        if (!result) {
          await db.prepare("INSERT INTO blog_summary(id, content) VALUES (?1, ?2)")
            .bind(query, data).run();
          result = await db.prepare(
            "SELECT content FROM blog_summary WHERE id = ?1"
          ).bind(query).first("content");
        }
        if (result != data) {
          await db.prepare("UPDATE blog_summary SET content = ?1, summary = NULL WHERE id = ?2")
            .bind(data, query).run();
        }
        return new Response("OK", {
          headers: commonHeader
        });
      } else {
        return new Response("need post", {
          headers: commonHeader
        });
      }
    } else if (url.pathname.startsWith("/count_click")) {
      let id_md5 = await md5(query);
      let count = await db.prepare("SELECT `counter` FROM `counter` WHERE `url` = ?1")
        .bind(id_md5).first("counter");
      if (url.pathname.startsWith("/count_click_add")) {
        if (!count) {
          await db.prepare("INSERT INTO `counter` (`url`, `counter`) VALUES (?1, 1)")
            .bind(id_md5).run();
          count = 1;
        } else {
          count += 1;
          await db.prepare("UPDATE `counter` SET `counter` = ?1 WHERE `url` = ?2")
            .bind(count, id_md5).run();
        }
      }
      if (!count) {
        count = 0;
      }
      return new Response(count, {
        headers: commonHeader
      });
    } else {
      return Response.redirect("https://mabbs.github.io", 302)
    }
  }
}


See also