未来是内容为王的时代,就连房地产行业,也要靠内容才更有机会了。
—— Jeff Tian
既然内容如此重要,那么作为技术从业者,可以为此做些什么呢?我想,通过大模型的力量,将内容生产效率提高到下一个层次,会是一个凸显技术价值的有趣尝试。
本篇文章将介绍一下在无头内容管理系统 Strapi 中集成宇宙最强富文编辑器 CKEditor 5,并添加 AI 小助手的过程,希望在内容生产上助你一臂之力!
另外,我在上一篇文章《欢迎来调戏我:在公众号里对接 AWS Bedrock 服务 - Jeff Tian的文章 - 知乎 》中介绍了如何将 AI 小助手接入微信公众号,但沮丧的是,听说这样做会极有可能被微信官方封号:
相比用在微信公众号的自动回复,本篇文章的应用场景,会更加具有可行性!
在线体验
https://strapi.brickverse.dev/admin
其实不仅可以提示它生成内容文案,而且还可以让它帮忙优化格式。当然,一些常用的使用场景,比如内容翻译,都有快捷方式:
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 天的试用期,我目前就是拿到了试用序列号。
通过插件的方式将 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 的许可证即可(其他选项可以留空不配置)。
对接 Bedrock
以上截图还展示了 AWS 的配置,这就是为了对接 Bedrock 服务,详见《欢迎来调戏我:在公众号里对接 AWS Bedrock 服务 - Jeff Tian的文章 - 知乎 》中对 Bedrock 的相关解释。
这样配置后,就可以使用 AI 小助手了!
通过后端方式为 CKEditor 5 的 AI 助手提供服务
以上通过配置的方式来使用 Bedrock 服务,虽然简单,但是不推荐!如果通过配置 AWS 访问密钥,来从客户端调用 Bedrock 服务,就会将 AWS 的密钥暴露在前端,所以非常建议通过后端接口来为 CKEditor 5 的 AI 助手提供服务。尽管在前一篇文章《欢迎来调戏我:在公众号里对接 AWS Bedrock 服务 - Jeff Tian的文章 - 知乎 》中写的接口,完全可以用在这里,但是对于 AI 写作,使用流的方式,用户体验更佳,这样就可以看见它的“实时”写作过程。
后端接口可以写在任何地方,但既然我们的 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 的基础设施上,不得已而为之,写了一个定时返回空响应的代码。代码虽丑,但效果拔群!
最终效果视频演示
彩蛋
最后附上 AWS Bedrock 的更多介绍资料: