📖
Nuxtで対話型AIを作成する
概要
Nuxtを使って chatgpt
やgemini
のような対話型UIを構築します.
今回はvertex
のgemini apiを利用します.
準備
それとなくNuxtの環境を用意してください.
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で準備する必要があります.以下のクイックスタートの準備そのままで良いです
- 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_CREDENTIALS
のjson
ファイルの絶対パスを指定することで有効化されます
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