mcpField
A
ai-streaming

ai-streaming

github.com/tushaarmehtaa开发1243 次安装2 周前更新
代码仓库

关于 ai-streaming

面向使用 Vercel AI SDK 的开发者,用于在 Web 应用中接入 AI 响应流的技能。它先检查现有项目配置,再介绍服务端路由、客户端 Hook、身份验证、取消请求与错误处理。

主要功能

  1. 01检查现有 AI 项目配置
  2. 02创建流式 API 路由
  3. 03连接聊天与补全 Hook
  4. 04在开始流式响应前验证身份
  5. 05处理取消请求与流错误
  6. 06呈现部分输出和加载状态

适用场景

  1. 01为 Web 应用添加流式聊天
  2. 02构建单轮文本生成
  3. 03避免已离开的请求继续运行
  4. 04排查响应缓冲或流错误

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tushaarmehtaa/tushar-skills --skill ai-streaming

安装后在 Claude Code 中输入/ai-streaming确认已加载。

详细介绍

将模型生成的 AI 响应以流式方式传输到浏览器。使用 Vercel AI SDK——这是适用于 Next.js AI 应用的理想抽象层。先读取项目,再接入现有的身份验证和模型配置。

会悄悄导致 AI 流式传输出错的四件事

  1. 路由处理程序使用了错误的运行时。 流式传输需要 Edge 运行时,或正确处理响应的 Node.js 运行时。如果没有 export const runtime = 'edge' 或适当的流式传输设置,响应就会被完全缓冲,最后一次性全部返回,而不是“流式”返回。
  2. 没有处理中止。 如果用户在生成过程中离开页面,模型会继续运行并消耗 Token。务必传递 AbortController 的 abortSignal,并在组件卸载时进行清理。
  3. 流开始后才检查身份验证。 调用 streamText 后,响应头就会发送。流开始后无法再返回 401。必须在调用任何 SDK 方法之前完成身份验证检查和验证。
  4. 客户端缺少错误边界。 如果流在响应过程中出错,useChat 会在 error 状态中报告错误;但如果不渲染该状态,UI 就会带着部分输出悄然卡住。

阶段 1:检测项目

cat package.json | grep -E "ai|anthropic|openai|@ai-sdk"
  • 存在 ai 包? → 已安装 Vercel AI SDK,进入阶段 3
  • 仅有 @anthropic-ai/sdk? → 在此基础上添加 Vercel AI SDK
  • 仅有 openai? → 在此基础上添加 Vercel AI SDK
  • 都没有? → 从头安装

检查现有的 AI 路由:

find . -name "*.ts" -path "*/api/*" | xargs grep -l "streamText\|createOpenAI\|anthropic" 2>/dev/null

阶段 2:安装

npm install ai @ai-sdk/anthropic
# or for OpenAI:
npm install ai @ai-sdk/openai

添加到 .env.example:

ANTHROPIC_API_KEY=
# or
OPENAI_API_KEY=

阶段 3:路由处理程序

创建流式 API 路由。必须先检查身份验证,再调用任何 SDK:

// app/api/chat/route.ts
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';

export const runtime = 'edge';  // required for true streaming

export async function POST(req: Request) {
  // 1. Auth — must happen before streamText
  const user = await getAuthUser(req);
  if (!user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // 2. Credits / rate limit check (if applicable)
  if (user.credits <= 0) {
    return Response.json({ error: 'No credits remaining', upgradeUrl: '/pricing' }, { status: 402 });
  }

  // 3. Parse the request
  const { messages } = await req.json();

  // 4. Stream
  const result = streamText({
    model: anthropic('claude-sonnet-4-6'),
    messages,
    system: 'You are a helpful assistant.',
    maxTokens: 1024,
    abortSignal: req.signal,  // propagate client abort
    onFinish: async ({ usage }) => {
      // Deduct credits after completion
      await deductCredits(user.id, usage.totalTokens);
    },
  });

  return result.toDataStreamResponse();
}

单轮生成(非聊天):

// app/api/generate/route.ts
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';

export const runtime = 'edge';

export async function POST(req: Request) {
  const user = await getAuthUser(req);
  if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const { prompt } = await req.json();

  const result = streamText({
    model: anthropic('claude-sonnet-4-6'),
    prompt,
    abortSignal: req.signal,
  });

  return result.toDataStreamResponse();
}

阶段 4:客户端 Hook

聊天界面(多轮):

'use client';
import { useChat } from 'ai/react';

export function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error, stop } = useChat({
    api: '/api/chat',
    onError: (err) => console.error('Chat error:', err),
  });

  return (
    <div>
      <div>
        {messages.map((m) => (
          <div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}>
            <p>{m.content}</p>
          </div>
        ))}
        {isLoading && <p className="text-muted animate-pulse">Thinking...</p>}
        {error && <p className="text-red-500">Something went wrong. Try again.</p>}
      </div>

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Ask anything..."
          disabled={isLoading}
        />
        {isLoading ? (
          <button type="button" onClick={stop}>Stop</button>
        ) : (
          <button type="submit">Send</button>
        )}
      </form>
    </div>
  );
}

单轮生成:

'use client';
import { useCompletion } from 'ai/react';

export function Generator() {
  const { completion, input, handleInputChange, handleSubmit, isLoading, error, stop } = useCompletion({
    api: '/api/generate',
  });

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <textarea value={input} onChange={handleInputChange} />
        <button type="submit" disabled={isLoading}>Generate</button>
        {isLoading && <button type="button" onClick={stop}>Stop</button>}
      </form>

      {error && <p className="text-red-500">{error.message}</p>}
      {completion && <div className="whitespace-pre-wrap">{completion}</div>}
    </div>
  );
}

阶段 5:流式加载状态

需要处理三种不同的状态:

// 1. Not started — show the input
// 2. Loading (model thinking, no tokens yet) — show spinner
// 3. Streaming (tokens arriving) — show partial text

function StreamingOutput({ isLoading, completion }: { isLoading: boolean; completion: string }) {
  if (!isLoading && !completion) return null;

  return (
    <div>
      {isLoading && !completion && (
        // Model is thinking — no tokens yet
        <div className="flex gap-1">
          <span className="animate-bounce">·</span>
          <span className="animate-bounce delay-100">·</span>
          <span className="animate-bounce delay-200">·</span>
        </div>
      )}
      {completion && (
        // Tokens arriving
        <p className="whitespace-pre-wrap">
          {completion}
          {isLoading && <span className="animate-pulse">▋</span>}
        </p>
      )}
    </div>
  );
}

阶段 6:将额度接入流

如果项目使用通过 ship-credits 技能创建的额度系统,请在流结束后扣除额度,而不是提前扣除:

// In the route handler onFinish callback:
onFinish: async ({ usage, finishReason }) => {
  if (finishReason === 'stop' || finishReason === 'length') {
    const cost = Math.ceil(usage.totalTokens / 100);  // define your credit cost
    await db.update(users)
      .set({ credits: sql`credits - ${cost}` })
      .where(eq(users.id, user.id));
  }
},

切勿在流开始前扣除额度——如果模型在生成第一个 Token 时就出错,用户就会白白损失额度。

验证

[ ] Route handler exports runtime = 'edge'
[ ] Auth check happens before any streamText call
[ ] req.signal passed as abortSignal to streamText
[ ] useChat/useCompletion renders error state (not just isLoading)
[ ] Stop button shown when isLoading — lets user cancel
[ ] Credits deducted in onFinish, not before the stream
[ ] Partial text renders during stream (not only after complete)
[ ] Test: start generation, navigate away — confirm model call aborts in server logs
[ ] Test: trigger an error — confirm error state renders, not a frozen UI

评论

支持 Markdown