📖

Nuxtで対話型AIを作成する

に公開

概要

Nuxtを使って chatgptgeminiのような対話型UIを構築します.
今回はvertexのgemini apiを利用します.

準備

それとなくNuxtの環境を用意してください.

https://github.com/KOBATATU/nuxt-vertex-chat/tree/main

package.jsonは以下

{
  "name": "vertex-gemini-ui",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "nuxt build",
    "dev": "nuxt dev",
    "generate": "nuxt generate",
    "preview": "nuxt preview",
    "postinstall": "nuxt prepare"
  },
  "dependencies": {
    "@google-cloud/vertexai": "^1.10.0",
    "nuxt": "^3.16.2",
    "vue": "^3.5.13",
    "vue-router": "^4.5.0"
  },
  "devDependencies": {
    "@tailwindcss/vite": "^4.1.5",
    "@types/node": "^22.15.2",
    "tailwindcss": "^4.1.5"
  }
}

vertex 準備

google consoleで準備する必要があります.以下のクイックスタートの準備そのままで良いです

https://cloud.google.com/vertex-ai/generative-ai/docs/reference/nodejs/latest

  • Make sure your node.js version is 18 or above.
  • Select or create a Google Cloud project.
  • Enable billing for your project.
  • Enable the Vertex AI API.
  • Install the gcloud CLI.
  • Initialize the gcloud CLI.
  • Create local authentication credentials for your user account:
gcloud auth application-default login
  • A list of accepted authentication options are listed in GoogleAuthOptions > interface of google-auth-library-node.js GitHub repo.
  • Official documentation is available in the Vertex AI SDK Overview page. From here, a complete list of documentation on classes, interfaces, and enums are available.

gcloud auth application-default loginしてvertexのapiを有効化していれば使えます.

あるいは サービスアカウントを発行後,vertex aiユーザの権限を付与することで使えます.その場合keyを発行してプロジェクト直下に配置します..envにてGOOGLE_APPLICATION_CREDENTIALSjsonファイルの絶対パスを指定することで有効化されます

vertex api

vertext apiを利用するため@google-cloud/vertexaiのライブラリを利用します.

用意する順番は以下

  • model用意
  • stream or content
    • stream形式でデータを受け取るか全てのデータを一度で受け取るか

この2つの処理をする必要があります.

import { VertexAI, type BaseModelParams } from "@google-cloud/vertexai";

<!-- model作成 -->
<!-- modelに対して色々なパラメータを入れることができるがここでは省略 -->
export function getModel(params?: BaseModelParams) {
  const runtimeConfig = useRuntimeConfig();
  const vertexAI = new VertexAI({
    <!-- locationはus-central1を使います -->
    project: runtimeConfig.GCP_PROJECT_ID,
    location: runtimeConfig.GCP_LOCATION,
  });
  const generativeModel = vertexAI.getGenerativeModel({
    model: "gemini-2.0-flash",
    ...params,
  });
  return generativeModel;
}
export async function generateText(prompt: string) {
  const model = getModel();

  const resp = await model.generateContent(prompt);
  const contentResponse = resp.response;
  return JSON.stringify(contentResponse);
}

export async function generateTextStream(prompt: string) {
  try {
    const model = getModel();
    const result = await model.generateContentStream({
      contents: [{ role: "user", parts: [{ text: prompt }] }],
    });
    console.log("Successfully initiated content stream.");
    return result.stream;
  } catch (error) {
    console.error("Error in generateTextStream:", error);

    throw createError({
      statusCode: 500,
      statusMessage: "Failed to generate content from AI model.",
    });
  }
}

stream server

Nuxtのserver apiを利用します.h3の標準にsendStreamがあるので使っていきます

    const geminiStream = await generateTextStream(userMessage);
    <!-- NodeのReadableStremを使うことでstreamの処理を行う.startが処理の始まり.途中で通信が遮断された場合はcancelに入る(未実装) -->
    const responseStream = new ReadableStream({
      async start(controller) {
        console.log("[API] Starting to stream response to client...");
        try {
          const result = [];
          <!-- 反復処理プロトコル(https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Iteration_protocols#%E3%82%A4%E3%83%86%E3%83%AC%E3%83%BC%E3%82%BF%E3%83%BC%E3%83%97%E3%83%AD%E3%83%88%E3%82%B3%E3%83%AB) geminiStream内でnextが呼ばれ,doneで判定される -->
          for await (const chunk of geminiStream) {
            const text = chunk?.candidates?.[0]?.content?.parts?.[0]?.text;
            if (text) {
              // SSE 形式. dataと `\n\n`を使うことでデータの区切りですよ,と伝える形式.文字列で返す. ここで形式的に {value: text, done: false} という形でレスポンスされる
              controller.enqueue(
                new TextEncoder().encode(
                  `data: ${JSON.stringify({ text })}\n\n`
                )
              );
            }
            result.push(text);
          }
          console.log(result.join(""));
          console.log("[API] Finished streaming response to client.");
          // {value: null, done: true }となる
          controller.close();
        } catch (error) {
          controller.error(
            new Error(
              "An error occurred while processing the AI response stream."
            )
          );
        }
      },
    });

    event.node.res.setHeader("Content-Type", "text/event-stream");
    event.node.res.setHeader("Connection", "keep-alive");
    event.node.res.statusCode = 200;
    return sendStream(event, responseStream);

ページ側

postの処理です.ページ側はstreamの処理を受け付けるために標準のfetchを利用.getReaderを使って逐次的に読み込みます.

const response = await fetch("/api/gemini/chat", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "text/event-stream",
      },
      body: JSON.stringify({ message: messageContent }),
    });

    if (!response.body) {
      throw new Error("Response body is missing.");
    }

    const stream = response.body;
    const reader = stream.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      /**
       * サーバーが data: メッセージ前半\n\ndata: メッセージ後半\n\n を送ったとしても、クライアントは以下のように2つのチャンクで受け取る可能性
       * 1回目の read(): value = ( data: メッセージ前半\n\ndata: メッセ のバイト列)
       * 2回目の read(): value = ( ージ後半\n\n のバイト列)
       * という感じでのことを考慮してlastChunkを利用する
       */
      buffer += decoder.decode(value);
      const lines = buffer.split("\n\n");
      const lastChunk = lines.pop() || "";

      for (const line of lines) {
        if (line.startsWith("data: ")) {
          try {
            const data = JSON.parse(line.substring(6));
            if (data.text) {
              messages.value[messages.value.length - 1].content += data.text;
              scrollToBottom();
            }
          } catch (e) {
            console.error("Failed to parse SSE data:", line, e);
          }
        }
      }

      if (done) {
        console.log("Stream finished.");
        break;
      }
      buffer = lastChunk;
      await new Promise((resolve) => setTimeout(resolve, 100));
    }
  } catch (error: any) {
    console.error("Error sending message or processing stream:", error);
  } finally {
    isLoading.value = false;
  }

以上

Discussion