mcpField
A
ai-streaming

ai-streaming

github.com/tushaarmehtaa開発1243 回インストール2 週間前に更新
リポジトリ

ai-streaming について

Vercel AI SDK を使う開発者向けに、Web アプリへ AI のストリーミング応答を組み込むスキルです。既存の構成を確認し、サーバールート、クライアントフック、認証、キャンセル、エラー処理を扱います。

主な機能

  1. 01既存の AI プロジェクト構成の確認
  2. 02ストリーミング API ルートの作成
  3. 03チャットと補完フックの接続
  4. 04ストリーミング前の認証検証
  5. 05キャンセルとストリームエラーの処理
  6. 06部分出力とローディング状態の表示

活用シーン

  1. 01Web アプリへのストリーミングチャット追加
  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 のストリーミングで気付かないうちに問題を起こす4つの要因

  1. ルートハンドラーのランタイムが間違っている。 ストリーミングには、Edge ランタイムまたは適切なレスポンス処理を行う Node.js ランタイムが必要です。export const runtime = 'edge' を設定せず、適切なストリーミング設定も行わないと、レスポンス全体がバッファーされ、最後にまとめて「ストリーミング」されます。
  2. 中断処理がない。 生成中にユーザーがページを離れても、モデルは実行を続け、トークンを消費します。必ず 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:クライアントフック

チャットインターフェース(複数ターン):

'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:ストリーミングの読み込み状態

処理する状態は、互いに異なる次の3つです:

// 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));
  }
},

ストリームの開始前にクレジットを差し引かないでください。モデルが最初のトークンでエラーを起こした場合、ユーザーは何も得られないままクレジットを失います。

検証

[ ] 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

コメント