AIアシスタントアプリを簡単に作れるTypeScriptパッケージ "my-assistant" を公開しました

# 概要

こんにちは。お盆休み中の自由研究としてAIアシスタントを作ったりしていたのですが、なんかDiscordをLINEに繋ぎ変えたりしているうちに実装が散らかりがちで嫌だなーと思っていたので、今回いい感じにライブラリ化してみました。よければ使ってみてください！

https://github.com/ryukez/my-assistant

# 実装

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/443618/4f4beae7-ced1-f62e-a9ec-4485241b206c.png)

全体像は上の図のようになっています。サーバーのように常駐型で動作するメインプログラムであるProcess、チャットインターフェースとのメッセージのやりとりを行うConnector、そしてアシスタントのコアロジックであるBrainという3つのコンポーネントを組み合わせ、一つのAppとして構築します。詳細は[README](https://github.com/ryukez/my-assistant/blob/main/README-ja.md#%E4%BD%BF%E3%81%84%E6%96%B9)を参照してもらえると幸いです。

# 使い方

使い方のイメージはこんな感じです。（[examples/discordbot/](https://github.com/ryukez/my-assistant/blob/main/src/examples/discordbot/commands/server.ts) のサンプル）

```ts
import { Command } from "commander";
import { OpenAIChatCompletionBrain } from "../../../brain";
import { App } from "../../../app";
import { Server } from "../../../process";
import { DiscordConnector } from "../../../connector";

export const serverCommand = new Command()
  .command("server")
  .description("Run chatbot in server mode")
  .action(async () => {
    const server = new Server();
    const brain = new OpenAIChatCompletionBrain(
      process.env["OPENAI_API_KEY"] ?? ""
    );
    const discordConnector = new DiscordConnector(
      process.env["DISCORD_TOKEN"] ?? "",
      process.env["DISCORD_TEXT_CHANNEL_ID"] ?? ""
    );

    const app = new App(server, brain, [discordConnector]);
    app.run();
  });
```

ここではProcessとしてExpress製のシンプルなhttpサーバー、BrainとしてOpenAIのChatCompletionを使うOpenAIChatCompletionBrain、Discordに接続するためにDiscordConnectorを使っています。
それぞれビルトインでライブラリに含まれているのでライブラリをnpm installしてこのまま使うことができます。

中身が気になる方のためにコードも貼っておきます。責務を細かめに分けているのでそれぞれコード量多くなりすぎずにスッキリと書けているんじゃないかなと思います。

<details><summary>Server</summary>

```ts
import express from "express";
import { logger } from "../logger";
import { Process } from "./process";

export class Server implements Process {
  private app: express.Express;

  constructor(private port: number = 8080) {
    const app = express();

    app.use(express.json());

    app.get("/", (req, res) => {
      res.send("🤖Bot is running!!🤖");
    });

    this.app = app;
  }

  // TODO: abstract server implmentation
  express(): express.Express {
    return this.app;
  }

  async run() {
    this.app.listen(this.port, () => {
      logger.info(`App listening on port ${this.port}`);
    });
  }
}
```

</details>

<details><summary>OpenAIChatCompletionBrain</summary>

```ts
import OpenAI from "openai";
import { Message, TextMessageContent } from "../app";
import { BrainError } from "./brain";

export class OpenAIChatCompletionBrain {
  private client: OpenAI;

  async *respond(message: Message): AsyncGenerator<Message> {
    const completion = await this.client.chat.completions.create({
      model: "gpt-4o-mini-2024-07-18",
      messages: [
        {
          role: "system",
          content: "You are a helpful assistant.",
        },
        {
          role: "user",
          content: message.content.text,
        },
      ],
    });

    if (completion.choices.length === 0) {
      throw new BrainError("internal", "no completion generated");
    }

    yield {
      id: `openai-${Date.now()}`,
      content: new TextMessageContent(
        completion.choices[0].message.content ?? "something wrong"
      ),
    };
  }

  constructor(apiKey: string) {
    this.client = new OpenAI({ apiKey });
  }
}
```

</details>


<details><summary>DiscordConnector</summary>

```ts
import { Client, Message as DiscordMessage, Partials } from "discord.js";
import { Connector } from "..";
import { Message, MessageHandler, TextMessageContent, Thread } from "../../app";

export class DiscordConnector implements Connector {
  private client: Client;
  private threads: Map<string, DiscordMessage[]> = new Map();

  constructor(private token: string, private textChannelId: string) {
    this.client = new Client({
      intents: [
        "DirectMessages",
        "Guilds",
        "GuildMembers",
        "GuildMessages",
        "GuildVoiceStates",
        "MessageContent",
      ],
      partials: [Partials.Message, Partials.Channel],
    });
  }

  async addListener(handler: MessageHandler): Promise<void> {
    this.client.on("messageCreate", async (message: DiscordMessage) =>
      this.onMessage(message, handler)
    );

    await this.client.login(this.token);
  }

  async onMessage(
    message: DiscordMessage,
    handler: MessageHandler
  ): Promise<void> {
    if (message.channelId !== this.textChannelId || message.author.bot) return;

    const id = `discord-user-message-${message.id}`;

    const thread: DiscordThread = {
      id: `discord-thread-${message.id}`,
      messages: [message],
    };

    await handler(this, thread, {
      id,
      content: new TextMessageContent(message.content),
    });
  }

  async sendMessages(
    thread: Thread,
    messages: AsyncGenerator<Message>
  ): Promise<void> {
    const discordThread = thread as DiscordThread;
    if (discordThread.messages.length === 0) {
      throw new Error("thread not found");
    }

    let messageToReply =
      discordThread.messages[discordThread.messages.length - 1];

    for await (const message of messages) {
      const discordMessage = await this.reply(
        messageToReply,
        message.content.text
      );
      messageToReply = discordMessage;
    }
  }

  // for test
  async reply(message: DiscordMessage, text: string): Promise<DiscordMessage> {
    return message.reply(text);
  }
}

type DiscordThread = {
  id: string;
  messages: DiscordMessage[];
};
```

</details>

<details><summary>App</summary>

```ts
import { Brain, BrainError } from "../brain";
import { logger } from "../logger";
import { Connector } from "../connector";
import { Message, Thread } from "./message";
import { Process } from "../process";

export class App {
  constructor(
    private process: Process,
    private brain: Brain,
    connectors: Connector[]
  ) {
    for (const connector of connectors) {
      connector.addListener(this.onMessage.bind(this));
    }
  }

  async run() {
    await this.process.run();
  }

  async onMessage(connector: Connector, thread: Thread, message: Message) {
    try {
      await connector.sendMessages(thread, this.brain.respond(message));
    } catch (e) {
      let text = "Unhandled error";
      if (e instanceof BrainError) {
        text = `Internal error: ${e.message}`;
      } else {
        text = `Unhandled error: ${e}`;
      }
      logger.error(text, { message_id: message.id });
    }
  }
}
```

</details>

これらのコンポーネントはビルトインのものを使うこともできるし、定義されたインターフェースを満たす自前のクラスとして実装することもできます。

実際にアシスタントアプリを作るときは、ConnectorとかはProcessはあまり触る必要がなくてBrainをメインに作り込んでいくことになろうかと思います。あくまでこのライブラリのサポートする部分はConnectorなど「AIアシスタントをアプリに繋ぎこむ部分をいい感じにできる」ところなので、langchain等のAIライブラリはBrain内に組み込んでいくことができてコンフリクトしない、というのがポイントになってます。

# 実際にやってみる

my-assistantパッケージを使って実際にOpenAIとDiscordを使ったアプリを作成してみます。

:::note warn
注意
このチュートリアルではOpenAIのAPIを利用するため料金が発生します。（gpt-4o-miniを使えば、一回あたり1円もかからない程度だと思いますが）
Limitの設定や終わった後のお片付けなど、実行は自己責任でお願いします :pray: 
::::

npmプロジェクトやTypeScriptの設定は省略します。（後の実行に関わるため、tsconfigの設定だけ貼っておきます）

<details><summary>tsconfig.json</summary>


```json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",      
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "exclude": [
    "node_modules"
  ]
}
```

</details>

まず、my-assistantパッケージをインストールしてください。他の必要なパッケージも入れておきます。

```sh
npm install my-assistant@0.0.1
npm install openai@4.55.9
npm install commander@12.1.0
```

まずアシスタントのコアロジックとなるBrainを実装します。今回はチュートリアルのため自前実装していますが、必要ない場合はビルトインのOpenAIBrainを使うこともできます。

今回はLLMの能力を最大限無駄遣いするために「与えられた文章を逆から読み上げる」という指示を与えてみます。ビルトインのOpenAIBrainを参考に、こんな感じで実装します。

```ts
// src/brain/my_assistant.ts
import { BrainError, Message, TextMessageContent } from "my-assistant";
import OpenAI from "openai";

export class MyAssistantBrain {
  private client: OpenAI;

  async *respond(message: Message): AsyncGenerator<Message> {
    const completion = await this.client.chat.completions.create({
      model: "gpt-4o-mini-2024-07-18",
      messages: [
        {
          role: "system",
          content: "与えられた文章を逆から読み上げてください。", // ここに指示したいプロンプトを入れる
        },
        {
          role: "user",
          content: message.content.text,
        },
      ],
    });

    if (completion.choices.length === 0) {
      throw new BrainError("internal", "no completion generated");
    }

    yield {
      id: `openai-${Date.now()}`,
      content: new TextMessageContent(
        completion.choices[0].message.content ?? "something wrong"
      ),
    };
  }

  constructor(apiKey: string) {
    this.client = new OpenAI({ apiKey });
  }
}
```

次に、実装したMyAssistantBrainと、ビルトインのServer、DiscordConnectorを使ってAppを宣言し、実行します。

```ts
// src/commands/server.ts
import { Command } from "commander";
import { App, DiscordConnector, Server } from "my-assistant";
import { MyAssistantBrain } from "../brain/my_assistant";

export const serverCommand = new Command()
  .command("server")
  .description("Run bot server")
  .action(async () => {
    const brain = new MyAssistantBrain(process.env["OPENAI_API_KEY"] ?? "");

    const server = new Server();

    const discordConnector = new DiscordConnector(
      process.env["DISCORD_TOKEN"] ?? "",
      process.env["DISCORD_TEXT_CHANNEL_ID"] ?? ""
    );

    const app = new App(server, brain, [discordConnector]);
    app.run();
  });
```

最後にプログラムのエントリポイントを追加すればコーディングパートは完了です。

```ts
// src/main.ts
import { program } from "commander";
import { serverCommand } from "./commands/server";

program.addCommand(serverCommand);

program.parse(process.argv);
```

実行時には環境変数として次の3つの情報が必要です。

- OPENAI_API_KEY: OpenAIのAPIキー
- DISCORD_TOKEN: Discordのbotトークン
- DISCORD_TEXT_CHANNEL_ID: botを反応させるチャンネルのID

これらの取得方法の詳細は他の方の記事に譲ります。こちらなどを参考にして頂ければ良いかと思います。

https://qiita.com/kurata04/items/a10bdc44cc0d1e62dad3

https://note.com/exteoi/n/nf1c37cb26c41

3つの情報がゲットできたら、プロジェクトのルートでコンソールを開いて環境変数を設定します。

```sh
export DISCORD_TOKEN=xxxxxx
export DISCORD_TEXT_CHANNEL_ID=xxxxxx
export OPENAI_API_KEY=xxxxxx
```

あとはtsをコンパイルしてプログラムを実行するだけです。Discordは常駐型のクライアントが勝手にメッセージ拾ってきてくれるので、Webhookサーバーをクラウドに立てたりする必要がなくて楽でいいですね。

```sh
npx tsc
node dist/main.js server
```

指定したDiscordのチャンネルにbotを招待してメッセージを投下し、返信が返ってきたら成功です :tada: :tada: :tada:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/443618/aec3c98a-1806-828b-315f-03e5bf9face0.png)

普通にめっちゃ間違えてますね。。。笑

# 終わりに

ということで今回は自作したアシスタント作成ライブラリの紹介をしました。皆様のコーディングライフに少しでもお役に立てれば幸いです！

https://github.com/ryukez/my-assistant