使用 Supabase 和 AI 构建自主创建的网站
这个想法
构建项目
TL;DR:
使用Supabase、Astro、Unreal Speech、Stable Diffusion、Replicate、大都会艺术博物馆构建
利用 Supabase Edge 函数、存储、Webhook、数据库触发器和 pg_cron
每小时的预定作业都会触发一次新的插入,然后触发对 Edge Functions 的 Webhook 调用以开始生成新资产
又回来写关于 Supabase 黑客马拉松的另一个项目了!这是关于我们最近的一个项目,叫做“Echoes of Creation”,重点介绍技术细节,而不是解释它的总体构建过程。
这个想法
我和我的朋友一年多来一直有个想法:建立一个网站,每小时自动“毁灭”并重建一次。这个想法是为了展现人工智能是如何产生影响的,尤其是它如何影响创意领域。页面上会有一个倒计时,显示新版本生成的时间,随着时间接近零,我们会使用各种效果逐渐破坏内容。这本质上是一个艺术项目,我们觉得在某个时候做起来会很酷,而这次 Supabase 黑客马拉松正好可以实现它!不过,由于时间限制,我们不得不稍微修改一下想法。
最初,我们曾考虑过每次创建页面时都创建一个全新的布局,并添加新的内容。然而,这在技术上非常具有挑战性,尤其是在只使用 CSS 和 HTML 的情况下:我们肯定不希望像其他网页一样,内容被框起来并垂直布局。因此,如果我们想实现最初的想法,可以选择使用 WebGL 来利用 3D 空间。这或许是项目未来的改进方案,但由于我对 WebGL 的经验不如预期,如果我们想在截止日期前完成,那么这是不可能的。
我们在Discord上聊天,我的朋友想出了一个好主意:与其让AI破坏网站,不如讲述一位艺术家在当今这个充斥着AI的世界里苦苦挣扎的故事。我们会做一个滚动式的网站,最好是只用动画来呈现内容,让用户感觉置身于3D空间。每个网站都是艺术家脑海中一个新的“思维/创意流程”。我们俩都对这个想法非常兴奋,并开始着手进行创作。
构建项目
一开始,我们对整个滚动的故事和动画有一个清晰的设想,但随着项目的推进,我们放弃了大部分内容。简而言之:我们觉得如果“镜头”能够改变角度,并在每个网站版本中以不同的方向旋转内容,会很酷,让你感觉仿佛置身于艺术家的大脑之中。我们实现了这个方案,效果不错(尽管有时有点令人作呕),然而,随着我们对故事和结构的深入思考,这种导航方式就变得不再有意义了。我们决定将重点更多地放在通过声音和其他内容进行叙事上。
但现在让我们关注项目的实际要点:Supabase Edge Functions。
在开始之前,有几件事:
- 有三个数据库表:
thoughts
,,artwork
thought_texts
thought
整个故事的叙述:创作过程就是“一个大想法”thought_texts
包含故事中的所有文本及其音频文件的链接artwork
包含所有艺术品;包括由大都会博物馆 API 生成和获取的
生成艺术品
在项目中,我们最终只使用了两个边缘函数:一个用于创建艺术品,名为generate
,另一个用于创建文本和语音,名为create-speech
。我们先从艺术品开始,它看起来像这样:
/* eslint-disable @typescript-eslint/ban-ts-comment */
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
import { corsHeaders } from "../_shared/cors.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.42.3";
import randomSample from "https://esm.sh/@stdlib/random-sample@0.2.1";
import Replicate from "https://esm.sh/replicate@0.29.1";
import { base64 } from "https://cdn.jsdelivr.net/gh/hexagon/base64@1/src/base64.js";
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
);
const replicate = new Replicate({
auth: Deno.env.get("REPLICATE_API_TOKEN") ?? "",
});
// @ts-expect-error
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
const { record } = await req.json();
const thought_id = record.id;
if (!thought_id) {
return new Response("Missing thought_id", {
status: 400,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
const allObjectIDsResponse = await fetch(
"https://collectionapi.metmuseum.org/public/collection/v1/objects?departmentIds=11",
{
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
}
);
const { objectIDs } = await allObjectIDsResponse.json();
const listOfArtworks = [];
const addedIDs: number[] = [];
while (listOfArtworks.length < 80) {
const randomObjectID = randomSample(objectIDs, { size: 1 })[0];
if (addedIDs.includes(randomObjectID)) continue;
const res = await fetch(
`https://collectionapi.metmuseum.org/public/collection/v1/objects/${randomObjectID}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
}
);
const artwork = await res.json();
if (!artwork.primaryImageSmall) continue;
addedIDs.push(artwork.objectID);
listOfArtworks.push({
image_url: artwork.primaryImageSmall,
artist_name: artwork.artistDisplayName,
title: artwork.title,
is_main: listOfArtworks.length === 0,
is_variant: false,
thought_id,
});
}
const mainImage = listOfArtworks[0];
const output = await replicate.run(
"yorickvp/llava-13b:b5f6212d032508382d61ff00469ddda3e32fd8a0e75dc39d8a4191bb742157fb",
{
input: {
image: mainImage.image_url,
top_p: 1,
prompt: "Describe this painting by " + mainImage.artist_name,
max_tokens: 1024,
temperature: 0.2,
},
}
);
const file = await fetch(mainImage.image_url).then((res) => res.blob());
const promises = [];
for (let i = 0; i < 8; i++) {
const body = new FormData();
body.append(
"prompt",
output.join("") + `, a painting by ${mainImage.artist_name}`
);
body.append("output_format", "jpeg");
body.append("mode", "image-to-image");
body.append("image", file);
body.append("strength", clamp(Math.random(), 0.4, 0.7));
const request = fetch(
`${Deno.env.get(
"STABLE_DIFFUSION_HOST"
)}/v2beta/stable-image/generate/sd3`,
{
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${Deno.env.get("STABLE_DIFFUSION_API_KEY")}`,
},
body,
}
);
promises.push(request);
}
const results = await Promise.all(promises);
const variants = await Promise.all(results.map((res) => res.json()));
await supabaseClient.from("artworks").insert(listOfArtworks);
for (let i = 0; i < variants.length; i++) {
const variant = variants[i];
const randomId = Math.random();
await supabaseClient.storage
.from("variants")
.upload(
`${thought_id}/${randomId}.jpeg`,
base64.toArrayBuffer(variant.image),
{
contentType: "image/jpeg",
}
);
await supabaseClient.from("artworks").insert({
image_url: `${Deno.env.get(
"SUPABASE_URL"
)}/storage/v1/object/public/variants/${thought_id}/${randomId}.jpeg`,
artist_name: mainImage.artist_name,
is_main: false,
is_variant: true,
thought_id,
});
}
await supabaseClient
.from("thoughts")
.update({ generating: false })
.eq("id", thought_id);
return new Response(JSON.stringify({ main: mainImage }), {
headers: { "Content-Type": "application/json", ...corsHeaders },
});
});
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
具体来说,在获取任何数据之前,我们首先会检查是否有新的思想 ID 需要生成艺术作品。我添加了400
响应,以便能够在 Webhook 稍后因某种原因失败时进行调试。record
数据结构会在 Webhook 触发器中自动传递,但我们稍后会讨论这些!
const { record } = await req.json();
const thought_id = record.id;
if (!thought_id) {
return new Response("Missing thought_id", {
status: 400,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
然后,我们将连接到大都会博物馆的 API,以获取其馆藏中公开的艺术品。博物馆竟然有如此免费的公开 API,真是太棒了!这些艺术品将成为艺术家在创作过程中产生的“想法”,其中一件将成为他们创作的主要作品。
const allObjectIDsResponse = await fetch(
"https://collectionapi.metmuseum.org/public/collection/v1/objects?departmentIds=11",
{
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
}
);
使用部门 ID 作为 URL 参数只会返回归类到特定部门的作品,例如“素描和版画”、“现代艺术”、“欧洲绘画”。在我们的例子中,我们使用了11
与“欧洲绘画”对应的 ID,该类别包含超过 2500 件艺术品!我们使用的端点会返回与作品相关的 ID 列表,我们将使用这些 ID 来获取与作品相关的实际数据。
您可以使用管道符拆分列表来获取来自多个部门的作品
|
,因此?departmentIds=11|9
将返回“欧洲绘画”和“素描和版画”。您可以在API 文档中查看可用的部门。
我们将循环遍历 80 个 ID 的列表,并从原始 ID 列表中随机挑选一件艺术品。我使用了stdlib 的随机样本 (random-sample),它使用Fisher-Yates算法来随机化条目,因为我不想用这类辅助函数让代码过于臃肿。在获取艺术品数据时,我不幸地发现有些艺术品没有可用的图片。这意味着我们需要循环遍历列表,直到获得 80 件带有图片的艺术品。对于故事的主体部分,我们尽量简单,只选择列表中的第一个。我们还会在此过程中存储一些元数据,以便以后使用。
const { objectIDs } = await allObjectIDsResponse.json();
const listOfArtworks = [];
const addedIDs: number[] = [];
while (listOfArtworks.length < 80) {
const randomObjectID = randomSample(objectIDs, { size: 1 })[0];
if (addedIDs.includes(randomObjectID)) continue;
const res = await fetch(
`https://collectionapi.metmuseum.org/public/collection/v1/objects/${randomObjectID}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
}
);
const artwork = await res.json();
if (!artwork.primaryImageSmall) continue;
addedIDs.push(artwork.objectID);
listOfArtworks.push({
image_url: artwork.primaryImageSmall,
artist_name: artwork.artistDisplayName,
title: artwork.title,
is_main: listOfArtworks.length === 0,
is_variant: false,
thought_id,
});
}
const mainImage = listOfArtworks[0];
注意:该
primaryImageSmall
属性包含指向更适合网页格式和大小的链接。此外primaryImage
,还有一个属性,但这些链接可能包含数兆字节的图像!
现在我们进入第一次使用 AI 的部分:从主图像生成提示,该提示将用于生成变体。
我们使用LLaVa 模型来获取主画作的描述。然后,我们从上一步获得的 URL 获取图像文件,并将其作为Stable Diffusion 3 的基础,生成变体。这使我们能够保留原始图像的结构和颜色,仅修改其中某些部分,使其看起来像是副本。根据strength
参数的不同,最终结果要么与原始图像完全相同,要么截然不同。如果您传递了1
,SD 会认为您根本没有传递图像,因此我们会选择一个介于0.4
和之间的数字0.7
,这将为混合中的原始图像提供 30-60% 的强度。
const output = await replicate.run(
"yorickvp/llava-13b:b5f6212d032508382d61ff00469ddda3e32fd8a0e75dc39d8a4191bb742157fb",
{
input: {
image: mainImage.image_url,
top_p: 1,
prompt: "Describe this painting by " + mainImage.artist_name,
max_tokens: 1024,
temperature: 0.2,
},
}
);
const file = await fetch(mainImage.image_url).then((res) => res.blob());
const promises = [];
for (let i = 0; i < 8; i++) {
const body = new FormData();
body.append(
"prompt",
output.join("") + `, a painting by ${mainImage.artist_name}`
);
body.append("output_format", "jpeg");
body.append("mode", "image-to-image");
body.append("image", file);
body.append("strength", clamp(Math.random(), 0.4, 0.7));
const request = fetch(
`${Deno.env.get(
"STABLE_DIFFUSION_HOST"
)}/v2beta/stable-image/generate/sd3`,
{
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${Deno.env.get("STABLE_DIFFUSION_API_KEY")}`,
},
body,
}
);
promises.push(request);
}
新的稳定版 Diffusion 3 端点用于
FormData
接收参数,这与之前的 JSON 负载有所不同。值得庆贺的改进是,它现在可以返回 JPEG 或 WebP 格式,而不是 PNG 格式,这意味着我们可以节省存储空间!
最后,我们将处理将变体存储到 Supabase 存储中,并更新思维记录,使其不再处于生成状态。这非常简单:首先,我们从大都会博物馆 API(即图片链接)批量插入所有艺术品,然后将每个生成的变体上传到存储中。我使用Math.random
“随机”ID 作为图片的文件名,因为我不在乎它们的名称(语义上):它们只会被放置在特定目录下,路径中包含思维 ID,这有助于我们追踪哪些图片属于哪个思维。我们还会在“artwork”表中创建包含图片链接的记录,这样我们就可以毫不费力地获取这些图片以及其他内容。
const results = await Promise.all(promises);
const variants = await Promise.all(results.map((res) => res.json()));
await supabaseClient.from("artworks").insert(listOfArtworks);
for (let i = 0; i < variants.length; i++) {
const variant = variants[i];
const randomId = Math.random();
await supabaseClient.storage
.from("variants")
.upload(
`${thought_id}/${randomId}.jpeg`,
base64.toArrayBuffer(variant.image),
{
contentType: "image/jpeg",
}
);
await supabaseClient.from("artworks").insert({
image_url: `${Deno.env.get(
"SUPABASE_URL"
)}/storage/v1/object/public/variants/${thought_id}/${randomId}.jpeg`,
artist_name: mainImage.artist_name,
is_main: false,
is_variant: true,
thought_id,
});
}
await supabaseClient
.from("thoughts")
.update({ generating: false })
.eq("id", thought_id);
最后更新想法记录将触发 Webhook 调用,从而触发 Cloudflare 页面中的部署。这是因为该网站不使用服务器端渲染来动态访问数据,因此每次有新想法(生成内容后完成)时,我们都需要重建它。我们将来会添加服务器端渲染 (SSR),以便您可以来回浏览想法,了解它与原始想法的进展情况!
生成语音
第二个函数,我认为是这个项目最酷的部分,用于针对网站中的某些文本生成 AI 语音。它如下所示,我们将在下面进行分解。
import { corsHeaders } from "../_shared/cors.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.42.3";
import Replicate from "https://esm.sh/replicate@0.29.1";
import randomSample from "https://esm.sh/@stdlib/random-sample@0.2.1";
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
// Setup type definitions for built-in Supabase Runtime APIs
/// <reference types="https://esm.sh/@supabase/functions-js/src/edge-runtime.d.ts" />
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
);
const replicate = new Replicate({
auth: Deno.env.get("REPLICATE_API_TOKEN") ?? "",
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
const voice = randomSample(["Scarlett", "Dan", "Liv", "Will", "Amy"], {
size: 1,
})[0];
const { record } = await req.json();
const thought_id = record.id;
const thoughtIdToUse = thought_id === 1 ? thought_id : thought_id - 1;
const { data: thought } = await supabaseClient
.from("thoughts")
.select(
`
id,
thought_texts(
index,
text
)
`
)
.eq("id", thoughtIdToUse);
const thoughtTexts = thought[0].thought_texts.sort(
(a, b) => a.index - b.index
);
for (let i = 0; i < thoughtTexts.length; i++) {
let newText = thoughtTexts[i].text;
if (thought_id !== 1) {
const input = {
prompt: `Write the following differently but keep the style of it being your thought: ${thoughtTexts[i].text}`,
temperature: 1.0,
frequency_penalty: 1.0,
prompt_template:
"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\nReturn info wrapped as text snippet wrapped in \`\`\`. If your suggestion is longer than 150 characters, make it shorter.<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n{prompt}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n",
};
const textResponse = await replicate.run(
"meta/meta-llama-3-70b-instruct",
{
input,
}
);
newText = textResponse.join("").replace(/\`\`\`/g, "");
}
const options = {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
Authorization: `Bearer ${Deno.env.get("UNREAL_SPEECH_API_KEY")}`,
},
body: JSON.stringify({
Text: newText,
VoiceId: voice,
Bitrate: "192k",
Speed: "0",
Pitch: voice === "Dan" || voice === "Will" ? "0.96" : "1.0",
TimestampType: "word",
}),
};
const voiceResponse = await fetch(
Deno.env.get("UNREAL_SPEECH_API_URL"),
options
);
const voiceResponseJson = await voiceResponse.json();
const blob = await fetch(voiceResponseJson.OutputUri).then((r) => r.blob());
const jsonBlob = await fetch(voiceResponseJson.TimestampsUri).then((r) =>
r.blob()
);
await supabaseClient.storage
.from("speeches")
.upload(`${thought_id}/${i}.json`, jsonBlob, {
contentType: "application/json",
});
await supabaseClient.storage
.from("speeches")
.upload(`${thought_id}/${i}.mp3`, blob, {
contentType: "audio/mpeg",
});
await supabaseClient.from("thought_texts").insert({
thought_id,
text: newText,
audio_url: `${Deno.env.get(
"SUPABASE_URL"
)}/storage/v1/object/public/speeches/${thought_id}/${i}.mp3`,
index: i,
});
}
await supabaseClient
.from("thoughts")
.update({ generating: false })
.eq("id", thought_id);
return new Response(JSON.stringify({ check: "database" }), {
headers: { "Content-Type": "application/json" },
});
});
创建 AI 语音其实并不难,但要找到一个不花钱的 API 却有点难。幸运的是,我遇到了Unreal Speech,它提供了相当慷慨的月度免费套餐。然而,这不足以让我们的网站运行一个多月,所以我需要做一些小技巧来维持运行(或者干脆掏钱)。
使用虚幻语音,你可以从五种声音中选择:“Scarlett”、“Dan”、“Liv”、“Will”和“Amy”。我们只需要一种声音,因为叙述者(即艺术家)应该始终是一个单一的“人”,所以我们将使用相同的random-sample
助手来选择其中一种声音。
const voice = randomSample(["Scarlett", "Dan", "Liv", "Will", "Amy"], {
size: 1,
})[0];
然后,我们不会总是用不同的声音大声朗读相同的文本,而是为每个新的思想记录生成新的文本。为了做到这一点,我们需要以前的思想文本作为这些新文本的基础,以便我们在每次迭代中保持原来的含义。在这里,我们使用Meta 的 LLama 3模型来生成文本,因为它既便宜又快速。promp 模板使用特殊语法来指示模型以特定方式回复。它的工作方式与 GPT4 类似,但是我更喜欢 JSON 数组语法。在模型的复制页面中,有关于如何使用此模板语法的指南。模型以流的形式返回文本,但是由于我们只想要一次获得整个字符串,因此最后我们需要将每个单词连接在一起。
const { data: thought } = await supabaseClient
.from("thoughts")
.select(
`
id,
thought_texts(
index,
text
)
`
)
.eq("id", thoughtIdToUse);
const thoughtTexts = thought[0].thought_texts.sort(
(a, b) => a.index - b.index
);
for (let i = 0; i < thoughtTexts.length; i++) {
let newText = thoughtTexts[i].text;
if (thought_id !== 1) {
const input = {
prompt: `Write the following differently but keep the style of it being your thought: ${thoughtTexts[i].text}`,
temperature: 1.0,
frequency_penalty: 1.0,
prompt_template:
"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\nReturn info wrapped as text snippet wrapped in \`\`\`. If your suggestion is longer than 150 characters, make it shorter.<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n{prompt}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n",
};
const textResponse = await replicate.run(
"meta/meta-llama-3-70b-instruct",
{
input,
}
);
newText = textResponse.join("").replace(/\`\`\`/g, "");
}
// More code here...
}
我们最初的想法是保留原始故事文本并为其生成语音。
接下来我们开始生成音频。我们将使用一些参数对 Unreal Speech API 进行常规 POST 调用:
VoiceId
是我们选择的声音Bitrate
是音质,我们将保持为 192k,以使文件相对较小Speed
为 0,所以我们让 AI 以“正常”速度说话,而不是减慢或加快速度- 如果声音是男声,我们会对“音调”进行一些调整,使其听起来更深沉一些
- “TimestampType” 允许我们定义是否需要按句子或单词添加时间戳的文本记录。我们选择了“word”,因为我们计划在音频播放时在应用程序中进行单词高亮显示(因为这次黑客马拉松没时间实现这个功能)。
请注意,我们使用的是/speech
Unreal Speech 的端点。它会将音频和文字记录临时上传到 Unreal Speech 的服务器上,并提供这些文件的 URL。此外,还有一个/stream
端点允许您在 0.3 秒内流式传输音频,并/synthesisTasks
允许您创建定时音频生成。
音频生成完成后,我们从提供的 URL 获取音频和文字记录文件,并将其上传到 Supabase 存储。最后,我们将新文本和音频文件的引用插入到表中thought_texts
。
const options = {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
Authorization: `Bearer ${Deno.env.get("UNREAL_SPEECH_API_KEY")}`,
},
body: JSON.stringify({
Text: newText,
VoiceId: voice,
Bitrate: "192k",
Speed: "0",
Pitch: voice === "Dan" || voice === "Will" ? "0.96" : "1.0",
TimestampType: "word",
}),
};
const voiceResponse = await fetch(
Deno.env.get("UNREAL_SPEECH_API_URL"),
options
);
const voiceResponseJson = await voiceResponse.json();
const blob = await fetch(voiceResponseJson.OutputUri).then((r) => r.blob());
const jsonBlob = await fetch(voiceResponseJson.TimestampsUri).then((r) =>
r.blob()
);
await supabaseClient.storage
.from("speeches")
.upload(`${thought_id}/${i}.json`, jsonBlob, {
contentType: "application/json",
});
await supabaseClient.storage
.from("speeches")
.upload(`${thought_id}/${i}.mp3`, blob, {
contentType: "audio/mpeg",
});
await supabaseClient.from("thought_texts").insert({
thought_id,
text: newText,
audio_url: `${Deno.env.get(
"SUPABASE_URL"
)}/storage/v1/object/public/speeches/${thought_id}/${i}.mp3`,
index: i,
});
最后,我们更新思维记录,使其不再处于生成状态。这只是为了确保当所有新生成的数据都到位时,我们始终触发 Webhook 来构建网站。
await supabaseClient
.from("thoughts")
.update({ generating: false })
.eq("id", thought_id);
计划任务和 Webhook
现在我们已经完成了主要功能,我们可以使用触发器执行一些简单的计划作业。我们需要做的就是pg_cron
在数据库设置中启用扩展和 Webhook 功能。
完成此操作后,以下 SQL 代码片段可让我们使先前的想法无效(即,使它们不再是活跃的想法),然后每小时创建一个新的想法。非常简单!
create function create_thought () returns void as $$
BEGIN
UPDATE thoughts
SET current = false
WHERE current = true;
INSERT INTO thoughts (created_at)
VALUES (NOW());
END;
$$ language plpgsql;
select cron.schedule ('hourly-thoughts', '0 * * * *', 'SELECT create_thought()');
而且由于我们只需要在有新想法时触发生成过程,我们只需要创建两个 Webhook 来监听这些数据库事件并触发对 Edge Functions 的调用:一个用于generate
,一个用于create-speech
之前除了稍微体验了一下 UI 之外,我还没用过 Webhook 功能,所以当我看到(我好像在一些博客文章里看到过,所以提醒自己一下……)居然可以直接从设置里选择一个 Supabase Edge Function 时,我感到非常惊喜!太酷了。
记得添加
Authorization
包含 token 值的 header,Bearer
这样 Function 触发器才能与这些 Webhook 配合使用!否则,您会遇到未授权的错误(这些错误可以在 Function 日志中捕获)。如果默认设置了这项功能就更好了。
还记得之前我们更新记录thought
,将generating
状态改为 false 吗?每次发生这种情况时,我们都需要触发新的部署吗?我们使用相同的 Webhook 功能来实现这一点!因此,与上面其他 Webhook 类似,唯一的区别是这次选择的UPDATE
是事件,保留选项HTTP Request
作为类型,然后只需输入提供商的部署构建 Webhook URL 作为 URL 值即可。
就这样:现在我们有了一个自动生成的网站!在这个网站上,我只需获取当前的想法,并显示我拥有的关于这个想法的所有数据。这种获取操作只在网站构建时执行一次,速度非常快。它还能减少流向 Supabase 实例的流量,这总是一件好事:它是一种可扩展的解决方案(在某种程度上)。
// The only Supabase call in client code
const { data: thought } = await supabase
.from('thoughts')
.select(`
id,
created_at,
current,
generating,
thought_texts(
index,
audio_url,
text
),
artworks(
is_main,
is_variant,
image_url,
artist_name,
title
)
`)
.eq('current', true)
.single()
就造型和我们设想的内容而言,这是一个非常雄心勃勃的项目(现在依然如此),我们俩都非常喜欢它背后的理念。对我们来说,这真的是一个艺术项目。
虽然我很高兴我们在黑客马拉松期间完成了项目,但我还是有点失望,因为我们没能完成所有想做的事情。最后,我们在滚动(仍在学习GSAP)和其他样式方面遇到了很多问题。最后,很多滚动条被剪掉了,只能用外推的方式。不过,事情就是这样,我们在整个过程中确实很享受:这些黑客马拉松很棒,以后还会继续参加。
无论如何,请时不时地回到网站,我们会继续构建,直到我们对最终结果满意为止。不过,现在我们要稍事休息,同时专注于我们的其他项目(我好像还没提到我们正在制作一款游戏!)。
希望以上对项目的洞察能给你一些启发,帮助你找到自己的解决方案。记住,不要放弃你的梦想:一切皆有可能,在人工智能的世界里,可能性无穷无尽!
鏂囩珷鏉ユ簮锛�https://dev.to/laznic/building-a-self-creating-website-with-supabase-and-ai-5b90