Convex 文件存储与 Action 实战:用 dall-e-storage-action 示例把 Dall-E 图片持久化到聊天应用
Convex 文件存储与 Action 实战:用 dall-e-storage-action 示例把 Dall-E 图片持久化到聊天应用
本文以开源仓库 convex-backend 中的 dall-e-storage-action 示例应用为研究对象,完整讲解如何将 Convex 的文件存储(Storage)与 Action(服务端操作)组合使用:当用户输入 /dall-e cute cat 之类的指令时,Action 调用 OpenAI 生成图片、下载图片二进制内容并存入 Convex 存储,再把 storageId 写入数据库,最后在查询中按需把 storageId 转换为可访问的图片 URL 展示在聊天流中。读完本文,你将掌握 ctx.storage.store / ctx.storage.getUrl 的完整调用链、convex.config.ts 中环境变量的声明方式,以及前后端如何通过 useAction 触发这一异步流程。
一、示例应用解决的核心问题
OpenAI 的 Dall-E 生成的图片 URL 默认只在一小时内有效,因此如果聊天应用直接把图片 URL 存进数据库,用户很快就会发现图片“过期”打不开。dall-e-storage-action 的解决思路是:在图片 URL 失效之前,把它下载下来并存入 Convex 文件存储,然后只把存储返回的 storageId 写入消息记录;展示时再把 storageId 动态转换为 Convex 托管的图片地址。
这个思路对应 README.md 中描述的三个代码落点,对应到当前仓库中的实际文件为:
- 在 convex/dallE.ts 的 action 中下载图片并存进 Convex 存储,同时把
storageId随消息一起写入; - 在 convex/messages.ts 中把
storageId作为消息正文落库; - 在 convex/messages.ts 的查询中,按需把
storageId转成图片 URL 返回给前端。
应用本身构建在 Convex 官方教程(tutorial)demo 的聊天应用之上,教程版本位于 npm-packages/demos/tutorial,区别在于本示例引入了 Action(Node.js 运行时)与文件存储,使聊天流中能出现 Dall-E 生成的图片消息。
二、运行前的环境准备
1. 安装依赖并初始化部署
按 README.md 的指引,在项目根目录执行:
npm install
npx convex init
其中 convex init 会创建本地 Convex 项目配置并建立部署连接。项目的依赖构成见 package.json:运行时依赖 convex、openai(示例锁定 ^6.0.0)、react 与 react-dom,构建侧使用 Vite 与 TypeScript。
2. 配置 OPENAI_API_KEY 环境变量
本应用的 convex/convex.config.ts 使用 defineApp 显式声明了所需环境变量:
import { defineApp } from "convex/server";
import { v } from "convex/values";
const app = defineApp({
env: {
OPENAI_API_KEY: v.string(),
},
});
export default app;
env: { OPENAI_API_KEY: v.string() } 的含义是:该部署必须配置字符串类型的 OPENAI_API_KEY,否则 Convex 会拒绝接收代码(即 convex dev / deploy 会因缺少环境变量而报错)。这也是文档中“deployment won't accept code until it is set”的原因。
设置环境变量的推荐方式是使用 CLI 交互式输入,避免密钥出现在 shell 历史记录中:
npx convex env set OPENAI_API_KEY
执行后会提示粘贴密钥,输入内容被隐藏。也可以在 Convex dashboard 的 Environment Variables 页面设置。密钥的值在代码中通过 env.OPENAI_API_KEY 读取(见下文 dallE.ts),env 由 convex/_generated/server 导出,类型与 convex.config.ts 的声明严格对应。
3. 启动开发服务器
npm run dev
package.json 中该脚本定义为 convex dev --start 'vite --open',即同时启动 Convex 本地开发服务与 Vite 前端,并自动打开浏览器。随后访问 localhost:3000 即可看到聊天界面。
三、核心实现:Action 中完成“生成—下载—存储—落库”全链路
文件 convex/dallE.ts 是整个示例的心脏。它定义了一个带 "use node" 指令的 Action,运行在 Node.js 运行时上,因此可以自由调用外部 HTTP 服务与第三方 SDK:
"use node";
import OpenAI from "openai";
import { action, env } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const send = action({
args: {
prompt: v.string(),
author: v.string(),
},
handler: async (ctx, { prompt, author }) => {
const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY });
// 1. 内容安全检查:拒绝违规 prompt
const modResponse = await openai.moderations.create({
input: prompt,
});
const modResult = modResponse.results[0];
if (modResult.flagged) {
throw new Error(
`Your prompt was flagged: ${JSON.stringify(modResult.categories)}`,
);
}
// 2. 调用 Dall-E 生成图片
const openaiResponse = await openai.images.generate({
prompt,
size: "256x256",
});
const dallEImageUrl = openaiResponse.data![0]["url"]!;
// 3. 下载图片并存入 Convex 存储,把 storageId 作为消息正文写入
const imageResponse = await fetch(dallEImageUrl);
if (!imageResponse.ok) {
throw new Error(`failed to download: ${imageResponse.statusText}`);
}
const image = await imageResponse.blob();
const storageId = await ctx.storage.store(image as Blob);
await ctx.runMutation(internal.messages.sendDallEMessage, {
body: storageId,
author,
prompt,
});
},
});
这段代码值得逐段拆解:
- 内容安全前置检查:在生成图片之前先用
openai.moderations.create对 prompt 做审查,若返回flagged: true则直接抛出异常,把被标记的类别一并写入错误信息,从源头拦截违规内容。 - 图片生成:
openai.images.generate传入 prompt 与尺寸参数(示例固定为256x256),从响应中取出临时图片 URL。 - 关键一步——下载并持久化:由于 Dall-E 返回的 URL 约一小时后过期,必须立刻
fetch下载并转为Blob,随后调用ctx.storage.store(blob)。store返回的storageId才是需要长期保存的“凭证”,数据库里存的不是 URL,而是这个 ID。 - 跨函数写入数据库:Action 不能直接写数据库,因此通过
ctx.runMutation(internal.messages.sendDallEMessage, ...)调用内部 mutation 完成插入,同时把prompt一并保存以便前端展示图片提示词。
Action 中不能写数据库的原因与 runMutation 的作用
这是本示例体现出的一个重要的 Convex 架构约束:Action 运行在独立于事务引擎的 Node.js 运行时,为了执行长时间的外部 I/O(HTTP 调用等)而设计,因此不具备直接的数据库读写能力。凡是需要落库的数据,都必须通过 ctx.runMutation / ctx.runQuery 委托给 mutation / query 执行,保证数据变更仍发生在 ACID 事务中。本例把“外部 HTTP 耗时操作”与“事务性写入”清晰分离,正是 Action 的标准用法。
四、数据层:storageId 如何随消息持久化
convex/messages.ts 定义了三种函数,分别负责图片消息落库、文本消息落库与消息列表查询:
export const sendDallEMessage = internalMutation({
args: {
body: v.string(),
author: v.string(),
prompt: v.string(),
},
handler: async (ctx, { body, author, prompt }) => {
const message = { body, author, format: "dall-e", prompt };
await ctx.db.insert("messages", message);
},
});
export const send = mutation({
args: {
body: v.string(),
author: v.string(),
},
handler: async (ctx, { body, author }) => {
const message = { body, author, format: "text" };
await ctx.db.insert("messages", message);
},
});
export const list = query({
args: {},
handler: async (ctx) => {
const messages = await ctx.db.query("messages").collect();
for (const message of messages) {
if (message.format === "dall-e") {
message.body = await ctx.storage.getUrl(message.body);
}
}
return messages;
},
});
要点如下:
- 统一的消息表与
format字段:文本消息与图片消息共用messages表,通过format: "text" | "dall-e"区分。图片消息额外携带prompt,正文body保存的是storageId(字符串)。 - 内部 mutation 只允许服务端调用:
sendDallEMessage声明为internalMutation,前端无法直接调用,只能由 Action 通过internal.messages.sendDallEMessage触发,避免用户绕过生成流程直接写入伪造的图片消息。 - 查询时按需转 URL:
list遍历消息时,仅对format === "dall-e"的消息调用ctx.storage.getUrl(message.body),把storageId实时转换成 Convex 托管的可访问地址。这保证了客户端拿到的一直是有效 URL,同时数据库里保存的始终是紧凑、稳定的 ID。 - 本示例未提供
schema.ts,生成的 convex/_generated/dataModel.d.ts 中Doc为宽松的any类型;如需更严格的类型检查,可按该文件中的提示补充 schema 后重新运行npx convex dev触发代码生成。
五、前端:用 useAction 触发长耗时操作
src/App.tsx 展示了客户端如何区分普通文本与 Dall-E 指令,并调用 Action:
const sendDallE = useAction(api.dallE.send);
async function handleSendMessage(event: FormEvent) {
event.preventDefault();
if (
newMessageText.startsWith("/dalle ") ||
newMessageText.startsWith("/dall-e ")
) {
const prompt = newMessageText.split(" ").slice(1).join(" ");
setSending(true);
try {
await sendDallE({ prompt, author: name });
} finally {
setSending(false);
}
} else {
await sendMessage({ body: newMessageText, author: name });
}
setNewMessageText("");
}
- 输入以
/dalle或/dall-e开头时,截取剩余部分作为 prompt,调用useAction(api.dallE.send);否则走普通useMutation(api.messages.send)。 - 渲染图片消息时,直接使用查询返回的 URL 作为
<img src>,并展示prompt与 "Powered by Dall-E (OpenAI)" 的署名:{message.format === "dall-e" ? ( <figure> <img title={message.prompt} src={message.body} /> <div className="dall-e-attribution">Powered by Dall-E (OpenAI)</div> </figure> ) : ( <span>{message.body}</span> )} - 消息列表通过
useQuery(api.messages.list)实时订阅,图片生成期间展示加载动画(sending状态渲染lds-dual-ring指示器)。
六、底层支撑:ctx.storage 存储 API 的原理
示例中的两个核心调用 store 与 getUrl 对应 Convex 客户端 SDK 中定义的存储接口,见 npm-packages/convex/src/server/storage.ts:
StorageReader接口提供getUrl(storageId: GenericId<"_storage">): Promise<string | null>,将存储 ID 解析为可 GET 访问的 URL;文档注释还说明 GET 响应会携带标准 HTTP Digest 头(sha256 校验和)。getMetadata已标记为废弃,推荐改用ctx.db.system.get("_storage", storageId)读取文件元数据(含sha256、size、contentType等字段)。StorageWriter接口提供store(...),用于把文件二进制内容上传到 Convex 存储并获得新的存储 ID。示例中ctx.storage.store(image as Blob)的as Blob转换对应源码注释中提到的类型限制(fetch 返回的 Blob 与 Convex 期望的 Blob 类型存在细微差异,代码中以 TODO 标注,等待后续版本放开)。
由此可以理解整个数据流中“ID 与内容分离”的设计:_storage 是 Convex 的系统表,文件本体与元数据由平台托管,业务表只需保存 Id<"_storage"> 即可实现文件的持久引用、去重与按需取用。
七、运行验证与延伸思考
按上文步骤配置完成后启动 npm run dev,在聊天框输入 /dall-e cute cat,即可看到聊天流中出现生成的猫咪图片。整条链路的可验证点包括:
- Action 中 OpenAI moderation 拦截违规 prompt(可用敏感词验证报错路径);
- 图片消息在
messages表中以storageId形式保存,而非临时 URL; - 查询接口返回的
body已是可访问的图片 URL; - 刷新页面后图片依然可用(不受 Dall-E 一小时过期限制)。
这个示例模式可以推广到其他“外部服务生成内容 → 持久化 → 展示”的场景,例如语音转文字、PDF 生成、视频封面抓取等。核心范式始终一致:在 Action 中完成外部调用与文件下载,用 ctx.storage.store 持久化二进制,用 ctx.runMutation 写入业务数据,在前端查询中用 ctx.storage.getUrl 按需换取访问地址。
八、小结
dall-e-storage-action 虽然是一个小型示例,却完整覆盖了 Convex 生态中三个高频且容易混淆的能力:Action(长耗时外部调用)、文件存储(store / getUrl / _storage 系统表)与内部函数(internalMutation)。通过阅读 convex/dallE.ts、convex/messages.ts 与前端 src/App.tsx,再结合 storage.ts 中的接口文档,即可在实际项目中复刻这套“生成—持久化—按需展示”的完整链路。