未来是内容为王的时代,就连房地产行业,也要靠内容才更有机会了。 —— Jeff Tian

既然内容如此重要,那么作为技术从业者,可以为此做些什么呢?我想,通过大模型的力量,将内容生产效率提高到下一个层次,会是一个凸显技术价值的有趣尝试。
本篇文章将介绍一下在无头内容管理系统 Strapi 中集成宇宙最强富文编辑器 CKEditor 5,并添加 AI 小助手的过程,希望在内容生产上助你一臂之力!
另外,我在上一篇文章《欢迎来调戏我:在公众号里对接 AWS Bedrock 服务 - Jeff Tian的文章 - 知乎 》中介绍了如何将 AI 小助手接入微信公众号,但沮丧的是,听说这样做会极有可能被微信官方封号:
image.png
相比用在微信公众号的自动回复,本篇文章的应用场景,会更加具有可行性!

在线体验

https://strapi.brickverse.dev/admin
dx8xj_d1x5l_b2e387090c.gif
WX20231219-173453.png
WX20231219-173608.png
image.png
其实不仅可以提示它生成内容文案,而且还可以让它帮忙优化格式。当然,一些常用的使用场景,比如内容翻译,都有快捷方式:
image.png

Strapi

Strapi 是一个优秀的无头内容管理系统,更多介绍详见《给 Strapi Admin Portal 添加单点登录方式 - Jeff Tian的文章 - 知乎 》。

CKEditor 5

我认为它是宇宙最强富文本编辑器,Strapi 默认的文本编辑器,功能非常简单。通过加入 CKEditor 5,可以说直接在 Strapi 里集成了一个 word,已经无敌了。
更多对 CKEditor 5 的讨论,可以参考:《对 UmiJS 和 ckeditor 的折腾 - Jeff Tian的文章 - 知乎 》以及这篇回答:《为什么都说富文本编辑器是天坑? - Jeff Tian的回答 - 知乎 》。

AI 助手

这其实还是 CKEditor 5 团队开发的,有了 AI 助手,那就是在宇宙最强的基础上更加如虎添翼了!不过,虽然 CKEditor 5 本身是免费的,但 AI 助手却不是免费的(许可证一年需要 5000 美元!)。好在有 30 天的试用期,我目前就是拿到了试用序列号。
image.png

通过插件的方式将 CKEditor 5 以及 AI 小助手集成到 Strapi

CKEditor 团队开发了 Strapi 插件,可以在 Strapi 里添加自定义字段,并启用 CKEditor 5。但是官方的插件,并不包含 AI 小助手,于是我 fork 了官方的插件,做了一些修改,将 AI 小助手添加进来了,源代码见:https://github.com/Jeff-Tian/strapi-plugin-ckeditor

使用方式

在 Strapi 项目里: json yarn add @jeff-tian/strapi-plugin-ckeditor yarn build yarn develop

配置也非常简单,只需要配置一下 CKEditor 5 的许可证即可(其他选项可以留空不配置)。
image.png

对接 Bedrock

以上截图还展示了 AWS 的配置,这就是为了对接 Bedrock 服务,详见《欢迎来调戏我:在公众号里对接 AWS Bedrock 服务 - Jeff Tian的文章 - 知乎 》中对 Bedrock 的相关解释。
这样配置后,就可以使用 AI 小助手了!

通过后端方式为 CKEditor 5 的 AI 助手提供服务

以上通过配置的方式来使用 Bedrock 服务,虽然简单,但是不推荐!如果通过配置 AWS 访问密钥,来从客户端调用 Bedrock 服务,就会将 AWS 的密钥暴露在前端,所以非常建议通过后端接口来为 CKEditor 5 的 AI 助手提供服务。尽管在前一篇文章《欢迎来调戏我:在公众号里对接 AWS Bedrock 服务 - Jeff Tian的文章 - 知乎 》中写的接口,完全可以用在这里,但是对于 AI 写作,使用流的方式,用户体验更佳,这样就可以看见它的“实时”写作过程。 Screen Recording 2023-12-19 at 11.18.16.mov (32MB) 后端接口可以写在任何地方,但既然我们的 Strapi 本身就是一个后端,不妨直接在 Strapi 里添加一个接口,用来为 AI 小助手服务。

添加一个方法调用 bedrock 服务

这个文件的主要内容和上一篇《欢迎来调戏我:在公众号里对接 AWS Bedrock 服务 - Jeff Tian的文章 - 知乎 》中的 bedrock.js 文件内容几乎一模一样,唯一的区别是这里支持流的响应方式。

json const { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand, InvokeModelCommand, } = require(@aws-sdk/client-bedrock-runtime);

// 心跳间隔设置为 15 秒,小于 Heroku 的 30 秒超时 const heartbeatInterval = 15000;

const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms))

const setupHeartBeat = (ctx, heartbeatInterval) => { const heartBeatFunc = () => { // 发送一个空白的数据块作为心跳 if (!ctx.res.writableEnded) { ctx.res.write(JSON.stringify({completion: })); } }

return setInterval(heartBeatFunc, heartbeatInterval); }

module.exports = { async index(ctx, next) { // called by GET /hello ctx.body = Hello World!; // we could also send a JSON },

async post(ctx, next) { // called by POST /hello console.log(ctx.request.body = , ctx.request.body);

const input = {
  modelId: ctx.request.body.model ?? anthropic.claude-v2,
  contentType: application/json,
  accept: application/json,
  body: JSON.stringify({
    prompt: ctx.request.body.prompt,
    max_tokens_to_sample: ctx.request.body.max_tokens_to_sample ?? 2000,
    temperature: ctx.request.body.temperature ?? 1,
    top_p: ctx.request.body.top_p ?? 1,
    top_k: ctx.request.body.top_k ?? 250
  })
}

const client = new BedrockRuntimeClient({
  region: us-east-1,
  credentials: {
    accessKeyId: from env,
    secretAccessKey: from env
  }
});

async function stream() {
  // 不要让 Koa 自动处理响应
  ctx.respond = false;
  let heartbeat = null;

  try {
    // 设置 HTTP 响应头
    ctx.res.writeHead(200, {
      Content-Type: application/json,
      Transfer-Encoding: chunked
    });

    heartbeat = setupHeartBeat(ctx, heartbeatInterval);

    const command = new InvokeModelWithResponseStreamCommand(input);
    const res = await client.send(command);

    for await (const event of res.body) {
      // 如果有心跳,清除它,因为我们即将发送实际数据
      if (heartbeat) {
        clearInterval(heartbeat);
        heartbeat = null;
      }

      if (event.chunk && event.chunk.bytes) {
        // 将 JSON 对象转换为字符串,并发送一个 JSON 块
        if (!ctx.res.writableEnded) {
          const response = Buffer.from(event.chunk.bytes).toString(utf-8) + n;
          console.log(response to client: , response);
          ctx.res.write(response);

          await sleep(600);
        }
      } else if (
        event.internalServerException ||
        event.modelStreamErrorException ||
        event.throttlingException ||
        event.validationException
      ) {
        console.error(event);
        if (!ctx.res.writableEnded) {
          ctx.res.write(JSON.stringify({
            completion: Error: ${event.internalServerException?.message ?? event.modelStreamErrorException?.message ?? event.throttlingException?.message ?? event.validationException?.message}
          }));
        }

        break;
      }

      if (!heartbeat) {
        // 数据发送后重新启动心跳
        heartbeat = setupHeartBeat(ctx, heartbeatInterval);
      }
    }

    // 结束 HTTP 响应前清除心跳
    if (heartbeat) {
      clearInterval(heartbeat);
      heartbeat = null;
    }

    ctx.res.end();
  } catch (ex) {
    console.error(bedrock error = , ex);
    if (heartbeat) {
      clearInterval(heartbeat);
      heartbeat = null;
    }
    ctx.res.end(JSON.stringify({
      completion: <p>抱歉,连接 Bedrock 出错了,原因是: ${ex.message}</p>,
      stop_reason: stop_sequence,
      stop: nnHuman:
    }));
  }
}

async function nonStream() {
  ctx.set(Content-Type, application/json);

  try {
    const command = new InvokeModelCommand(input);
    const response = await client.send(command);

    console.log(-------------------);
    console.log(---Full Response---);
    console.log(-------------------);
    console.log(response);

    const rawRes = response.body;
    const jsonString = new TextDecoder().decode(rawRes);

    console.log(-------------------------);
    // Answers are in parsedResponse.completion
    console.log(jsonString);
    console.log(-------------------------);

    ctx.body = jsonString;
  } catch (ex) {
    console.error(bedrock error = , ex);

    ctx.body = {
      completion: <p>抱歉,连接 Bedrock 出错了,原因是: ${ex.message}</p>
    }
  }
}

if (ctx.request.body.stream) {
  await stream();
} else {
  await nonStream();
}

} };

克服 Heroku 的响应超时限制

在《给 Strapi Admin Portal 添加单点登录方式 - Jeff Tian的文章 - 知乎 》中我说过,部署 Strapi 的最简单的方式就是将它部署到 Heroku 这个 PaaS 平台,但是 Heroku 对一个请求响应,却有 30 秒的限制。一旦和 Bedrock 服务的网络连接稍慢,就会造成一个 AI 帮写的停滞,在这时,尽管可以通过重试,有极大的概率成功写完,但是如果能够减少这种情况,还是要尽量减少。
因此,你会在上面的代码中,看到一些所谓的心跳响应。是的,本来是不必要的,但是在 Heroku 的基础设施上,不得已而为之,写了一个定时返回空响应的代码。代码虽丑,但效果拔群!

最终效果视频演示

Screen Recording 2023-12-19 at 10.53.39.mov (151.64MB)

彩蛋

最后附上 AWS Bedrock 的更多介绍资料:
GCR Amazon Bedrock First Call Deck CN.pdf