Vue.js（Vue3）でAzure OpenAI またはChatGPTのストリーム出力対応する

# 概要
ChatGPTとかBingChatとかのタイピングぬるぬる出てくるのカッコいいので、自分でも実装してみたメモ。
多分、ChatGPTでもそのまま動きます。（APIが完全に一緒なので）

## why?
世の中的情報はそれなりにあるのですが、コマンドプロンプトベースだったり、Jupyterベースだったり、Next.jsベースだったりで
完全一致！って案件がなかったのでまとめ。
ついでに結構真面目にTypeScript書きました。
（が、コンパイル前のvue-tscで謎のバグにはまった、、、）

## 構成
- Node.js
- Vue.js (Vue3 + TypeScript + Composition API）
- Azure OpenAI

# 実装
## 型定義
JSONオブジェクトそのままなのはキモチワルイなーってことで、ちょっとだけ真面目に書いた。
しかしキモチワルイくらいにコード補完が効く。。
みんなChatGPTのコード書いてるんですね！！！
型定義ファイルはどう分けるのが正解なのかまだわかってないので、とりあえず使うところの近傍にって感じ。

``` TypeScript:src\api_call\azureapi.ts
export type ChatMessege = {
  role?: "system" | "user" | "assistant";
  content?: string;
};
export type ChatCompletionMessageChunk = {
  id: string;
  object: string;
  created: number;
  model: string;
  choices: [
    {
      delta: {
        role?: "system" | "user" | "assistant";
        content?: string;
      };
      indexe: number;
      finish_reason: "stop" | "max_tokens" | "timeout";
    }
  ];
};

```

## API呼び出し

``` TypeScript:src\api_call\azureapi.ts
export async function* streamChatCompletion(
  input: ChatMessege[],
  token: String
) {
  const req = JSON.stringify({
    model: openAIConfig.model,
    temperature: openAIConfig.temperature,
    messages: input,
    stream: true, // streamオプションを付ける
  });

  try {
    const api_endpoint = openAIConfig.endpoint;
    const completion = await fetch(api_endpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer " + token,
      },
      body: req,
    });
    if (!completion.ok || !completion.body) throw new Error();

    const reader = completion.body?.getReader();
    if (completion.status !== 200 || !reader) {
      console.error(completion);
      throw new Error("Request failed");
    }
    try {
      const decoder = new TextDecoder("utf-8");
      let done = false;
      let buffer = "";
      while (!done) {
        const { done: readDone, value } = await reader.read();
        if (readDone) {
          done = readDone;
          reader.releaseLock();
        } else {
          buffer += decoder.decode(value, { stream: true });
          while (true) {
            const newlineIndex = buffer.indexOf("\n");
            if (newlineIndex === -1) break;
            const line = buffer.slice(0, newlineIndex);
            buffer = buffer.slice(newlineIndex + 1);

            if (line.includes("[DONE]")) {
              done = true;
              break;
            }
            // レスポンス
            if (line.startsWith("data:")) {
              const chunk = JSON.parse(
                line.slice(5)
              ) as ChatCompletionMessageChunk;
              yield chunk;
            }
          }
        }
      }
    } catch (error) {
      console.error(error);
    } finally {
      reader.releaseLock();
    }
  } catch (error) {
    console.error(error);
    return null;
  }
}

```

ほぼほぼコピペとリファクタしただけなんだけども、ざっくり
ストリームオプションつけて
``` TypeScript
const req = JSON.stringify({
    model: openAIConfig.model,
    temperature: openAIConfig.temperature,
    messages: input,
    stream: true, // streamオプションを付ける
  });
```
fetchしたあとにawaitしないでリーダーを取り付け
``` TypeScript
const reader = completion.body?.getReader();
```

リーダーのループ
``` TypeScript
while (!done) {
    const { done: readDone, value } = await reader.read();
```

それからバッファにdecode
``` TypeScript
buffer += decoder.decode(value, { stream: true });
```

chunkも毎回送られてくるわけでもなく、1回に数行来ることもある、、気がする。
ので一応ループ（改行区切りで取り出し）
``` TypeScript
while (true) {
    const newlineIndex = buffer.indexOf("\n");
    if (newlineIndex === -1) break;
    const line = buffer.slice(0, newlineIndex);
    buffer = buffer.slice(newlineIndex + 1);
```
[DONE]が来たらおしまい

``` TypeScript
if (line.includes("[DONE]")) {
  done = true;
  break;
}
```

それ以外なら返答なので、JSON.parseで事前定義したtypeに変換してyield
``` TypeScript
// レスポンス
if (line.startsWith("data:")) {
  const chunk = JSON.parse(
    line.slice(5)
  ) as ChatCompletionMessageChunk;
  yield chunk;
}
```

##  画面
正直、ここが一番ハマった。。
Composition APIのreactiiveとかrefの挙動がまだよくわかってないので、、、:sweat_smile:
とりあえず戦略としては
- 過去ログは配列管理する（チャットログを文脈として渡したいので）
- ストリームの出力は別変数に一旦入れて表示する
- 全部取得したら過去ログに突っ込む

呼び出し部。
makePromptは表示用に持ってるメッセージからAPIに渡す部分を引っこ抜くだけなので省略

``` TypeScript
<script lang="ts" setup>
import { reactive, onMounted, inject } from "vue";
import { streamChatCompletion } from "@/api_call";
import type { AuthPluginType } from "@/plugins";
import type { ChatMessege } from "@/api_call";

//　ユーザ入力と過去ログを扱う用
const message = reactive({
  input: "",
  logs: [],
});
// ストリーム出力を表示する用
const prompt = reactive({
  content: "",
  loading: false,
  color: "bg-light-blue-lighten-5",
});

// 
const getChatCompletion = async () => {
  // 最新の入力をログに突っ込む
  message.logs.push({
    id: message.logs.length + 1,
    color: user_color,
    icon: "mdi-chat-question-outline",
    message: {
      role: "user",
      content: message.input,
    } as ChatMessege,
  });
  
  message.input = "";

  //API呼び出し
  const generator = streamChatCompletion(makePrompt(), auth.accessToken());
  // 順次生成の回答を表示
  prompt.loading = true;
  for await (let chunk of generator) {
    if (chunk.choices[0].delta.content != null) {
      prompt.content = prompt.content + chunk.choices[0].delta.content;
    }
  }
  prompt.loading = false;

  // 最後まで受信したらログに追加
  let resChat = {
    id: message.logs.length + 1,
    color: ai_color,
    icon: "mdi-robot",
    message: {
      role: "assistant",
      content: prompt.content,
    } as ChatMessege,
  };
  prompt.content = "";
  message.logs.push(resChat);
  
};
</script>

```

## 画面表示
受信中の表示は、v-listに入れたかったけどむりぽかったのでv-cardで間に合わせました。

``` HTML
<!-- メインのログ表示 -->
<v-list>
  <v-list-item v-for="item in message.logs" :key="item.id">
    <template v-slot:prepend>
      <v-avatar>
        <v-icon :icon="item.icon" size="x-large"></v-icon>
      </v-avatar>
    </template>
    <v-card :text="item.message.content" :color="item.color"></v-card>
  </v-list-item>
</v-list>
<!-- メッセージ受信中だけ表示するカード -->
<v-card v-show="prompt.loading" flat class="px-4">
  <div class="d-flex flex-no-wrap">
    <v-avatar>
      <v-icon icon="mdi-loading mdi-spin" size="x-large"></v-icon>
    </v-avatar>
    <v-card-text :class="prompt.color">{{
      prompt.content
    }}</v-card-text>
  </div>
</v-card>

```

# 感触
やっぱり全部生成してから取るよりは体感早いですね。
全部帰ってくるのにかかる時間は、、、正直そんなに変わらない気がする。
GPT-4-32Kだと返答トークンも多くなるはずなので、利点が見えるかも？

ざっくり2~3回くらいに分けて帰ってくる感があるので、内部的には数十文字くらい作ったら再推論とかしてんのかなぁって思いますね。

### 回答中
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/4096a53f-0a03-b3d5-3fce-a6f7dd4c79be.png)

### 最終的な回答。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/94f105a0-d0bf-ab1e-951f-c3bc5bd5ccb2.png)

ちなみに上記回答の店舗はどれも秋葉原店は無かったです:innocent:


# 参考にしたサイトとか
- [OpenAIのChat APIの返答をストリーミングする（Node\.js）](https://zenn.dev/k_kind/articles/chatapi-stream)
- [ChatGPT API でチャットの返答を Stream 受信して最速表示する方法](https://zenn.dev/himanushi/articles/99579cf407c30b)
- [【チャットAI】更新：ChatGPTのAPI呼び出しをReact＋Node\.jsでstream対応実装してみた（要約機能、会話保存機能付き） \- Qiita](https://qiita.com/falman_ai/items/d7f46c6ff7645ed03d4e#3%E3%83%95%E3%83%AD%E3%83%B3%E3%83%88%E3%81%B8%E3%81%AE%E8%BF%94%E5%8D%B4)