Nuxt.js 的事件驱动 AI

在 Nuxt 服务端路由中接收 AI Webhooks,运行自定义服务端逻辑,并回调 ModelRiver:利用 Nitro 内置的事件处理能力。

概述

Nuxt 3 使用 Nitro 作为其服务端引擎,提供了基于文件的服务端路由,非常适合作为 Webhook 处理程序。ModelRiver 事件驱动的 Webhooks 可以自然地与 Nuxt 的自动导入系统、Composables 和服务端中间件集成。

您将构建的内容:

  • 一个用于接收 ModelRiver 发出的 AI 生成数据的 Nitro 服务端路由
  • 使用 Nuxt 服务端中间件进行签名验证
  • 具有回调 ModelRiver 功能的后台处理逻辑
  • 一个用于基于 WebSocket 交付结果的 Vue Composable

快速开始

安装依赖

Bash
npx nuxi@latest init my-ai-app
cd my-ai-app
npm install

环境变量

Bash
# .env
NUXT_MODELRIVER_API_KEY=mr_live_YOUR_API_KEY
NUXT_MODELRIVER_WEBHOOK_SECRET=your_webhook_secret
TYPESCRIPT
1// nuxt.config.ts
2export default defineNuxtConfig({
3 runtimeConfig: {
4 modelriverApiKey: "",
5 modelriverWebhookSecret: "",
6 },
7});

Webhook 处理程序

服务端路由

TYPESCRIPT
1// server/api/webhooks/modelriver.post.ts
2import crypto from "crypto";
3 
4function verifySignature(payload: string, signature: string, secret: string): boolean {
5 const expected = crypto
6 .createHmac("sha256", secret)
7 .update(payload)
8 .digest("hex");
9 
10 return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
11}
12 
13export default defineEventHandler(async (event) => {
14 const config = useRuntimeConfig();
15 const signature = getHeader(event, "mr-signature");
16 const rawBody = await readRawBody(event);
17 
18 // 1. 验证 Webhook 签名
19 if (!signature || !rawBody || !verifySignature(rawBody, signature, config.modelriverWebhookSecret)) {
20 throw createError({ statusCode: 401, statusMessage: "Invalid signature" });
21 }
22 
23 const payload = JSON.parse(rawBody);
24 const { type, event: eventName, ai_response, callback_url, customer_data } = payload;
25 
26 // 2. 处理事件驱动的工作流
27 if (type === "task.ai_generated" && callback_url) {
28 // 处理并回调
29 await processAndCallback(eventName, ai_response, callback_url, customer_data, config);
30 
31 return { received: true };
32 }
33 
34 // 标准 Webhook
35 console.log("收到标准 Webhook:", payload.type);
36 return { received: true };
37});
38 
39async function processAndCallback(
40 eventName: string,
41 aiResponse: { data: Record<string, unknown> },
42 callbackUrl: string,
43 customerData: Record<string, unknown>,
44 config: ReturnType<typeof useRuntimeConfig>
45) {
46 try {
47 let enrichedData = { ...aiResponse.data };
48 
49 // 3. 您的自定义业务逻辑
50 if (eventName === "content_ready") {
51 enrichedData = {
52 ...enrichedData,
53 processed: true,
54 saved_at: new Date().toISOString(),
55 };
56 }
57 
58 // 4. 回调 ModelRiver
59 await $fetch(callbackUrl, {
60 method: "POST",
61 headers: {
62 Authorization: `Bearer ${config.modelriverApiKey}`,
63 "Content-Type": "application/json",
64 },
65 body: {
66 data: enrichedData,
67 task_id: `task_${Date.now()}`,
68 },
69 });
70 
71 console.log(`✅ 已针对事件发送回调: ${eventName}`);
72 } catch (error) {
73 console.error("处理错误:", error);
74 
75 await $fetch(callbackUrl, {
76 method: "POST",
77 headers: {
78 Authorization: `Bearer ${config.modelriverApiKey}`,
79 "Content-Type": "application/json",
80 },
81 body: {
82 error: "processing_failed",
83 message: error instanceof Error ? error.message : "未知错误",
84 },
85 });
86 }
87}

触发异步请求

服务端路由

TYPESCRIPT
1// server/api/ai/generate.post.ts
2export default defineEventHandler(async (event) => {
3 const config = useRuntimeConfig();
4 const body = await readBody(event);
5 
6 const response = await $fetch("https://api.modelriver.com/v1/ai/async", {
7 method: "POST",
8 headers: {
9 Authorization: `Bearer ${config.modelriverApiKey}`,
10 "Content-Type": "application/json",
11 },
12 body: {
13 workflow: "content_generator",
14 messages: [{ role: "user", content: body.prompt }],
15 metadata: body.metadata || {},
16 },
17 });
18 
19 return response;
20});

Vue Composable

TYPESCRIPT
1// composables/useEventDrivenAI.ts
2export function useEventDrivenAI() {
3 const result = ref<Record<string, unknown> | null>(null);
4 const status = ref<"idle" | "processing" | "complete" | "error">("idle");
5 
6 async function generate(prompt: string) {
7 status.value = "processing";
8 result.value = null;
9 
10 try {
11 const { channel_id, ws_token, websocket_channel } = await $fetch("/api/ai/generate", {
12 method: "POST",
13 body: { prompt },
14 });
15 
16 // 通过 WebSocket 连接
17 const ws = new WebSocket(
18 `wss://api.modelriver.com/socket/websocket?token=${encodeURIComponent(ws_token)}`
19 );
20 
21 ws.onopen = () => {
22 ws.send(JSON.stringify({
23 topic: websocket_channel,
24 event: "phx_join",
25 payload: {},
26 ref: "1",
27 }));
28 };
29 
30 ws.onmessage = (event) => {
31 const msg = JSON.parse(event.data);
32 if (msg.event === "response") {
33 result.value = msg.payload.data;
34 status.value = "complete";
35 ws.close();
36 }
37 };
38 
39 ws.onerror = () => {
40 status.value = "error";
41 };
42 } catch (error) {
43 status.value = "error";
44 console.error("触发 AI 生成失败:", error);
45 }
46 }
47 
48 return { result, status, generate };
49}

最佳实践

  1. 使用 useRuntimeConfig():绝不硬编码 API 密钥;Nuxt 会在运行时安全地注入它们。
  2. 使用 $fetch 发起回调:Nitro 内置的 fetch 会自动处理重试和错误解析。
  3. 读取原始 Body 进行验证:使用 readRawBody(event) 获取未经解析的负载以进行 HMAC 验证。
  4. 保持 Webhook 路由简洁:将繁重的处理逻辑卸载到独立的服务端实用程序中。
  5. 使用 Composables:将 WebSocket 逻辑封装在可复用的 Vue Composables 中。

下一步