推論モデルは回答に時間がかかります。そのため、OpenAI互換APIの呼び出しはブラウザのJavaScriptで実行します。受け取ったストリーミングのレスポンスをページに表示します。そのページ上に表示されている文章を、データベースに書き込みます。
Ollamaの場合は、以下のような設定になります。Ollamaではページが生成されたURLとAPI呼び出し先のポート番号の違いは見ていないのか、CORSに関する設定は不要でした。
このリージョンにプロンプトを入力するテキスト領域、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='*'