OpenAIをAPI利用するPHPとjavascript

# OpenAI Assistants

ChatGPTをはじめとするAIっておもしろいですよね。
MyGPTsも良いですが、エンジニアとしてはAPI利用したいケースの方が多いと思います。
APIからGPTsを利用するために、AssistantsでInstructionsを作りますが
いざAPIを使おうとするとthreadとかmessagesとかrunsとか・・・色々クセを理解しないと
API利用するのも一苦労です。

ってことで、簡単にAPIを利用できるように**ライブラリ化**しました。
しかも、バックエンドとフロントエンドがたった１つのファイルで使えるお手軽さ（！）
リポジトリを公開しましたので、即利用したい方は以下からどうぞ。
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
　https://github.com/tri-comma/OpenAI_PHP_JS
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑

## 解説

1つのファイルでPHPとjavascript？？:thinking:
はい、そうです。GETとPOSTでレスポンスを切り替えることによって
フロントエンドライブラリとしても動くし、バックエンドライブラリとしても動くようにしました。
我ながら良いアイディアだ笑
あくまでイメージですが、以下の感じ。

（ライブラリ側のPHPソース）

```php
<?php
if ($is_post) {
    // OpenAIのAPIを処理する
} else {
    header('Content-Type: text/javascript');
?>
// javascriptとしてのレスポンス
<?php
}
?>
```

（呼び出し元のHTMLソース）
```html
<!-- GETするとjavascriptソースになる -->
<script type="text/javascript" src="/php/openai.php"></script>
<script>
// 同じソースファイルにPOSTするとREST-APIとして振る舞う
xhr.open('POST', '/php/openai.php');
</script>
```


これによって「scriptタグでOpenAI用のjsライブラリ（といっても拡張子はphp）を参照すれば、自動的にバックエンドも実装されている」という素敵な状態になります。

ところで「なぜPHPなの？」という疑問にお答えしておきます。
すぐ試せる環境がPHPだったからだ。わははは。
まあ、WordPress環境とかにもすぐ導入できて良いんじゃないですかね。（ならプラグインにしろ）

## 使い方

詳しくは[GitHubリポジトリ](https://github.com/tri-comma/OpenAI_PHP_JS)の説明を見てください。
ReCAPTCHAを使うかどうかも選択できます（天才）
ここではREADME.mdに記載のない点について簡単に解説します。

```javascript
    const oa = new OpenAI('Specify AssistantsID here.', null, false);
    oa.send('Hello World!', 5, ()=>{ // Wait up to 5 seconds for a response.
        if (oa.status !== 'complete' && !oa.error) {
            oa.recieve(5, ()=>{
                console.log(oa.result);
            });
        } else {
            console.log(oa.result);
        }
    });
```

- 第二引数のThreadIDは、以前の会話を引き継ぎたい時に指定します。
- 第三引数でtrueを指定すればlocalStorageからThreadIDを回復します。（第二引数より優先されます）
- sendメソッドの `'Hellow World!'` 部分が、いわゆる利用者からGPTsへの発言内容です。
- 結果が返ってくるのに10秒くらいかかることが多いので、最大待ち時間を秒指定できるようにしました。
- 最大待ち時間を過ぎてもまだ結果が返ってきていない場合のstatusは `'in_progress'` です。
    - そのほかにstatusは `'failed'` になる場合があります。
- sendメソッドもrecieveメソッドも、statusが `'completed'` になっていれば、GPTsからの返答内容がresultの中に入っています。
- Assistants側の設定で JSONフォーマット にしていれば、resultの中身は文字列ではなくオブジェクトになります。

## ライブラリ解説

### フロントエンド実装(javascript)

以上を踏まえて、実装したソースについても少し触れます。
（phpのレスポンスをjavascriptにする、というイレギュラー対応のせいで、どのエディタも色分けしてくれない・・・）

```javascript
class OpenAI {
	constructor(assistantId, threadId, useLocalStorage) {
		this.useLocalStorage = useLocalStorage;
		if (this.useLocalStorage == true) {
			this.threadId = localStorage.getItem('threadId') ?? threadId;
			this.assistantId = localStorage.getItem('assistantId') ?? assistantId;
		} else {
			this.threadId = threadId;
			this.assistantId = assistantId;
		}
		this.runId = '';
		this.onload  = (res)=>{ console.log(res); };
		this.onerror = (res)=>{ console.error(res); };
		this.url = '<?=$self ?>';
		this.result = null;
		this.status = null;
		this.error = null;
		const recaptchaJs = document.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]');
		this.recaptchaSiteKey = recaptchaJs ? recaptchaJs.src.split('=')[1] : null;
	}
	send(message, waitSecond = 0, onload = this.onload, onerror = this.onerror) {
		const payload = {
			fn: 'send',
			aid: this.assistantId,
			msg: message,
			tid: this.threadId,
			wit: waitSecond,
		};
		this._doPost(this.url, payload, onload, onerror);
	}
	receive(waitSecond = 0, onload = this.onload, onerror = this.onerror) {
		const payload = {
			fn: 'receive',
			tid: this.threadId,
			rid: this.runId,
			wit: waitSecond,
		};
		this._doPost(this.url, payload, onload, onerror);
	}
	newThread() {
		this.threadId = null;
		localStorage.removeItem('threadId');
	}
	_doPost(url, data, onload, onerror) {
		const xhr = new XMLHttpRequest();
		xhr.open('POST', url);
		xhr.setRequestHeader('Content-Type', 'application/json');
		xhr.onload = () => { this._callback(xhr, onload); };
		xhr.onerror = () => { this._callback(xhr, onerror); };
		if (this.recaptchaSiteKey) {
			const sitekey = this.recaptchaSiteKey;
			const self = this;
			grecaptcha.ready(function() {
				grecaptcha.execute(sitekey, {action: 'submit'}).then(function(token) {
					data.token = token;
					xhr.send(JSON.stringify(data));
					self.result = null;
					self.status = 'justsent';
				});
			});
		} else {
			xhr.send(JSON.stringify(data));
			this.result = null;
			this.status = 'justsent';
		}
	}
	_callback(xhr, fn) {
		try {
			let res = JSON.parse(xhr.responseText);
			this.status = res.sts;
			this.error = res.error ?? null;
			if (this.status === 'completed') {
				try {
					this.result = JSON.parse(res.msg);
				} catch (e) {
					this.result = res.msg;
				}				
			}
			if (this.threadId != res.tid) {
				this.threadId = res.tid;
				if (this.useLocalStorage == true) {
					localStorage.setItem('threadId', this.threadId);
				}
			}
			this.runId = res.rid;
			fn(res);
		} catch (e) {
			console.error(e);
		}
	}
}
```

まあ、すでに解説したとおりですね。。。
利用者はsendメソッドとrecieveメソッドだけ理解していれば、このライブラリを利用できます。
あとはThreadIDの管理がどうなっているのかを理解するには、ソースを見ていただいた方が早いかと。
ちなみにRunIDというのもあるんですが、sendメソッド１回に対してRunIDが1つ割り当てられる、
そのRunIDは次のsendメソッドを実行するまで保持される、くらいに考えていただければOKです。
本来OpenAIのAPIとしては、過去メッセージまで遡れるようになっているんですが
今回のこのライブラリでは、最新のやり取りについてしか扱えないようになっています。
それと、ReCAPTCHA利用時はrenderパラメタから勝手にサイトキーを拾うようにしてます。

### バックエンド実装(PHP)

phpソースの全体を掲載します。（リポジトリのソースとまったく同じです）

```php
<?php
// Copyright © 2024 TRI-COMMA. All rights reserved. ver 20240506
const KEY = ''; // Specify OpenAI API KEY here
const RSECRET = null; // (Optional) Specify the ReCAPTCHA v3 secret key here
const RMIN = 0.7; // ReCAPTCHA passing score (0.0-1.0, 0.5 recommended)
const FN_SND = 'send';
const FN_RCV = 'receive';

try {
	$p = getParam();
	$p['tid'] = $p['tid'] ?: createThread()['id'];
	if ($p['fn'] == FN_SND) {
		createMessage($p['tid'], $p['msg']);
		$p['rid'] = run($p['aid'], $p['tid'])['id'];
	}
	for ($i = 0; $i < ($p['wit'] + 1); $i++) {
		$resR = getRun($p['tid'], $p['rid']);
		$resM = getMessage($p['tid'], $p['rid']);
		$res = [
			'tid' => $p['tid'],
			'rid' => $p['rid'],
			'sts' => getStatus($resR, $p['rid']),
			'msg' => getContentText($resM, $p['rid']),
		];
		if ($res['sts'] == 'completed' || $res['sts'] == 'failed') {
			break;
		}
		sleep(1);
	}
	doResponse($res);
} catch (Exception $e) {
	doResponse(['error'=>$e->getMessage(),'param'=>$p]);
}

function getParam() {
	try {
		if ($_SERVER['REQUEST_METHOD'] == 'GET') doResponseJS();
		if ($_SERVER['REQUEST_METHOD'] != 'POST') throw new Exception('This REST-API only accepts POST method.');
		$p = json_decode(file_get_contents('php://input'), true);
		if ($p == null) throw new Exception('Parameter not found. Please send JSON data with "Content-Type: application/json"');
		checkParam($p,'fn');
		if ($p['fn'] != FN_SND && $p['fn'] != FN_RCV) throw new Exception('Only "'.FN_SND.'" or "'.FN_RCV.'" can be specified for parameter "fn".');
		$p['tid'] = $p['fn'] == FN_SND ? $p['tid'] : checkParam($p,'tid');
		$p['aid'] = $p['fn'] == FN_SND ? checkParam($p,'aid') : $p['aid'];
		$p['rid'] = $p['fn'] == FN_RCV ? checkParam($p,'rid') : $p['rid'];
		$p['msg'] = $p['fn'] == FN_SND ? checkParam($p,'msg') : $p['msg'];
		$p['wit'] = $p['wit'] ?? 1;
		doRecaptcha($p);
		return $p;
	} catch (Exception $e) {
		doResponse(['error'=>$e->getMessage(),'param'=>$p]);
	}
}

function doRecaptcha($p) {
	if (RSECRET) {
		$token = checkParam($p,'token');
		$recaptch_url = 'https://www.google.com/recaptcha/api/siteverify';
		$recaptcha_params = [
			'secret' => RSECRET,
			'response' => $token,
		];
		$recaptcha = json_decode(file_get_contents($recaptch_url . '?' . http_build_query($recaptcha_params)));
		if ($recaptcha->score < RMIN) {
			throw new Exception('ReCAPTCHA Error. Score is '.$recaptcha->score);
		}
	}
}

function checkParam($param, $name) {
	if (!$param[$name]) {
		throw new Exception('Parameter "'.$name.'" is not found. This parameter is required.');
	}
	return $param[$name];
}

function createThread() {
	$url = 'https://api.openai.com/v1/threads';
	return doApi('POST', $url, []);
}

function createMessage($tid, $msg) {
	$url = 'https://api.openai.com/v1/threads/'.$tid.'/messages';
	return doApi('POST', $url, [
		'role'=>'user',
		'content'=>$msg
	]);
}

function run($aid, $tid) {
	$url = 'https://api.openai.com/v1/threads/'.$tid.'/runs';
	return doApi('POST', $url, [
		'assistant_id'=>$aid
	]);
}

function getRun($tid, $rid) {
	$url = 'https://api.openai.com/v1/threads/'.$tid.'/runs';
	return doApi('GET', $url, []);
}

function getMessage($tid, $rid) {
	$url = 'https://api.openai.com/v1/threads/'.$tid.'/messages';
	return doApi('GET', $url, []);
}

function doResponse($res) {
    header('Content-Type: application/json');
    echo json_encode($res);
    exit();
}

function doApi($method, $apiUrl, $requestData) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $apiUrl);
    if ($method == 'POST') {
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
    }
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, array(
        'Content-Type: application/json',
        'Authorization: Bearer ' . KEY,
        'OpenAI-Beta: assistants=v2',
    ));
    $response = curl_exec($ch);
    curl_close($ch);
    return json_decode($response, true);
}

function getStatus($res, $rid) {
	$idx = array_search($rid, array_column($res['data'], 'id'));
	if ($idx === false) return null;
	return $res['data'][$idx]['status'];
}

function getContentText($res, $rid) {
	$idx = array_search($rid, array_column($res['data'], 'run_id'));
	if ($idx === false) return null;
	return $res['data'][$idx]['content'][0]['text']['value'];
}

function doResponseJS() {
    header('Content-Type: text/javascript');
    $self = ($_SERVER['HTTPS'] ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
?>
class OpenAI {
	constructor(assistantId, threadId, useLocalStorage) {
		this.useLocalStorage = useLocalStorage;
		if (this.useLocalStorage == true) {
			this.threadId = localStorage.getItem('threadId') ?? threadId;
			this.assistantId = localStorage.getItem('assistantId') ?? assistantId;
		} else {
			this.threadId = threadId;
			this.assistantId = assistantId;
		}
		this.runId = '';
		this.onload  = (res)=>{ console.log(res); };
		this.onerror = (res)=>{ console.error(res); };
		this.url = '<?=$self ?>';
		this.result = null;
		this.status = null;
		this.error = null;
		const recaptchaJs = document.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]');
		this.recaptchaSiteKey = recaptchaJs ? recaptchaJs.src.split('=')[1] : null;
	}
	send(message, waitSecond = 0, onload = this.onload, onerror = this.onerror) {
		const payload = {
			fn: 'send',
			aid: this.assistantId,
			msg: message,
			tid: this.threadId,
			wit: waitSecond,
		};
		this._doPost(this.url, payload, onload, onerror);
	}
	receive(waitSecond = 0, onload = this.onload, onerror = this.onerror) {
		const payload = {
			fn: 'receive',
			tid: this.threadId,
			rid: this.runId,
			wit: waitSecond,
		};
		this._doPost(this.url, payload, onload, onerror);
	}
	newThread() {
		this.threadId = null;
		localStorage.removeItem('threadId');
	}
	_doPost(url, data, onload, onerror) {
		const xhr = new XMLHttpRequest();
		xhr.open('POST', url);
		xhr.setRequestHeader('Content-Type', 'application/json');
		xhr.onload = () => { this._callback(xhr, onload); };
		xhr.onerror = () => { this._callback(xhr, onerror); };
		if (this.recaptchaSiteKey) {
			const sitekey = this.recaptchaSiteKey;
			const self = this;
			grecaptcha.ready(function() {
				grecaptcha.execute(sitekey, {action: 'submit'}).then(function(token) {
					data.token = token;
					xhr.send(JSON.stringify(data));
					self.result = null;
					self.status = 'justsent';
				});
			});
		} else {
			xhr.send(JSON.stringify(data));
			this.result = null;
			this.status = 'justsent';
		}
	}
	_callback(xhr, fn) {
		try {
			let res = JSON.parse(xhr.responseText);
			this.status = res.sts;
			this.error = res.error ?? null;
			if (this.status === 'completed') {
				try {
					this.result = JSON.parse(res.msg);
				} catch (e) {
					this.result = res.msg;
				}				
			}
			if (this.threadId != res.tid) {
				this.threadId = res.tid;
				if (this.useLocalStorage == true) {
					localStorage.setItem('threadId', this.threadId);
				}
			}
			this.runId = res.rid;
			fn(res);
		} catch (e) {
			console.error(e);
		}
	}
}
<?php
	exit();
}
?>
```

パラメタチェックは割と厳密にしてみました。
`OpenAI-Beta` ヘッダには `assistants=v2` を指定しています。
このライブラリの一番の利点は、RunIDを管理して適切にステータスを管理してくれることです。
なお、 `RSECRET` が指定されている場合には、勝手にReCAPTCHAが動くようになってます。
あと、phpのファイル名を変更しても、jsからのPOST先に反映されるようにしました。

## サンプル

[店舗経営アドバイザー](https://www.tri-comma.com/tools/store_manager.html)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127776/12c8b1c6-fd4e-011e-0503-26ed1e554aa8.png)

最後に、このライブラリを使っているページをご紹介です。

# まとめ

- GPTsも良いですが、Assistantsで表現の幅がかなり広がります！
- GPTsの会話は学習に利用されることがあるらしいですが、Assistantsとの会話は学習利用されません！
- とは言え、AssistantsでのAIサービス公開は、サービス提供者側の費用負担になっちゃいますorz
- なのでライブラリとしてはReCAPTCHAにも対応して、スパム対策もばっちり。
- ということで、このライブラリの活用でAIサービス実装がめちゃ簡単になり、いろんなAIサービスが立ち上がることを期待です。

## 参考にさせていただきました

- [OpenAIのAssistants APIを使用してチェック業務を自動化してみた](https://qiita.com/haraitai00/items/fb9e85101ab681dfd414)
- [reCAPTCHA v3 を5分で実装 - Web担当者の備忘録](https://liapoc.com/recaptcha-v3.html)