アプリケーションを実行する環境はMシリーズのMacbookで、ローカルLLMの実行にLM Studioを使用します。モデルとしてサイバーエージェントが日本語の追加学習を行なったcyberagent-deepseek-r1-distill-qwen-32b-japaneseの使用を想定しています。エンドポイントとモデルは設定で変更できるので、Ollamaや14bのモデルでもアプリケーションは利用可能です。ローカル環境でのAPEXの環境構築については、こちらの記事を参照してください。
追記:LM StudioまたはOllamaでCORSの対応さえできていれば、APEXのアプリケーションはどこでも作成できます。
作成したAPEXアプリケーションは以下のように動作します。ローカルで実行するLLMでも、ここまでできるようになったのか、と感じます。DeepSeek-R1の他のモデルでも確認してみましたが、英語や中国語やその他の言語が混ざることが多々あり、日本語の生成は今ひとつでした。モデルを公開していただいたサイバーエージェント様、ありがとうございます。
以下よりアプリケーションの作成手順を紹介します。
推論モデルは回答に時間がかかります。そのため、OpenAI互換APIの呼び出しはブラウザのJavaScriptで実行します。受け取ったストリーミングのレスポンスをページに表示します。そのページ上に表示されている文章を、データベースに書き込みます。
最初に生成した小説を保存する表EBAJ_STORIESを作成します。以下のDDLを実行します。
create table ebaj_stories (
id number generated by default on null as identity
constraint ebaj_stories_id_pk primary key,
title varchar2(80 char) not null,
prompt clob,
story clob
);
以下はSQLワークショップのSQLコマンドでの実行例です。
空のAPEXアプリケーションを作成します。名前は小説生成器とします。
アプリケーションが作成されます。アプリケーション定義の編集を開きます。
LM Studioが宛先の場合は、以下のように設定します。
G_ENDPOINT_URL: http://localhost:8080/v1/chat/completions
G_MODEL_NAME: cyberagent-deepseek-r1-distill-qwen-32b-japanese
LM Studioのローカル・サーバーのSettingsでServer Portが8080でない場合は、そのポート番号をURLに含めます。また、Enable CORSをオンにします。
今回の用途に合わせて、Context Lengthを最大の131072に設定してモデルをロードしています。
Ollamaの場合は、以下のような設定になります。Ollamaではページが生成されたURLとAPI呼び出し先のポート番号の違いは見ていないのか、CORSに関する設定は不要でした。
G_ENDPOINT_URL: http://localhost:11434/v1/chat/completions
G_MODEL_NAME: yuma/DeepSeek-R1-Distill-Qwen-Japanese:32b
以下はLM Studioのローカル・サーバーを呼び出す設定です。
ページ・デザイナでホーム・ページを開き、小説を生成するユーザー・インターフェースを作成します。
置換文字列G_ENDPOINT_URLとして設定した値を、このページで実行するJavaScriptのコードから参照できるように、ページ・アイテムP1_ENDPOINT_URLを作成します。JavaScriptから参照するだけなので、タイプは非表示、設定の保護された値はオンにします。
ソースのタイプにアイテムを選択し、アイテムとしてG_ENDPOINT_URLを指定します。HTMLページ上に値があればよく、データベースに保存する必要はないので、セッション・ステートのストレージはリクエストごと(メモリーのみ)を選択します。
同様にページ・アイテムP1_MODEL_NAMEを作成し、ソースのアイテムにG_MODEL_NAMEを指定します。
このリージョンにプロンプトを入力するテキスト領域、APIリクエストの送信ボタン、生成された小説をデータベースに保存するボタン、保存する際に付けるタイトルを設定するテキスト・フィールドを作成します。
表示上の修飾は不要なので、外観のテンプレートはBlack with Attributesを選択します。このリージョンの下に小説を表示するリージョンを作成しますが、リージョン間の隙間を空けるために、テンプレート・オプションの詳細のBottom MarginにMediumを指定します。
ボタンをクリックしたときに実行される処理は、APEXアクションとして実装します。APEXアクションを保持するコンテキストをこのリージョンに作成するため、詳細の静的IDとしてPROMPTを設定します。
LLMへの指示を入力するページ・アイテムを作成します。識別の名前はP1_PROMPT、複数行に渡る長い文章の入力を想定して、タイプをテキスト領域とします。外観の高さは10行としました。
このページの処理はすべてJavaScriptで実装します。ページ・アイテムの値をセッション・ステートとして(データベースに)保存する必要は無いため、セッション・ステートのストレージはリクエストごと(メモリーのみ)を選択します。
小説の生成を依頼するボタンGENERATEを作成します。ラベルは生成とします。
外観のホットをオン、テンプレート・オプションのWidthにStretchを設定し、画面の横幅いっぱいに強調色でボタンを表示します。
ボタンの処理はGENERATEという名前のAPEXアクションとして実装します。ボタンを押した時にこの処理が呼び出されるように、動作のアクションに動的アクションで定義を選択し、詳細のカスタム属性としてdata-action="GENERATE"を指定します。
生成された小説を保存する際に付けるタイトルを入力するページ・アイテムを作成します。
識別の名前はP1_TITLE、タイプはテキスト・フィールド、ラベルはTitleとします。セッション・ステートのストレージはリクエストごと(メモリーのみ)です。
小説をデータベースに保存するボタンSAVEを作成します。ラベルは保存とします。
ボタンGENERATEと同様に、ボタンをクリックしたときにAPEXアクションSAVEを呼び出します。詳細のカスタム属性にdata-action="SAVE"を指定します。
生成された小説を保存するデータベース操作は、Ajaxコールバックとして実装します。左ペインでプロセス・ビューを開き、Ajaxコールバックとしてプロセスを作成します。
識別の名前はSAVE_STORY、タイプはコードを実行です。ソースのPL/SQLコードとして以下を記述します。
begin
insert into ebaj_stories(title, prompt, story) values(:P1_TITLE, :P1_PROMPT, apex_application.g_x01);
htp.p('{ "success": true }');
end;
LLMが生成した小説を記述するリージョンを作成します。
識別の名前は小説、タイプは静的コンテンツです。LLMからストリームとして受け取った文字列を、JavaScriptより逐次、リージョンのinnerTextに追記します。
外観のテンプレート・オプションのHeightとして320pxを選択します。リージョンの高さを固定し、出力が溢れた場合は自動的にスクロールするようにします。
JavaScriptより出力先となるリージョンを特定するため、詳細の静的IDとしてNOVEL_CONTAINERを設定します。
生成AIの呼び出しや小説の保存を行うコードを記述したJavaScriptのファイルを、静的アプリケーション・ファイルとして保存します。
共有コンポーネントの静的アプリケーション・ファイルを開きます。
以下のファイルをjs/app.jsとして保存します。
ファイルの作成をクリックします。
ディレクトリにjs、ファイル名にapp.jsを指定し、作成をクリックします。
ファイルの内容をコピペし、変更の保存をクリックします。
メッセージにファイルが保存され、縮小されましたと表示されます。
これで作成したファイルが、静的アプリケーション・ファイルとして参照できるようになりました。参照のリンクをコピーした後、取消をクリックしてスクリプト・エディタを終了します。
ホーム・ページのページ・プロパティのJavaScriptのファイルURLに、以下を記述します。
[module,defer]#APP_FILES#js/app#MIN#.js
以上で小説の生成と保存を行うページは完成しました。
ページを実行して、小説の生成と保存を行います。
Promptに適当な指示を入力し、ボタン生成をクリックします。アプリケーションの名前は小説生成器としましたが、Promptに入力した文字をそのまま生成AIに渡しているだけです。そのため、実際に生成される文章は小説に限られるわけではありません。
生成された小説をデータベースに保存します。
適当なタイトルを入力し、ボタン保存をクリックします。
saved!とメッセージが表示されれば、生成された小説の保存は成功です。
保存された小説を表示するページを作成します。
このページは概ねAPEXの標準の機能を使って作成します。
ページの作成をクリックし、空白のページを作成します。
空白ページを選択します。
作成するページの名前は小説を読むとします。ページの作成をクリックします。
空白ページが作成されたら、読みたい小説を選択するページ・アイテムと、いくつかのボタンを配置するためのリージョンを作成します。
リージョンの名前はItem Containerとし、タイプは静的コンテンツとします。アイテムとボタンを整列させるため、外観のテンプレートにItem Containerを指定します。
読みたい小説を選択するページ・アイテムを作成します。
識別の名前はP2_STORY、タイプはポップアップLOVにします。ラベルは小説としました。
設定の表示形式はインライン・ポップアップを選択します。レイアウトのリージョンにItem Container、スロットはページ・アイテムの規定の位置であるItemに配置します。
LOVのタイプにSQL問合せを選択し、SQL問合せとして以下を記述します。
select title d, id r from ebaj_stories
追加値の表示はオフ、NULL値の表示はオンとし、NULL表示値に- 読みたい小説を選んで -を設定します。
このページ・アイテムはサーバー側の処理で参照するため、セッション・ステートのストレージとしてセッションごと(永続)を選択します。
小説を表示するボタンREADを作成します。ラベルは読むとします。動作のアクションにページの送信を設定します。
レイアウトのリージョンはItem Container、スロットにButton Endを選択し、アイテムP2_STORYの右側に配置します。
小説の取り出しと表示は、実際には動的コンテンツのリージョンで行います。このボタンはページを送信することにより、ページ・アイテムP2_STORYの値をセッション・ステートに保存します。
小説を削除するボタンDELETEを作成します。ラベルは削除とします。動作のアクションにページの送信を設定します。
動作の確認の要求をオンにし、確認のメッセージとして本当に削除しますか?、スタイルに危険を指定します。
ボタンDELETEを押した時に実行されるプロセスを作成します。
識別の名前はDELETE、タイプはコードの実行です。
ソースのPL/SQLコードとして以下の1行を記述します。
delete from ebaj_stories where id = :P2_STORY;
サーバー側の条件のボタン押下時にDELETEを指定します。
P2_STORYに指定された小説が削除されたので、P2_STORYとして保存されているセッション・ステートの値もクリアします。
プロセスを作成します。識別の名前はP2_STORYのクリア、タイプとしてセッション・ステートのクリアを選択します。設定のタイプにアイテムのクリアを選択し、アイテムとしてP2_STORYを選びます。
サーバー側の条件のボタン押下時にDELETEを選択します。
最後に小説を表示するリージョンを作成します。
識別の名前は小説、タイプとして動的コンテンツを選択します。
ソースのCLOBを返すPL/SQLファンクション本体に以下を記述します。
declare
l_story clob;
begin
if :P2_STORY is not null then
select story into l_story from ebaj_stories where id = :P2_STORY;
return apex_markdown.to_html(l_story);
end if;
return '';
end;
以上でアプリケーションは完成です。
今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/novel-generator.zip
Autonomous DatabaseのAPEX 24.1で作成したエクスポートはこちらに置きました。
https://github.com/ujnak/apexapps/blob/master/exports/novel-generator-241.zip
アプリケーションをAutonomous Databaseで実行した場合は、OllamaでもCORSのエラーが発生します。そのため、ollama serveを実行する前に環境変数OLLAMA_ORIGINSを設定して、CORSの制限を緩和します。
export OLLAMA_ORIGINS='*'
Oracle APEXのアプリケーション作成の参考になれば幸いです。
完