LangChain.jsを使ってLLMに問い合わせるだけの機能を作ってみた

## できたもの
![FocuSee Project 2024-09-02 01-32-44_compressed.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/953141/6f60d070-6d58-3253-02c7-4926341f69fd.gif)

## アプリの概要
別の用途ですでに、LangChainを使っている状態だが、学習の記録のために何か作ってみたかった。

ボリュームは小さく、スクリプトだけで試してみてもよかったが、せっかくなら画面があったほうが良いなぁと思って、とりあえずURLの中身の内容について質問して回答をもらえるような形にしてみた。

### 流れ
1. ユーザーがURLを入力
2. 指定されたURLのコンテンツを取得
3. LangChain.jsとGemini-1.5-proモデルを使用してコンテンツに関しての質問を回答してくれる。

## 実装のポイント

### 1. フロントエンド（React）

まず、シンプルなReactコンポーネントを作成しました。
チャットの入力欄とURLの入力欄を用意しました。

```typescript
import React, { useState, useEffect, useRef } from "react";
import axios from "axios";

const App: React.FC = () => {
	const [input, setInput] = useState("");
	const [urls, setUrls] = useState("");
	const [messages, setMessages] = useState<
		Array<{ role: string; content: string }>
	>([]);
	const [isLoading, setIsLoading] = useState(false);
	const [isLearning, setIsLearning] = useState(false);
	const messagesEndRef = useRef<null | HTMLDivElement>(null);

	useEffect(() => {
		messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
	}, [messages]);

	const streamResponse = async (url: string, data, role: string) => {
		const response = await axios.post(url, data, {
			responseType: "stream",
		});
		const reader = response.data.getReader();
		const decoder = new TextDecoder();

		setMessages((prev) => [...prev, { role, content: "" }]);

		while (true) {
			const { done, value } = await reader.read();
			if (done) break;
			const chunk = decoder.decode(value);
			setMessages((prev) => [
				...prev.slice(0, -1),
				{ role, content: prev[prev.length - 1].content + chunk },
			]);
		}
	};

	const handleLearn = async () => {
		setIsLearning(true);
		try {
			await streamResponse(
				`${API_BASE_URL}/summary`,
				{ urls: urls.split(",").map((url) => url.trim()) },
				"assistant"
			);
		} catch (error) {
			console.error("Error:", error);
			setMessages((prev) => [
				...prev,
				{
					role: "assistant",
					content: "Error: Failed to learn from URLs",
				},
			]);
		}
		setIsLearning(false);
	};

	const handleSubmit = async (e: React.FormEvent) => {
		e.preventDefault();
		if (!input.trim()) return;

		setMessages((prev) => [...prev, { role: "user", content: input }]);
		setInput("");
		setIsLoading(true);

		try {
			await streamResponse(
				`${API_BASE_URL}/chat`,
				{ message: input },
				"assistant"
			);
		} catch (error) {
			console.error("Error:", error);
			setMessages((prev) => [
				...prev,
				{ role: "assistant", content: "Error: Failed to get response" },
			]);
		}
		setIsLoading(false);
	};

	return (
		<div className="flex justify-center items-center min-h-screen bg-gray-100">
			<div className="w-full max-w-6xl p-4">
				<div className="mb-4">
					<input
						type="text"
						value={urls}
						onChange={(e) => setUrls(e.target.value)}
						placeholder="Enter URLs separated by commas"
						className="w-full p-2 border rounded"
					/>
					<button
						onClick={handleLearn}
						disabled={isLearning}
						className="mt-2 p-2 bg-green-500 text-white rounded disabled:bg-gray-400"
					>
						{isLearning ? "Learning..." : "Learn from URLs"}
					</button>
				</div>
				<div className="h-[500px] flex flex-col border rounded bg-white">
					<div className="flex-1 overflow-y-auto p-4 space-y-4">
						{messages.map((message, index) => (
							<div
								key={index}
								className={`max-w-[80%] p-3 rounded ${
									message.role === "user"
										? "bg-blue-500 text-white ml-auto"
										: "bg-gray-200"
								}`}
							>
								{message.content}
							</div>
						))}
						<div ref={messagesEndRef} />
					</div>
					<form
						onSubmit={handleSubmit}
						className="flex p-4 bg-gray-50"
					>
						<input
							type="text"
							value={input}
							onChange={(e) => setInput(e.target.value)}
							placeholder="Type your message..."
							disabled={isLoading}
							className="flex-1 p-2 border rounded-l"
						/>
						<button
							type="submit"
							disabled={isLoading}
							className="p-2 bg-blue-500 text-white rounded-r disabled:bg-gray-400"
						>
							Send
						</button>
					</form>
				</div>
			</div>
		</div>
	);
};

export default App;


```
### 2. バックエンド（Express + LangChain.js）

バックエンドでは、Express.jsでサーバーを立て、LangChain.jsを使用してURL内容の取得と
プロンプトの実行、解答を行います。

```typescript
import express from 'express';
import cors from 'cors';
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { CheerioWebBaseLoader } from "langchain/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";

const app = express();
app.use(cors());
app.use(express.json());

//ここでは、Gemini AIモデルを初期化しています。gemini-1.5-proモデルを使用し
//最大出力トークン数や温度などのパラメータを設定しています。
//APIキーは環境変数から読み込んでいます。
const geminiModel = new ChatGoogleGenerativeAI({
    modelName: "gemini-1.5-pro",
    maxOutputTokens: 2048,
    temperature: 0,
    apiKey: process.env.GOOGLE_API_KEY,
    streaming: true,
});
//この関数は、指定されたURLのWebページの内容をスクレイピングします。
//cheerioを使用してHTMLを解析し、スクリプトとスタイル要素を除去した後
//本文のテキスト内容を抽出します。
async function scrapeWebpage(url: string): Promise<string> {
    try {
        const response = await axios.get(url);
        const html = response.data;
        const $ = cheerio.load(html);

        $("script, style").remove();
        return $("body").text().trim();
    } catch (error) {
        console.error(`Error scraping ${url}:`, error);
        return "";
    }
}

// このエンドポイントでは、以下の処理を行っています：

// 1. 提供されたURLリストから各Webページの内容をスクレイピング
// 2. スクレイピングした内容を結合してlearnedContent変数に保存
// 3. クライアントに処理の進行状況を通知
app.post('/summary', async (req, res) => {
    const { urls } = req.body;

    res.writeHead(200, {
        "Content-Type": "text/plain",
        "Transfer-Encoding": "chunked",
    });

    res.write("URLを確認しています。しばらくお待ちください。\n");

    try {
        const scrapedContents = await Promise.all(urls.map(scrapeWebpage));
        learnedContent = scrapedContents.join("\n\n");

        res.write("URLの情報について質問してください。\n");
        res.end();
    } catch (error) {
        console.error("Error during learning:", error);
        res.write("URLの内容を確認中に、エラーが発生しました。\n");
        res.end();
    }
});

//チャットエンドポイントでは、以下の処理を行っています：

//1. ユーザーからのメッセージを受け取る
//2. 学習した内容とユーザーのメッセージを使用してGemini AIモデルにストリーミングリクエストを送信
//3. AIモデルからの応答をストリーミングで受け取り、クライアントにリアルタイムで送信

app.post("/chat", async (req, res) => {
    const { message } = req.body;

    if (!message || !learnedContent) {
        return res.status(400).json({ error: "Message is required or no data has been learned yet" });
    }

    res.writeHead(200, {
        "Content-Type": "text/plain",
        "Transfer-Encoding": "chunked",
    });

    try {
        const stream = await geminiModel.stream(
            [
                new SystemMessage(`あなたは、以下の情報に基づいて質問に答えるAIアシスタントです：\n\n${learnedContent}\n\n上記の提供された情報のみを用いて質問に答えてください。もし答えが提供された情報に含まれていない場合は、「その情報については知りません」と回答してください。`),
                new HumanMessage(message) as any,// 修正予定
            ],
            {
                callbacks: [
                    {
                        handleLLMNewToken(token: string) {
                            console.log("Received token:", token);
                            res.write(token);
                        },
                    },
                ],
            }
        );

        for await (const chunk of stream) {
            // Content is streamed via the callback
        }

        res.end();
    } catch (error) {
        console.error("Error during chat:", error);
        res.write("回答中にエラーが発生しました。");
        res.end();
    }
});

```
### プロンプトの種類について
プロンプトにはSystemPromptと通常のメッセージがある。

```typescript
// プロンプトタイプの説明
const conversation = [
    new SystemMessage(`あなたは、以下の情報に基づいて質問に答えるAIアシスタントです：...`),
    // SystemMessage: AIの全体的な振る舞いを定義。会話全体に適用される。

    new HumanMessage(message)
    // HumanMessage: ユーザーからの具体的な質問。各ターンで変化する。
];
```


## LangChainライブラリの良かった点
- WebSocketとかで実装をしなくても、**LLMの回答結果をStream機能で配信する**ことができる。Nextjsとかだとこのページが参考になる。**かなり短く実装できるのがうれしい！**
https://js.langchain.com/v0.2/docs/how_to/generative_ui/

- WebスクレイピングとLLMの連携が簡単にできる。cheerioを使用することで、簡単にWebページの内容を取得できるし、プロンプトにも簡単に指定でできる。（Langchain自体にCheerioの機能も入っていたりする）

- サンプルでは行っていないが、プロンプト自体に、結果をJsonやマークダウン形式で絞るように指示することで、出力のフォーマットを固定しながら出力することができた。

- 別件で作成したアプリでは要約にEmmbedingを使っている。URLの要約だけを目的にするなら、もっと簡単に実装することができる。（諸事情により公開できないので下記にサンプルコードを記載する）

## Emmbedingとは

Embedding（埋め込み）は、テキストや単語を数値のベクトルに変換する技術です。これを使うと、コンピュータがテキストの意味を理解しやすくなります。

### 簡単な例：

- 「犬」と「猫」という単語があるとします。
- Embeddingを使うと、これらの単語が例えば以下のようなベクトルに変換されます：
  - 犬 → [0.2, 0.5, 0.1]
  - 猫 → [0.3, 0.4, 0.2]
- これらのベクトルの「近さ」を計算することで、単語の意味の類似性を数値化できます。

### なぜ重要？

1. **意味の理解**: AIが単語や文章の意味をより深く理解できる。
2. **類似性の計算**: 文章や単語の類似性を簡単に計算できる。
3. **検索の改善**: より関連性の高い検索結果を提供できる。

## 2. Langchainのブラウザ機能（WebBrowser）

LangchainのWebBrowserは、AIがウェブページの情報を読み取り、理解するためのツールです。

### 主な機能：

1. **ウェブページの閲覧**: 指定されたURLのウェブページを「読む」ことができます。
2. **情報抽出**: ページの内容から必要な情報を抽出します。
3. **要約**: ページの内容を要約することもできます。

### WebBrowserの使い方の違い：

WebBrowserツールは、引数の与え方によって動作が変わります：

1. **URLのみを指定した場合**:
   - 例: `browser.invoke("https://example.com")`
   - 動作: ウェブページの全体的な要約を生成します。
   - 用途: ページの大まかな内容を把握したい時に便利です。

2. **URLと検索クエリを指定した場合**:
   - 例: `browser.invoke("https://example.com", "specific information")`
   - 動作: ページ内で指定された情報に最も関連する部分を見つけ、その要約を提供します。
   - 用途: ページ内の特定の情報を探したい時に有用です。

### 実際の使用例：

```javascript
const browser = new WebBrowser({ model, embeddings });

// ウェブページの全体要約
const summary = await browser.invoke("https://www.example.com");

// 特定の情報を探す
const specificInfo = await browser.invoke(
  "https://www.example.com",
  "company mission statement"
);

// WebbrowserとEmmbeddingの例

// LLMを定義
const model = new ChatOpenAI({ temperature: 0 });
// embeddingを定義
const embeddings = new OpenAIEmbeddings(
    process.env.AZURE_OPENAI_API_KEY
      ? { azureOpenAIApiDeploymentName: "Embeddings2" }
      : {}
);
// WebBrowser機能を定義
const browser = new WebBrowser({ model, embeddings });

// 変数にURLのみを設定すると、内容を要約してMarkdownで返却してくれる。
const result = await browser.invoke(
`"https://www.themarginalian.org/2015/04/09/find-your-bliss-joseph-campbell-power-of-myth","who is joseph campbell"`
);

console.log(result);
```

## まとめ
Embedding使うほうがURL1つで素早くきれいに要約できるので、Emmbedingモデルを使える環境とか、余力があればEmbedding使うほうが良い

## おまけ
ArcSearchというiPhoneアプリがあるけどそのプロンプト（と思われるもの）がリークされていたので、このプロンプトに当てはめてみると面白い要約結果が得られたと思う。※使用は自己責任です！

- leaked prompt 
https://gist.github.com/dan-palmer/d1e3469c7d4173efd0aeb0a209795f08