1
1

OpenAIをAPI利用するPHPとjavascript

Last updated at Posted at 2024-05-06

OpenAI Assistants

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

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

解説

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

(ライブラリ側のPHPソース)

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

(呼び出し元の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リポジトリの説明を見てください。
ReCAPTCHAを使うかどうかも選択できます(天才)
ここではREADME.mdに記載のない点について簡単に解説します。

    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にする、というイレギュラー対応のせいで、どのエディタも色分けしてくれない・・・)

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メソッド1回に対してRunIDが1つ割り当てられる、
そのRunIDは次のsendメソッドを実行するまで保持される、くらいに考えていただければOKです。
本来OpenAIのAPIとしては、過去メッセージまで遡れるようになっているんですが
今回のこのライブラリでは、最新のやり取りについてしか扱えないようになっています。
それと、ReCAPTCHA利用時はrenderパラメタから勝手にサイトキーを拾うようにしてます。

バックエンド実装(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先に反映されるようにしました。

サンプル

店舗経営アドバイザー

image.png

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

まとめ

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

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1