アプリケーションを実行する環境は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として保存します。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* ストリーム出力された小説を、リージョンNOVEL_CONTAINERに記述する。 | |
* | |
* 自動スクロールをするために、スクロール対象となっている要素をCSSクラスt-Region-bodyを | |
* セレクタに含めて取り出し、そのinnerTextに出力する。 | |
* t-Region-bodyはAPEXの内部構造なので、本来はセレクタに含めるのは推奨されない。 | |
* | |
* リージョンのスクロールは、OpenAI GPT-4oに以下のプロンプトで生成してもらったコード。 | |
* ---- | |
* MutationObserverを使って自動的にスクロールするコードを教えてください。 | |
* ---- | |
* 参照: | |
* https://developer.mozilla.org/ja/docs/Web/API/MutationObserver | |
* https://developer.mozilla.org/ja/docs/Web/API/MutationObserver/observe | |
*/ | |
const novel = document.querySelector("#NOVEL_CONTAINER .t-Region-body"); | |
const observer = new MutationObserver( (mutations) => { | |
let shouldScroll = false; | |
mutations.forEach( (mutation) => { | |
if (mutation.type === 'childList') { | |
shouldScroll = true; | |
} | |
else if (mutation.type === 'characterData') { | |
shouldScroll = true; | |
} | |
}); | |
if (shouldScroll) { | |
novel.scrollTop = novel.scrollHeight; | |
} | |
}); | |
observer.observe(novel, { | |
childList: true, | |
subtree: true, | |
characterData: true | |
}); | |
/* | |
* あらかじめページ・アイテムに設定されたエンドポイントとモデル名を読み込む。 | |
*/ | |
const ENDPOINT = apex.item("P1_ENDPOINT_URL").getValue(); | |
const MODEL = apex.item("P1_MODEL_NAME").getValue(); | |
/* | |
* LM Studioが返すストリーミングの出力を受け取って、APEXのリージョンに出力する。 | |
* | |
* Claude 3.5 Sonnetに以下のプロンプトで生成してもらったコード。 | |
* ---- | |
* how to handle openai.chat.completions.create with streaming on the browser? | |
*/ | |
// Function to handle streaming chat completions | |
async function streamChatCompletion(messages) { | |
const response = await fetch( ENDPOINT, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
model: MODEL, | |
messages: messages, | |
stream: true, | |
}), | |
}); | |
if (!response.ok) { | |
throw new Error(`HTTP error! status: ${response.status}`); | |
} | |
const reader = response.body.getReader(); | |
const decoder = new TextDecoder('utf-8'); | |
let buffer = ''; | |
// Example usage with event handling | |
const eventTarget = new EventTarget(); | |
// Process the stream | |
const processStream = async () => { | |
try { | |
while (true) { | |
const { done, value } = await reader.read(); | |
if (done) { | |
eventTarget.dispatchEvent(new CustomEvent('complete')); | |
break; | |
} | |
// Decode the chunk and add to buffer | |
buffer += decoder.decode(value); | |
// Split buffer into lines | |
const lines = buffer.split('\n'); | |
buffer = lines.pop() || ''; // Keep last partial line in buffer | |
// Process complete lines | |
for (const line of lines) { | |
if (line.trim() === '') continue; | |
if (line.trim() === 'data: [DONE]') continue; | |
if (line.startsWith('data: ')) { | |
try { | |
const json = JSON.parse(line.slice(6)); | |
const content = json.choices[0]?.delta?.content || ''; | |
if (content) { | |
eventTarget.dispatchEvent(new CustomEvent('token', { | |
detail: { content } | |
})); | |
} | |
} catch (error) { | |
console.error('Error parsing JSON:', error); | |
} | |
} | |
} | |
} | |
} catch (error) { | |
eventTarget.dispatchEvent(new CustomEvent('error', { | |
detail: { error } | |
})); | |
} | |
}; | |
// Start processing the stream | |
processStream(); | |
return eventTarget; | |
} | |
// Using the streaming function | |
async function generateStory(messages) { | |
try { | |
const stream = await streamChatCompletion(messages); | |
let fullResponse = ''; | |
// Listen for tokens | |
stream.addEventListener('token', (event) => { | |
const token = event.detail.content; | |
fullResponse += token; | |
console.log('Received token:', token); | |
// Update your UI here | |
novel.innerText += token; | |
}); | |
// Listen for completion | |
stream.addEventListener('complete', () => { | |
console.log('Stream complete'); | |
console.log('Full response:', fullResponse); | |
}); | |
// Listen for errors | |
stream.addEventListener('error', (event) => { | |
console.error('Stream error:', event.detail.error); | |
}); | |
} catch (error) { | |
console.error('Error:', error); | |
} | |
} | |
/* | |
* 小説を生成するAPEXアクション. | |
*/ | |
const a_GENERATE = { | |
name: "GENERATE", | |
action: (event, element, args) => { | |
const prompt = apex.item("P1_PROMPT").getValue(); | |
// Example usage | |
const messages = [ | |
// { role: 'system', content: 'あなたは日本人の小説家です。小説は日本語で書いてください。' }, | |
{ role: 'user', content: prompt } | |
]; | |
generateStory(messages); | |
} | |
}; | |
/* | |
* 生成された小説をデータベースに保存するAPEXアクション | |
*/ | |
const a_SAVE = { | |
name: "SAVE", | |
action: (event, element, args) => { | |
apex.server.process( "SAVE_STORY", | |
{ | |
pageItems: "#P1_TITLE,#P1_PROMPT", | |
x01: novel.innerText | |
}, | |
{ | |
success: (data) => { | |
apex.message.showPageSuccess("saved!"); | |
} | |
} | |
) | |
} | |
}; | |
/* | |
* APEXアクションの定義。 | |
*/ | |
const promptContext = apex.actions.createContext('controls-novel', document.getElementById("PROMPT")); | |
promptContext.add([ a_GENERATE, a_SAVE ]); |
ファイルの作成をクリックします。
ディレクトリに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のアプリケーション作成の参考になれば幸いです。
完