🎃

Cloudflare Worker +D1 + hono + sqlc(TS)でユーザーごとに履歴を持てるChatBotの作成

2023/07/27に公開

はじめに

個人で利用する用途だとChatGPTの課金をするよりもAPIを使ったほうが安いですが、良いインターフェースがあまりありません。Cloudflare Worker + D1 + hono + sqlcを使ってみたかったので作ってみました。

基本的な設定とベースの実装は https://zenn.dev/razokulover/articles/4d0ba10083524e を参考にしました。

要件

  • 会話をユーザーごとに管理できる
  • すべての履歴をコンテキストに入れずにスレッド単位で会話できるようにする
  • トークンをチャージして、会話をするとトークンを消費していく
  • 特定のメッセージが来たときに特定の関数を呼び出す
    • 「会話を始める」で新しいスレッドを開始する
    • 「残高を見る」で残高を確認できる
    • 「残高を加える」で残高を追加できる
    • 「モデルを切り替える」でGPT3.5/4のモデルを切り替えられる

ここまでやっている事例はあまりなさそうなのでやってみました。本当はStripeで決済できるようにしたかったですが、会話をDBに保存することに伴う利用規約やプライバシーポリシーの整備などが大変そうだったので断念しました。

アーキテクチャ

シーケンス図

メッセージを受信した場合

「会話を始める」で新しいスレッドを開始する場合

「残高をみたい」で残高確認する場合

「モデルを切り替える」でモデルを切り替える場合

ユーザーを作成する際に100トークンをおまけとして付与しています。

スキーマ

CREATE TABLE user (
    user_id TEXT PRIMARY KEY,
    num_token INTEGER NOT NULL,
    is_charged_once INTEGER NOT NULL,
    model TEXT NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE payment (
    payment_id INTEGER PRIMARY KEY,
    user_id TEXT NOT NULL,
    amount INTEGER NOT NULL,
    charged_token INTEGER NOT NULL
);

CREATE TABLE thread (
    thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id TEXT NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES user (user_id)
        ON DELETE CASCADE
);   
CREATE INDEX thread_user_id_index ON thread (user_id, thread_id);

CREATE TABLE message (
    message_id INTEGER PRIMARY KEY AUTOINCREMENT,
    thread_id INTEGER NOT NULL,
    user_id TEXT NOT NULL,
    prompt TEXT NOT NULL,
    completion TEXT NOT NULL,
    total INTEGER NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES user (user_id)
        ON DELETE CASCADE,
    FOREIGN KEY (thread_id) REFERENCES thread (thread_id)
        ON DELETE CASCADE
);
CREATE INDEX message_thread_id_index ON message (thread_id, message_id);

SQLiteを初めて使ったので、テンションが上ってDELETE CASCADEをつけました。

sqlcの定義

sqlcからtypescriptのコード生成にはプラグインが必要でした。https://github.com/orisano/sqlc-gen-ts-d1 を利用させていただきました。

必要な関数を定義してsqlc generateするとコード生成してくれます。

-- name: GetUser :one
SELECT *
FROM user
WHERE user_id = @user_id;

-- name: CreateUser :exec
INSERT INTO user (user_id, num_token, is_charged_once, model)
VALUES (@user_id, @num_token, @is_charged_once, @model);

-- name: UpdateUser :exec
UPDATE user
SET num_token = @num_token, is_charged_once = @is_charged_once, model = @model, updated_at = CURRENT_TIMESTAMP
WHERE user_id = @user_id;

-- name: GetThread :one
SELECT *
FROM thread
WHERE thread_id = @thread_id;

-- name: GetLastThread :one
SELECT *
FROM thread
WHERE user_id = @user_id
ORDER BY thread_id DESC
LIMIT 1;

-- name: CreateThread :exec
INSERT INTO thread (user_id)
VALUES (@user_id);

-- name: GetMessagesASC :many
SELECT *
FROM message
WHERE thread_id = @thread_id
ORDER BY message_id ASC;

-- name: CreateMessage :exec
INSERT INTO message (thread_id, user_id, prompt, completion, total)
VALUES (@thread_id, @user_id, @prompt, @completion, @total);

バックエンドの実装

https://github.com/tychy/pocket-ai-public に置いています。自由に利用していただいて構いません。

OpenAIとAzureでAPIの仕様がちょっと違うので、コードを置いておきます。

OpenAI

const callOpenAI = async (messages: Message[]): Promise<any> => {
	const openai_url = 'https://api.openai.com/v1/chat/completions';
	const requestOptions = {
		'method': 'POST',
		'headers': {
			'Content-Type': 'application/json',
			'Authorization': `Bearer ${c.env.OPENAI_API_KEY} `,
		},
		'body': JSON.stringify({
			'model': 'gpt-3.5-turbo',
			'messages': messages,
		}),
	};

	const response = await fetch(openai_url, requestOptions);
	const json = await response.json() as ChatCompletion;
	const completion: string = json.choices[0].message.content;
	await reply(completion);
}

Azure

const callAzureAI = async (messages: Message[]): Promise<any> => {
	// https://learn.microsoft.com/ja-jp/azure/cognitive-services/openai/reference
	const azure_url = '発行したURL'
	const requestOptions = {
		'method': 'POST',
		'headers': {
			'Content-Type': 'application/json',
			'api-key': `${発行したAZURE_API_KEY}`,
		},
		'body': JSON.stringify({
			'max_tokens': 200,
			'messages': messages,
		}),
	};
	const response = await fetch(azure_url, requestOptions);
	const json = await response.json() as ChatCompletion;
	const completion: string = json.choices[0].message.content;
	await reply(completion);
}

動作確認

ブラウザからchatgptを開かなくて良いのはかなり便利でした。

お仕事のご連絡お待ちしています。contact@tychy.jp まで

Discussion