2025年1月31日金曜日

APEX 24.2で追加されたパスワードの表示ボタンについて

Oracle APEX 24.2ではタイプパスワードのページ・アイテムに、パスワードの表示と非表示を切り替えるボタンが追加されました。


このボタンはデフォルトで表示されます。

APEX 24.2以前のアプリケーションをAPEX 24.2にインポートした直後はテーマは以前のままなので、パスワードの表示/非表示のボタンは表示されません。テーマをリフレッシュすると、パスワードの表示/非表示のボタンが表示されます。

外観テンプレート・オプションに含まれるHide Password Visibilityにチェックを入れることにより、APEX 24.2以前のように、このボタンを無くすことができます。

パスワードのページ・アイテムの外観テンプレート・オプションを開きます。


一般Hide Password Visibilityのオプションがあります。これにチェックを入れます。


パスワードを入力するフィールドから、表示/非表示を切り替えるボタンがなくなります。


Microsoft Edgeは、ブラウザ自体にパスワードの表示/非表示を切り替える機能が組み込まれています。


Edgeのウォレットパスワードに、この機能の設定が含まれています。
edge://wallet/settings#settings-passwords-section

パスワードフィールドに[パスワードを表示する]ボタンを表示する


この設定をオフにすると、APEXによる切り替えだけが表示されるようになります。


とはいえ、この設定は全部のWebサイトのtypeがpasswordのINPUT要素に影響するため、APEXの都合でオン、オフは決められません。また、この設定自体は利用者が個別に設定します。

JavaScriptから、ブラウザのパスワードに関する設定を参照する方法は無いみたいです。セキュリティの観点から行って、それは致し方ないと思われます。

そのため、ページ・ロード時にブラウザのパスワードフィールドに[パスワードを表示する]ボタンを表示するが無効のときに限り、APEXのパスワードの表示/非表示を切り替えるボタンを表示する、という実装はできません。

APEX側でパスワードの表示/非表示のボタンを表示すると決めた場合、Edgeでは(ブラウザの設定によるが)、パスワードの表示/非表示のボタンが二重に表示されることがあるのは仕方がないようです。

日本語を追加学習したサイバーエージェントのDeepSeek-R1のモデルで小説を生成して共有するアプリを作る

ローカルで動作するLLMにプロンプトを与えて小説を生成し、生成された小説をデータベースに保存して共有するAPEXアプリケーションを作成します。

アプリケーションを実行する環境はMシリーズのMacbookで、ローカルLLMの実行にLM Studioを使用します。モデルとしてサイバーエージェントが日本語の追加学習を行なったcyberagent-deepseek-r1-distill-qwen-32b-japaneseの使用を想定しています。エンドポイントとモデルは設定で変更できるので、Ollamaや14bのモデルでもアプリケーションは利用可能です。ローカル環境でのAPEXの環境構築については、こちらの記事を参照してください。

追記:LM StudioまたはOllamaでCORSの対応さえできていれば、APEXのアプリケーションはどこでも作成できます。

作成したAPEXアプリケーションは以下のように動作します。ローカルで実行するLLMでも、ここまでできるようになったのか、と感じます。DeepSeek-R1の他のモデルでも確認してみましたが、英語や中国語やその他の言語が混ざることが多々あり、日本語の生成は今ひとつでした。モデルを公開していただいたサイバーエージェント様、ありがとうございます。


(GIF動画は1秒2枚で作成しています。実際の出力はもっと連続しています。)

以下よりアプリケーションの作成手順を紹介します。

推論モデルは回答に時間がかかります。そのため、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アプリケーションを作成します。名前小説生成器とします。


アプリケーションが作成されます。アプリケーション定義の編集を開きます。


アプリケーション定義置換にAPIのエンドポイントURLモデル名を設定します。コンテナとして動作しているデータベースからではなくブラウザからアクセスするため、接続先のホストはlocalhostになります。

LM Studioが宛先の場合は、以下のように設定します。

G_ENDPOINT_URL: http://localhost:8080/v1/chat/completions
G_MODEL_NAME: cyberagent-deepseek-r1-distill-qwen-32b-japanese

LM Studioのローカル・サーバーのSettingsServer Port8080でない場合は、そのポート番号を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を指定します。


静的コンテンツのリージョンとしてPromptを作成します。

このリージョンにプロンプトを入力するテキスト領域、APIリクエストの送信ボタン、生成された小説をデータベースに保存するボタン、保存する際に付けるタイトルを設定するテキスト・フィールドを作成します。

表示上の修飾は不要なので、外観テンプレートBlack with Attributesを選択します。このリージョンの下に小説を表示するリージョンを作成しますが、リージョン間の隙間を空けるために、テンプレート・オプション詳細Bottom MarginMediumを指定します。

ボタンをクリックしたときに実行される処理は、APEXアクションとして実装します。APEXアクションを保持するコンテキストをこのリージョンに作成するため、詳細静的IDとしてPROMPTを設定します。


LLMへの指示を入力するページ・アイテムを作成します。識別名前P1_PROMPT、複数行に渡る長い文章の入力を想定して、タイプテキスト領域とします。外観高さ10行としました。

このページの処理はすべてJavaScriptで実装します。ページ・アイテムの値をセッション・ステートとして(データベースに)保存する必要は無いため、セッション・ステートストレージリクエストごと(メモリーのみ)を選択します。


小説の生成を依頼するボタンGENERATEを作成します。ラベル生成とします。

外観ホットオンテンプレート・オプションWidthStretchを設定し、画面の横幅いっぱいに強調色でボタンを表示します。

ボタンの処理は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として保存します。

/*
* ストリーム出力された小説を、リージョン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 ]);
view raw app.js hosted with ❤ by GitHub

ファイルの作成をクリックします。


ディレクトリ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のアプリケーション作成の参考になれば幸いです。

2025年1月30日木曜日

APEX 24.2にデータベース・オブジェクトの依存性を確認するUIが追加されました

Oracle APEX 24.1でパッケージAPEX_APP_OBJECT_DEPENDENCYが追加され、APEXアプリケーションが依存しているデータベース・オブジェクトについて確認できるようになりました。APEX 24.2では、データベース・オブジェクトの依存性を確認するユーザー・インターフェースが、アプリケーション・ビルダーに追加されました。

以下の手順で呼び出します。

アプリケーション・ビルダーより、データベース・オブジェクトの依存性を確認する対象のアプリケーションを開きます。


ユーティリティを開きます。


データベース・オブジェクトの依存性を開きます。


依存性のスキャンが未実施のときは、アプリケーションのスキャンを実行するように促されます。アプリケーションのスキャンとは、APEX_APP_OBJECT_DEPENDENCY.SCANの実行です。


スキャン・タイプ有効範囲を設定し、スキャンを実行します。

スキャン・タイプの選択はFull ScanErrors onlyのどちらかです。APEX_APP_OBJECT_DEPENDENCY.SCANの引数p_optionsにあたる設定です。

Full Scanがc_option_all、Errors onlyがc_option_errorsになるようです。c_option_dependenciesおよびc_option_identifiersの切り替えが意味を持つのは、PL/Scopeとしてidentifiers:allを設定し、データベース・オブジェクトの再コンパイルを行った場合に限られます。

有効範囲を設定することで、特定のページのみをスキャンの対象にできます。


スキャンが完了すると、そのアプリケーションが依存しているデータベース・オブジェクトと、そのデータベース・オブジェクトを参照しているアプリケーション内の位置が表示されます。

左ペインのツリーには概ねビューAPEX_USED_DB_OBJECTSの内容が表示されています。左ペインでオブジェクトを選択すると、選択したデータベース・オブジェクトでビューAPEX_USED_DB_OBJ_DEPENDENCIESを絞り込んだ内容が一覧されます。


依存性のクリアをクリックすると、APEX_APP_OBJECT_DEPENDENCY.CLEAR_CACHEが呼び出されます。


コンポーネントのプロパティはリンクになっています。


クリックすると、アプリケーション・ビルダーの該当ページが開きます。


以上がAPEX 24.2で追加された、データベース・オブジェクトの依存性を確認するUIです。

APEX 24.2では、依存性を検出するカバレッジが広がりました。パッケージTESTを作成し、引数として与えられた文字列をそのまま返すファンクションECHOを作成します。
create or replace package test as
function echo(p in varchar2)
return varchar2;
end;
/
create or replace package body test as
function echo(p in varchar2)
return varchar2
as
begin
    return p;
end echo;
end test;
/
このTEST.ECHOを、追加された検出対象に含めます。

レポート列のSQL式が対象になります。


RESTソースのデータ・プロファイル列タイプSQL式が対象になります。


RESTソースのローカル後処理タイプSQL問合せが対象になります。


ユーティリティからデータベース・オブジェクトの依存性を開きます。

依存しているオブジェクトとして検出されたパッケージTESTを選択すると、上記の3箇所がコンポーネントのプロパティにリンクとして表示されます。


パッケージAPEX_APP_OBJECT_DEPENDENCYについては、以前の記事「APEX_APP_OBJECT_DEPENDENCYを使ってAPEXアプリケーションが参照しているデータベース・オブジェクトを確認する」で紹介しています。この記事で作成したアプリケーションをAPEX 24.2向けに修正しました。
https://github.com/ujnak/apexapps/blob/master/exports/application-object-dependency-scanner-242.zip

APEXの標準としてデータベース・オブジェクトの依存性を確認するページが追加されましたが、上記のAPEXアプリケーションは、APIの呼び出しやPL/Scopeの設定が含まれているので、まだ参考になる部分はあります。

なぜかAPEX 24.2では、ビューAPEX_USED_DB_OBJECT_COMP_PROPSより列COMPONENT_IDが除かれています。

この列が無くなって困ることは無いと思いますが、このビューをソースとしているレポートでエラーは発生する可能性はあります。その場合は、レポートの列を同期化することで対応できます。

今回の記事は以上になります。

Oracle APEXのアプリケーション作成の参考になれば幸いです。