HiAPI
博客

教程

使用 hiapi API 将 AI 图像生成功能添加到 Next.js 应用

路由处理器代理、异步任务轮询、持久化存储,以及零 CLS——约 150 行 TypeScript 即可实现接近生产环境的集成。

2026年11月3日
使用 hiapi API 将 AI 图像生成功能添加到 Next.js 应用

将图像生成模型接入 Next.js 14 App Router 应用只是一个小项目,但几乎所有人都会在同样的四个关键难点上犯错:

  1. API 需要 60–120 秒——对单次客户端请求来说太长,对大多数边缘运行时来说也太长。
  2. API 密钥不能发送到浏览器,因此需要路由处理器作为代理。
  3. 结果 URL 有效期很短,因此需要在用户刷新之前完成持久化存储。
  4. 图片返回时尺寸未知,如果直接渲染,会严重破坏累积布局偏移(Cumulative Layout Shift)。

本教程将带你完成一次接近生产环境的 hiapi 图像 API 集成——任务创建、轮询、持久化上传,以及 CLS 安全的渲染——大约 150 行 TypeScript。所有代码都可以直接放入真实的 app/ 目录。


异步任务约定

hiapi 为所有图像和视频模型提供一个统一端点:POST https://api.hiapi.ai/v1/tasks。它完全异步——提交任务,轮询直到进入终态,然后下载输出。没有长连接 HTTP,没有 100 秒代理超时,也没有 Edge 运行时的坑。

任务有四种可能状态:

  • pending——已接受但尚未被取走
  • running——正在生成
  • success——output[0].url 已就绪(签名、限时有效)
  • fail——error.code 和 error.message 已填充

单张 1K 图像通常需要 30–120 秒完成。其中大部分时间用于模型运行;API 本身不增加可测量的开销。


第 1 步:项目设置

你需要一个 Next.js 14+ App Router 项目和一个环境变量:

# .env.local
HIAPI_API_KEY=sk-...   # 切勿暴露给浏览器

将此密钥作为服务端机密添加到你的部署平台(Vercel、Cloudflare Pages、Railway)。不要给它加上 NEXT_PUBLIC_ 前缀。

如果你想要持久化存储(推荐——第 5 步会涵盖),还需添加:

R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_BASE=https://cdn.your-domain.com   # 存储桶上的自定义域名

第 2 步:路由处理器——为什么需要它

路由处理器存在的唯一原因:将 API 密钥保留在服务器端。从 React 组件发起的任何请求都在用户的浏览器中运行,而编译进客户端代码的任何字符串都离被窃取只差一个 DevTools 标签页。

路由处理器还为你提供了一个单一、可审计的位置,用于在请求到达付费端点之前实施速率限制、记录使用情况并验证提示词。

创建 app/api/images/route.ts:

// app/api/images/route.ts
import { NextResponse } from "next/server";

export const runtime = "nodejs";          // 长时间 fetch 需要 Node,而非 Edge
export const maxDuration = 180;           // Vercel:延长无服务器超时

const HIAPI = "https://api.hiapi.ai/v1/tasks";

type CreateBody = {
  prompt: string;
  model?: string;
  aspectRatio?: string;   // 例如 "16:9"、"3:2"、"1:1"
  resolution?: "1K" | "2K" | "4K";
};

export async function POST(req: Request) {
  const body = (await req.json()) as CreateBody;
  if (!body?.prompt || body.prompt.length > 2000) {
    return NextResponse.json({ error: "invalid prompt" }, { status: 400 });
  }

  const res = await fetch(HIAPI, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.HIAPI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: body.model ?? "Nano-Banana-2",
      input: {
        prompt: body.prompt,
        aspect_ratio: body.aspectRatio ?? "16:9",
        resolution: body.resolution ?? "1K",
      },
    }),
  });

  if (!res.ok) {
    const text = await res.text();
    return NextResponse.json({ error: text }, { status: res.status });
  }

  const { data } = await res.json();
  // data.taskId 是客户端用于轮询的句柄
  return NextResponse.json({ taskId: data.taskId });
}

有两个设置很重要,但很容易忘记:

  • runtime = "nodejs"——在许多环境中,Edge 运行时将单次 fetch 限制在约 25 秒,不适合轮询循环。
  • maxDuration = 180——Vercel 无服务器函数在 Hobby 计划下默认 10 秒,Pro 计划下 60 秒;你必须手动提高此值,否则如果你选择在服务端等待,路由会在轮询中途被终止。

第 3 步:轮询端点(或者:为什么不要在服务端等待)

向客户端展示进度有两种选择:

方案 优点 缺点
服务端等待后一次性返回 只需一次客户端请求 占用无服务器调用 60–120 秒,容易超时,没有进度反馈
服务端返回 taskId,客户端轮询 服务端成本低,实时进度 需要多构建一个端点

在生产环境中,第二种方案明显更好。添加 app/api/images/[id]/route.ts:

// app/api/images/[id]/route.ts
import { NextResponse } from "next/server";

export const runtime = "nodejs";

const HIAPI = "https://api.hiapi.ai/v1/tasks";

export async function GET(
  _req: Request,
  { params }: { params: { id: string } },
) {
  const res = await fetch(`${HIAPI}/${params.id}`, {
    headers: { "Authorization": `Bearer ${process.env.HIAPI_API_KEY}` },
    cache: "no-store",
  });

  if (!res.ok) {
    return NextResponse.json({ error: await res.text() }, { status: res.status });
  }

  const { data } = await res.json();
  // status ∈ { pending, running, success, fail }
  return NextResponse.json({
    status: data.status,
    url: data.output?.[0]?.url ?? null,
    error: data.error ?? null,
  });
}

这个端点就是客户端每 3 秒轮询的对象。每次调用都很快(不到 200ms)且无状态。


第 4 步:客户端组件

一个简单的表单、带加载指示器的加载状态,以及一个结果画布。轮询通过 setInterval 设置,并在进入终态或组件卸载时清除。

// app/generate/page.tsx
"use client";

import { useEffect, useRef, useState } from "react";

type Status = "idle" | "queued" | "running" | "done" | "error";

export default function GeneratePage() {
  const [prompt, setPrompt] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [url, setUrl] = useState<string | null>(null);
  const [taskId, setTaskId] = useState<string | null>(null);
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => () => {
    if (pollRef.current) clearInterval(pollRef.current);
  }, []);

  async function start() {
    setStatus("queued");
    setUrl(null);
    const r = await fetch("/api/images", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt, aspectRatio: "16:9" }),
    });
    if (!r.ok) return setStatus("error");
    const { taskId } = await r.json();
    setTaskId(taskId);
    setStatus("running");

    pollRef.current = setInterval(async () => {
      const p = await fetch(`/api/images/${taskId}`).then(x => x.json());
      if (p.status === "success") {
        setUrl(p.url);
        setStatus("done");
        clearInterval(pollRef.current!);
      } else if (p.status === "fail") {
        setStatus("error");
        clearInterval(pollRef.current!);
      }
    }, 3000);
  }

  return (
    <main className="mx-auto max-w-2xl p-8 space-y-6">
      <textarea
        value={prompt}
        onChange={e => setPrompt(e.target.value)}
        rows={3}
        className="w-full border rounded p-2"
        placeholder="A sunset over a misty pine forest, cinematic, 16:9"
      />
      <button
        onClick={start}
        disabled={!prompt || status === "queued" || status === "running"}
        className="px-4 py-2 rounded bg-black text-white disabled:opacity-50"
      >
        {status === "running" ? "Generating…" : "Generate"}
      </button>

      {/* 预留画布——为什么这个精确宽高比的 div 很重要,见第 6 步 */}
      <div className="relative w-full" style={{ aspectRatio: "16/9" }}>
        {status === "running" && (
          <div className="absolute inset-0 grid place-items-center bg-neutral-100 rounded">
            <span className="text-neutral-500">Waiting on task {taskId?.slice(0, 8)}…</span>
          </div>
        )}
        {url && (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={url}
            alt={prompt}
            className="absolute inset-0 w-full h-full object-cover rounded"
          />
        )}
      </div>
    </main>
  );
}

一些生产环境的改进值得现在注意,之后可以逐步加入:

  • 指数退避(3 秒 → 5 秒 → 8 秒)可减少慢速生成时的请求数量。
  • 空闲超时(例如 240 秒后放弃)可防止对已死亡任务的僵尸轮询。
  • 显示部分进度文本——"running" 与 "pending"——让用户确信有事情在发生。

第 5 步:持久化结果——输出 URL 会过期

使用任何现代图像 API 时,最常见的错误就是把 output[0].url 存入数据库然后以为万事大吉。那些 URL 是签名的、短命的(通常几分钟到几小时),用户一刷新就会 404。

解决办法是将字节通过你自己的服务器管道传入持久化存储。同一个 app/api/images/[id]/route.ts 处理器就是正确的位置——只在第一次收到 success 响应时执行,在将 URL 返回给客户端之前。

// 在 `data.status === "success"` 检查之后添加到 app/api/images/[id]/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

async function persist(taskId: string, sourceUrl: string): Promise<string> {
  const bytes = new Uint8Array(await (await fetch(sourceUrl)).arrayBuffer());
  const key = `generations/${taskId}.jpg`;
  await s3.send(new PutObjectCommand({
    Bucket: process.env.R2_BUCKET!,
    Key: key,
    Body: bytes,
    ContentType: "image/jpeg",
    CacheControl: "public, max-age=31536000, immutable",
  }));
  return `${process.env.R2_PUBLIC_BASE}/${key}`;
}

在状态路由中,一旦状态变为 success,就使用 persist(),然后向客户端返回持久化的 URL,而不是上游的签名 URL。客户端永远不会看到会过期的 URL,明天刷新仍然有效。

如果你持久化到你自己的数据库(Postgres、Supabase 等),请存储持久化 URL——而不是签名 URL。


第 6 步:消除 CLS

累积布局偏移是 AI 图像应用上核心网页指标的无声杀手。图片在页面加载 60 多秒后才到达。如果你在一个未设定尺寸的容器中用 <img> 渲染它,每次结果都会移动其下方的所有内容。

两种可靠的技术:

用 CSS 预留宽高比。这就是上面组件中 aspectRatio: "16/9" 样式所做的。无论图片是否已加载,容器都会保持其空间。

在请求中匹配宽高比。向 hiapi 发送 aspectRatio: "16:9",然后在容器上声明 16/9。两者必须一致,否则会出现黑边,或者更糟——形状与预留的不一致。

对于 Next.js 的 <Image> 组件,同样的原则也适用:传入与请求比例匹配的 width 和 height 属性。除非父元素已经是固定尺寸的盒子,否则不要使用 fill。

import Image from "next/image";

<Image
  src={persistedUrl}
  alt={prompt}
  width={1280}
  height={720}
  className="rounded"
  priority={false}
/>

第 7 步:选择模型并估算成本

hiapi 上的图像模型共享相同的端点结构——只有 model 字段不同。常见选择,默认分辨率下的每张图片价格:

模型 价格 / 1K 图像 优势
qwen-image-2.0 $0.025 最便宜;默认 2K;强大的中日韩文字渲染能力
gpt-image-2 $0.03 宽高比可靠;强大的排版能力
Nano-Banana $0.05 非常快(1–2 秒);角色一致性好
flux-1.1-pro $0.05 偏艺术风格的照片级真实感
Nano-Banana-2 $0.085(1K)/ $0.076(2K)/ $0.114(4K) 高端质量,原生 4K

诚实的默认选择取决于任务。对于 UI 模型稿和标题,gpt-image-2 最稳妥。对于原生 4K 的照片级真实感,选 Nano-Banana-2。对于每张图扔掉也不心疼的批量工作,选 qwen-image-2.0 或 Nano-Banana。

对于一个 10 万用户的应用,每个会话每天生成一张图片:

  • qwen-image-2.0:$2,500/天
  • Nano-Banana-2 1K:$8,500/天

上述架构不变。只有第 2 步中的 model 字段会变。


第 8 步:生产环境检查清单

上线前:

  1. 对路由处理器实施速率限制——否则一个 IP 就能在一个下午耗尽你的月度预算。Upstash、Cloudflare Rate Limiting,或简单的 Redis 计数器都可以。
  2. 在服务端转发前验证提示词长度和内容。hiapi 平台会拒绝某些内容;你不会想为 400 错误支付往返延迟。
  3. 设置每用户每日配额——按 userId 和 YYYY-MM-DD 存储生成次数,并在当天第 11 次时用友好的提示拒绝。
  4. 在响应前持久化——永远不要让结果 URL 在未经过持久化存储之前到达客户端。
  5. 预设置结果容器尺寸以匹配请求的宽高比。跳过这一步就是 CLS 为 0 和为 0.4 的区别。
  6. 将持久化步骤放到后台以让客户端亚秒级响应:立即返回上游 URL,启动队列任务镜像到 R2 并修补数据库行。

最后一点值得再多说一句:大多数应用不需要它。内联持久化让代码保持简单,额外 1–2 秒发生在 60 秒等待之后,此时用户已经在看加载指示器了。


接下来

你现在拥有一个完整、安全、CLS 安全的 hiapi 图像 API 的 Next.js 集成,大约 150 行 TypeScript。从这里开始,同样的 POST /v1/tasks 模式可扩展到视频模型(Seedance 2.0 用于电影感素材)、图生图流程(只需在 input 内添加 image_url)以及多轮编辑——所有这些都无需改变轮询架构。

如果你想要一个可工作的起点,hiapi 模型页面列出了所有响应此端点的模型,并给出了每个模型的示例输入结构。将其中的任何一个放入上面的 model 字段,同一个组件就能渲染结果。

立即开始构建持久 AI 产物

获取 API 密钥,通过一个基于任务(task)的 API 调用图像、视频和音频模型。

阅读 API 文档