クリぼっちでも付箋パーティー

これは [Infocom 非公式 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/infocom) 22日目の記事です。

# はじめに
リモートコミュニケーション時代のデジタル三種の神器（諸説あります）の一つである[Miro](https://miro.com/)は、大変便利なコラボレーションツールです。弊社ではブレストや議事メモ、[読書会](https://qiita.com/r_kunisan/items/37377373aeb88fb526b1#%E8%AD%B0%E8%AB%96%E5%BD%A2%E5%BC%8F%E3%81%AE%E5%B7%A5%E5%A4%AB)などに活用させていただいています。

もちろん自分の頭の中を整理するツールとしても便利なのですが、いかんせん一人で考えていると息詰まります。昨今はCopilot的なアシストがあらゆるツールに搭載され、もちろんmiroでもAI機能を利用できますが、どのプランにも利用上限が課されています。また、提供されているAIが自分のユースケースに合致しないこともあります。

ということで、本記事では、自前のAIによりMiroを拡張してみます。

# 動作イメージ
実装後の動作を先にお見せしておくと、こんな感じ。
例えば「CCoEの役割について掘り下げ、アクションを検討しよう」みたいなディスカッションにおいて、要素分解とアイデア出しをAIが手伝ってくれます。

![ふせんAIデモ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162839/e6de2822-425f-f7fc-fc3b-1dd367035671.gif)

今回は、以下の二つの機能を実装しましたが、本記事では後者の実装例を紹介します。
* ユーザが選択した内容を要素分解し、周囲に付箋で展開する機能
* ユーザが選択した内容を膨らませるアイデアを出し、周囲に付箋で展開する機能

Let's ふせんパーティー🎉

# アーキテクチャ
Miroを拡張する手段は二つあります。
Web SDKとREST APIです。

> The Miro Web SDK and the Miro REST API complement each other, providing a comprehensive toolkit for building apps that interact with Miro boards and items. While the Web SDK is ideal for creating apps that run within the Miro interface and provide real-time interactions, the REST API allows you to manage data and perform operations outside of the Miro client, enabling broader integrations and automation.
> 
> *https://developers.miro.com/docs/miro-web-sdk-introduction*

どちらもボード上のオブジェクトに対しては同じような操作ができます。
使い分けとしては、トリガーがMiroの利用者側にあり、ブラウザ上でのアクションを起点としたい場合はWeb SDK。トリガーがMiroの外側の世界にある場合はREST APIでの連携が良いと思います。ユースケースによっては非同期に組み合わせることもありえますね。
今回はWeb SDKを使ってMiro Appとして実装しました。

AI側はAzure OpenAIを利用しました。クライアントとの間にAzure Functionsを挟み、プロンプトや接続先AIモデルの管理を行います。

![puruya.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162839/0160ad77-427d-8824-7232-a4d21f9b6415.png)

# フロントエンド（Miro App）
Miro Appの初期登録は「[Build your first Hello World App](https://developers.miro.com/docs/build-your-first-hello-world-app)」に分かりやすい手引きがあるので割愛します。

Miroが拡張機能としてアプリケーションを読み込む流れは以下の通りです。
![Miro_Web_SDK_loading_and_auth_check](https://files.readme.io/e9f88d5-Miro_Web_SDK_loading_and_auth_check.png)
(https://developers.miro.com/docs/miro-web-sdk-introduction#get-to-know-miro-web-sdk)

Miroはアプリケーションに設定されているURLにアクセスして拡張機能の内容を取得するため、アプリを共有するためには他者からも参照可能な場所に配置する必要があります。開発時はlocalhostとしておいて、手元の開発環境を参照させるようにします。

![miro-app-profile2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162839/70da04d6-e866-0f4d-1889-3800c3736139.png)

### ハンドラの登録
今回実装するのは、「ユーザが選択した内容を膨らませるアイデアを出し、周囲に付箋で展開する機能」なので、付箋などのテキストオブジェクトを選択してアクションが開始できるように、ハンドラを登録します。以下のコードをMiro Appが読み込まれる際のエントリポイントとして配置しておきます。

```javascript
const { board } = window.miro;

/**
 * ハンドラを登録します。
 */
async function registerHandlers() {
	// アイデア出しアクションのハンドラ
	board.ui.on("custom:boost-ideas", async(evt) => {
		// 選択されているオブジェクトを取得（複数ある場合は一つ目のみ）
		const note = evt.items[0];
		// AIに周辺アイデアを問い合わせ
		const ideas = await fetchIdeas(stripHTML(note.content));
		// オブジェクト周辺に付箋を展開
		await expandNote(note, ideas);
	});
	// メニュー登録
	board.experimental.action.register({
        // イベント名（ハンドラで監視するイベント名と合わせる）
		event: "boost-ideas",
		ui: {
			label: {
				en: "Expand sticky notes",
				ja_JP: "アイデアを捻出します",
			},
			// 利用可能なicon一覧：https://developers.miro.com/docs/add-custom-actions-to-your-app#add-custom-actions
			icon: "curve-square-circle-arrow",
		},
        // アクションの対象となるオブジェクトの条件（テキスト系オブジェクト）
		"predicate": {
			"$or": [
				{ type: "text" },
				{ type: "sticky_note" },
			]
		}
	});
}

registerHandlers();
```

### バックエンドAIへのリクエスト
Function Appに対してのHTTPリクエストはこのような感じになります。バックエンドの構成については後述しますが、エンドポイントへのアクセスに関数キーを要求するようにしたため、クライアント側にこれを持たせることになります。

```javascript
// バックエンドのURL
const FUSEN_AI_URL = "（Function Appのエンドポイント）";

// 関数キー
const FUNCTION_KEY = "（Function Appの関数キー）";

/**
 * 周辺アイデアの捻出をAIにリクエストします。
 * @param {String} text 元になるテキスト
 * @return {[String]} AIが生成したアイデア
 */
async function fetchIdeas(text) {
	const options = {
		method: "POST",
		headers: {
			accept: "application/json",
			"content-type": "application/json",
			"X-Functions-Key": FUNCTION_KEY,
		},
		body: JSON.stringify({
			"data": text,
			"maxIdeas": 3, // 最大3つまで生成してもらう
		}),
	};
	const response = await fetch(FUSEN_AI_URL, options);
	const json = await response.json();
	return json.data;
}
```

### 付箋の生成
AIに出力させた周辺アイデアを付箋にしてボード上に配置します。
miro.board.createStickyNoteのリファレンスは[こちら](https://developers.miro.com/docs/websdk-reference-sticky-note)。

```javascript
/**
 * 関連テキストを付箋として周囲に配置します。
 * @param {Object} note 元の付箋
 * @param {[String]} texts 展開するテキスト
 */
async function expandNote(note, texts) {
    // 元の付箋の周辺に新しい付箋を展開する座標を計算
	const pos = calculatePositions(note, texts.length);
	for (let i = 0; i < texts.length; i++) {
		await board.createStickyNote({
			content: texts[i],
			x: pos[i].x,
			y: pos[i].y,
			width: pos[i].w,
			style: { fillColor: "light_blue" },
		});
	}
}

/**
 * 付箋を配置する座標を計算します。
 * 基準オブジェクトから半円形に下方向に展開します。
 * @param {Object} base 配置の基準となるオブジェクト
 * @param {Integer} numNotes 展開する付箋の数
 * @return {[Object]} 各付箋の座標とサイズ。{x, y, w}で表現
 */
function calculatePositions(base, numNotes, options) {
    // 基準となるオブジェクトの中心座標
	const center = {
		x: base.x + base.width / 2,
		y: base.y - base.height / 2,
	};
    // 付箋の間隔
	const interval = {
		x: base.width * (Math.floor(numNotes / 2) + 0.5),
		y: base.height * (Math.floor(numNotes / 2) + 1),
	};
	const angle = Math.PI / (numNotes + 1);
	const pos = [];
	for (let i = 0; i < numNotes; i++) {
		const theta = Math.PI + angle * (i + 1);
		pos.push({
			x: center.x + interval.x * Math.cos(theta) - base.width / 2,
			y: center.y - interval.y * Math.sin(theta),
			w: base.width,
		});
	}
	return pos;
}
```

煩雑になるため本記事では割愛していますが、付箋の展開時に以下の挙動を追加しました。

* AIが新規作成した付箋に「bot」というタグを付与
* 元のテキストオブジェクトから新規付箋に対してのコネクタを生成
* 新規付箋を選択状態に変更

AIへのリクエスト時に対象オブジェクトに流入するコネクタがある場合、親オブジェクトを辿ってコンテキスト情報を取得し、サーバに送信しています。

# バックエンド（Azure）
バックエンドはFunction Appをクライアントに対してのエンドポイントとし、Azure OpenAI（以下AOAI）を隠蔽します。インフラ構築用のコードは後述しますが、ポータル画面からの操作の場合は以下の通りです。

AOAIへのアクセスはEntra ID認証を利用するため、Function AppにマネージドIDを割り当てます。

![fn-id.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162839/c432fca4-c9c2-f834-4dee-df0a4f8b0458.png)

IDを有効にしたあと、「Azureロールの割り当て」から「Cognitive Services OpenAI User」を割り当て、同一リソースグループ内のAIサービスを利用できるように設定します。

![fn-id-role.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162839/9c60e257-2514-a9be-e5c7-8653885d1200.png)

### プロンプト
AOAIではChatCompletionを実行します。
システムに与えるプロンプトとしては、オズボーンのチェックリストをベースに周辺アイデアを検討させることとしました。

```
オズボーンのチェックリストを参考に、ユーザが指定したフレーズから連想する簡潔なアイデアを
最大${maxItems}個まで列挙せよ。

# オズボーンのチェックリスト
- 転用：新しい使い道はないか？
- 応用：過去に似たアイデアを応用できないか？
- 変更：一部を変えたらどうなるか？
- 拡大：何かを付け加えられないか？
- 縮小：機能を減らせないか？
- 代用：他のもので代用できないか？
- 再編成：要素、成分、型、順序、結果などを替えられないか？
- 逆転：考え方や立場を逆にできないか？
- 統合：何かと組み合わせたり、一つにまとめられないか？
```

これだけでもそれなりに良い出力を生成してくれたのでほぼチューニングはしていませんが、付箋に書き込むことを想定しているため「簡潔な」という指定を入れています。また、生成するアイデアの最大数を実行時に指定できるようにしています。

職場で使うのであれば、「業務として取り組む」などの利用目的を明示することで、出力の妥当性を向上できそうです。

### Functionsコード
Function App上で実行するコードでは、AOAIに接続してChatCompletionのリクエストを投げます。戻り値を構造化するため、[Zod](https://zod.dev/)でスキーマ定義を行い、フォーマットを規定します。

```javascript
const { app } = require('@azure/functions');
const { AzureOpenAI } = require("openai");
const { DefaultAzureCredential, getBearerTokenProvider } = require("@azure/identity");
const { zodResponseFormat } = require("openai/helpers/zod");
const { z } = require("zod");

/**
 * 指定されたフレーズから連想するアイデアを返します。
 * HTTPリクエストの内容：{
 *   data: "対象フレーズ", // 必須
 *   model: "AIモデル", // オプション
 *   maxTokens: "最大トークン数", // オプション
 *   maxIdeas: "最大単語数", // オプション
 * }
 */
app.http("boostIdeas", {
	methods: ["GET","POST"],
	authLevel: "function",
	handler: async (request, context) => {
		context.log(`Http function processed request for url "${request.url}"`);
		// リクエストパラメータの抽出
		const params = (await request.json()) || {};
		context.log(`request: ${JSON.stringify(params)}`);
		// 必須パラメータが空の場合はエラーを返す（省略）
		if (!params.data) return insufficientParametersError();
		// LLMに要求するレスポンスのスキーマ
		const Ideas = z.object({
			data: z.array(z.string()),
		});
		const schema = zodResponseFormat(Ideas, "boost_ideas");
		// LLMに入力するメッセージ
		const messages = [
			{ role: "system", content: `オズボーンのチェックリストを参考に...` },
			{ role: "user", content: params.data },
		];
		// Chat Completionの実行
		const result = await getChatCompletions(messages, schema, params);
		context.log(`result: ${JSON.stringify(result)}`);
		return {
			body: JSON.stringify({ "data": JSON.parse(result.choices[0].message.content).data}),
		};
	},
});

/**
 * ChatCompletionを実行します。
 * @param {[Object]} messages 対象メッセージ
 * @param {Object} schema レスポンススキーマ(zod)
 * @param {Object} params 呼び出しパラメータ
 * @return {Object} ChatCompletion結果
 */
async function getChatCompletions(messages, schema, options) {
	// Azure OpenAI envs
	const endpoint = process.env.AZURE_OPENAI_ENDPOINT;
	const apiVersion = "2024-08-01-preview";
	const deployment = options?.model || "gpt-4o-2024-08-06";
	const maxTokens = options?.maxTokens || 1024;
	// setup Azure OpenAI client
	let client = undefined;
	if (process.env.AZURE_OPENAI_API_KEY) {
        // APIキーが環境変数に定義されている場合、APIキーを利用（ローカル開発用）
		client = new AzureOpenAI({ endpoint, apiKey: process.env.AZURE_OPENAI_API_KEY, apiVersion, deployment });
	} else {
        // APIキーが定義されていない場合、Entra ID認証
		const credential = new DefaultAzureCredential();
		const scope = "https://cognitiveservices.azure.com/.default";
		const azureADTokenProvider = getBearerTokenProvider(credential, scope);
		client = new AzureOpenAI({ endpoint, azureADTokenProvider, apiVersion, deployment });
	}
	// chat completionクエリ
	// https://learn.microsoft.com/ja-jp/javascript/api/overview/azure/openai-readme?view=azure-node-latest
	// https://platform.openai.com/docs/guides/structured-outputs
	return await client.chat.completions.create({
		messages: messages,
		max_tokens: maxTokens,
		response_format: schema,
	});
}
```
認証部分は少しややこしいですが、Azureへのデプロイ時はFunction Appに付与したロールによりAOAIを利用するため、APIキーの定義は不要です。ローカル開発時はやむを得ずAZURE_OPENAI_API_KEYを環境変数に定義し、APIキーを利用してAOAIにアクセスするようにしています。

ここは王道のやり方を知らないので、有識者の方のコメントをいただければ幸いです。

### バックエンド構築スクリプト
リソースグループ、AOAI、Functions Appを構築して連携させるためのCLIコードです。
削除するときはリソースグループごと全消去。

```bash
#=============================================================================
# デプロイ先の設定
#=============================================================================
# ランダム識別子の生成
let RID=${RANDOM}*${RANDOM}

# 対象サブスクリプション
SUBSCRIPTION_NAME=my_subscription
LOCATION=eastasia

# サブスクリプションの設定
az account set --subscription $SUBSCRIPTION_NAME

#=============================================================================
# リソースグループ
#=============================================================================
# リソースグループの設定
RESOURCE_GROUP_NAME=rg-miro-backend-${RID}

# リソースグループの作成
az group create --name $RESOURCE_GROUP_NAME --location $LOCATION

#=============================================================================
# Azure OpenAI
#=============================================================================
# Azure OpenAIの設定
AOAI_LOCATION=japaneast
AOAI_SERVICE_NAME=aoai-miro-backend-${RID}
AOAI_MODEL_NAME=gpt-4o
AOAI_MODEL_VERSION=2024-08-06
AOAI_DEPLOY_NAME=${AOAI_MODEL_NAME}-${AOAI_MODEL_VERSION}

# サービスを作成
az cognitiveservices account create --name $AOAI_SERVICE_NAME --resource-group $RESOURCE_GROUP_NAME --location $AOAI_LOCATION --kind OpenAI --sku s0 --custom-domain $AOAI_SERVICE_NAME

# エンドポイントを取得
AOAI_ENDPOINT=$(exec_or_die az cognitiveservices account show --name $AOAI_SERVICE_NAME --resource-group $RESOURCE_GROUP_NAME | jq -r .properties.endpoint)

# 補足：モデル名一覧は以下のコマンドで取得可能
# az cognitiveservices model list -l japaneast | jq -r '.[] | select(.kind == "OpenAI") | .model | [.name, .version] | @tsv'

# モデルのデプロイ
az cognitiveservices account deployment create --name $AOAI_SERVICE_NAME --resource-group $RESOURCE_GROUP_NAME --deployment-name $AOAI_DEPLOY_NAME --model-name $AOAI_MODEL_NAME --model-version $AOAI_MODEL_VERSION --model-format OpenAI --sku-capacity 10 --sku-name GlobalStandard

#=============================================================================
# Azure Functions
#=============================================================================
# Function Appの設定
STORAGE_NAME=samirobe${RID}
FUNCTION_APP_NAME=fn-miro-backend-${RID}
SKU_STORAGE=Standard_LRS
FUNCTIONS_VERSION=4
FUNCTIONS_OS_TYPE=Linux
FUNCTIONS_RUNTIME=node
FUNCTIONS_RUNTIME_VERSION=20

# ストレージアカウントの作成
az storage account create --name $STORAGE_NAME --sku $SKU_STORAGE --resource-group $RESOURCE_GROUP_NAME --location $LOCATION --allow-blob-public-access false

# Defender for Storageの無効化（※上流でCCoEが有効化しているため）
az security atp storage update --resource-group $RESOURCE_GROUP_NAME --storage-account $STORAGE_NAME --is-enabled false

# Function Appの作成
az functionapp create --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME --storage-account $STORAGE_NAME --flexconsumption-location $LOCATION --functions-version $FUNCTIONS_VERSION --os-type $FUNCTIONS_OS_TYPE --runtime $FUNCTIONS_RUNTIME --runtime-version $FUNCTIONS_RUNTIME_VERSION

# 環境変数の設定（AOAIエンドポイント）
az functionapp config appsettings set --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME --settings "'AZURE_OPENAI_ENDPOINT=${AOAI_ENDPOINT}'"

# IDの割り当て
subscription_id=`az account show --subscription $SUBSCRIPTION_NAME --query "[id]" --output tsv`
az functionapp identity assign --resource-group $RESOURCE_GROUP_NAME --name $FUNCTION_APP_NAME --role "'Cognitive Services OpenAI User'" --scope /subscriptions/${subscription_id}/resourceGroups/${RESOURCE_GROUP_NAME}

# CORS設定（localhost vite）
cors_allowed_origin="http://localhost:3000"
az functionapp cors add --allowed-origins $cors_allowed_origin --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME
```

# 今後やりたいこと
AI系の機能追加はいくらでもできそうですね。
音声入力で付箋を作るとか、複数のオブジェクトからまとめレポートを出力するとか。
アイデア出しのAIに複数人格を用意するのも面白そうです。完全にエージェント化し、Web SDKではなくAPI連携で自律的にMiroに書き込みを行うような作りにすれば、ぼっちユーザーでもみんなでディスカッションしている世界が作れます。

![ひとりじゃないよ](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhMbKnTZZjMvL9MvgYS8ONbyUgenAUDEbLc_68-ciVzMcVo_SiRrLq9IjqmR5TUnh5xBtS0IGJ18r3xq0U81SriiMpFRJI9qshgqmTgL7-bmuH_6YJUCJzO9PHcu024Sgv85Bribry_w_KU/s450/kjhou_shifuku.png)

使い勝手の面では、こんなところは作り込みが必要になりそうです。
* 付箋を空きスペースにうまく配置する
* AIの呼び出しを多重起動しない
* AI利用のユーザ認証、接続制限

作成したMiro Appは、マーケットプレイスで公開する以外に、[インストール用のURLを生成](https://developers.miro.com/docs/share-an-app-outside-of-a-developer-team)してクローズドに利用することが可能です。社内で共有して使う場合、Azure側にネットワーク的な接続制限を設定することで、外部ユーザによるAI機能の濫用を防ぐことができます。

# おまけ：はまったところ
最後に、少し時間を取られたことについて書いておきます。ほぼAzure周りです。

### Miro App：Hello World
チュートリアルの初めのステップで ```npx create-miro-app@latest``` を実行するように書かれていますが、何を選んでもエラーになりました...。

```
$ npx create-miro-app@latest
✔ What is your application name? … my-miro-app
✔ Pick your framework: › Vanilla [WEB SDK]
✔ Pick your flavour: › JavaScript

Creating a new Miro app in /Users/xxx/tmp/miro/my-miro-app.

creating jsconfig.json

Aborting installation.

			Unexpected error. Report it as a bug, so we can look into it:
			TypeError: ws.availableParallelism is not a function
```
ググっても同様の報告がないので、私の環境がまずいような気がします。たぶんハマるのでコマンドでのセットアップはサクッとあきらめて、手作業で開発環境を設定しました。恐らく、うまく行くと、Miro Appのテンプレートとviteの環境が設定されます。

### Miro画面に対する描画
前述した読み込みフローをよく見ればわかりますが、Miro AppはMiro本体と同じスクリプト空間ではなく、headless iframeなど独立した空間で動作します。このため、ブラウザ上に自由に描画を行うことはできません。できてしまうと拡張機能の範疇を超えてしまうので、当然の制御ではありますね。

例えば、自前のポップアップを表示しようとしても、画面には何も表示されず、コンソールにエラーも出力されません。ユーザに情報を提示したい場合は[miro.board.notificationsパッケージ](https://developers.miro.com/docs/websdk-reference-notifications)を使うのが正解です。

### Azure OpenAI利用時の認証
AOAIのインスタンス作成時にカスタムドメインを設定しているかどうかで、エンドポイントの定義が変わります。

* カスタムドメイン設定時：「インスタンス名.openai.azure.com」
  * 例：```https://my-aoai-service.openai.azure.com/```
* カスタムドメイン未設定時：「リージョン名.api.cognitive.microsoft.com」
  * 例：```https://westus.api.cognitive.microsoft.com/```

後者の場合、AIモデルのデプロイに対するエンドポイント（ターゲットURI）は例えば「```https://westus.api.cognitive.microsoft.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-08-01-preview```」のようになりますが、これでデプロイを一意に識別することはできないため、APIキーに含まれる情報でデプロイを特定する（＝APIキーを利用しないとアクセスできない）ものと推測します。つまり、Function AppにどのスコープでCognitive Services OpenAI Userのロールを割り当てても、Entra ID認証でAOAIを利用することはできません。

よってカスタムドメインを割り当てる必要がありますが、CLIからAzure OpenAIサービスを作成する場合、デフォルトではカスタムドメインなしになります。「--custom-domain」の指定が必要。

```bash
$ az cognitiveservices account create --name $AOAI_SERVICE_NAME \
--resource-group $RESOURCE_GROUP_NAME --location $AOAI_LOCATION \
--kind OpenAI --sku s0 --custom-domain $AOAI_SERVICE_NAME
```

ポータル画面からAzure OpenAIサービスを作成した場合は、デフォルトでインスタンス名がカスタムドメイン名になります。なんでや。

なお、Entra ID認証の場合、APIキーは不要ですが、無効化する手段はなさそうです。
また、ポータルからAPIキーを再生成すると長い文字列（[a-zA-Z0-9]、84文字）が発行されますが、CLIで再生成すると短い文字列（[a-f0-9]、32文字）になります。なんでや。

### Azure Functions Flex従量課金プラン
Azure Functionsをしばらくぶりに使いましたが、Flex従量課金というプランが追加されていました（ブランクがやばい...）。ポータルから作成する場合はこれがデフォルト。

![create-functions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162839/36c5726a-01e9-aae9-e310-39be9bdbfccd.png)

オルターブースのブログ記事 [Azure Function Flex Consumptionを試してみた](https://aadojo.alterbooth.com/entry/2024/06/18/162110) でも触れられている通り、Flexのほうがコスパに優れ、VNet統合も可能とのことで、Flexを選択して作成。

...というだけでは済まず、Flex従量課金の場合、デプロイにおいて以下の対応が必要でした。

#### Github Actionsからのデプロイ
Flex従量課金プランの場合、Actionsのワークフロー内のAzure/functions-action@v1に以下のパラメータの指定が必要です。何かしら海より深い理由があるとは思うのですが、プランによってデプロイのパラメータが変わるのはダサい。

* sku: flexconsumption
* remote-build: true

```
- name: 'Run Azure Functions Action'
  uses: Azure/functions-action@v1
  id: fa
  with:
    app-name: ${{ vars.AZURE_FUNCTIONAPP_NAME }}
    package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
    publish-profile: ${{ secrets.AZURE_FUNCTION_APP_PUBLISH_PROFILE }}
    sku: flexconsumption
    remote-build: true
```

これが指定されていない場合、ワークフロー実行時に以下のようなエラーが発生します。

```
Run Azure/functions-action@v1

Successfully parsed SCM credential from publish-profile format.
Using SCM credential for authentication, GitHub Action will not perform resource validation.
Successfully acquired app settings from function app (with SCM credential)!
Will archive backend into /home/runner/work/_temp/temp_web_package_36705276975725853.zip as function app content
Will use Kudu https://<scmsite>/api/zipdeploy to deploy since publish-profile is detected.
Setting SCM_DO_BUILD_DURING_DEPLOYMENT in Kudu container to false
Update using context.kuduService.updateAppSettingViaKudu
Response with status code 405
App setting SCM_DO_BUILD_DURING_DEPLOYMENT has not been propagated to Kudu container yet, remaining retry 19
...
App setting SCM_DO_BUILD_DURING_DEPLOYMENT has not been propagated to Kudu container yet, remaining retry 1
App setting SCM_DO_BUILD_DURING_DEPLOYMENT has not been propagated to Kudu container yet, remaining retry 0
Warning: App setting SCM_DO_BUILD_DURING_DEPLOYMENT fails to propagate to Kudu container
Setting ENABLE_ORYX_BUILD in Kudu container to false
Update using context.kuduService.updateAppSettingViaKudu
Response with status code 405
App setting ENABLE_ORYX_BUILD has not been propagated to Kudu container yet, remaining retry 19
...
App setting ENABLE_ORYX_BUILD has not been propagated to Kudu container yet, remaining retry 1
App setting ENABLE_ORYX_BUILD has not been propagated to Kudu container yet, remaining retry 0
Warning: App setting ENABLE_ORYX_BUILD fails to propagate to Kudu container
Package deployment using ZIP Deploy initiated.
Error: Failed to deploy web package to App Service.
Error: Execution Exception (state: PublishContent) (step: Invocation)
Error:   When request Azure resource at PublishContent, zipDeploy : Failed to use /home/runner/work/_temp/temp_web_package_36705276975725853.zip as ZipDeploy content
Error:     Failed to deploy web package to App Service.
Bad Gateway (CODE: 502)
Error:       Error: Failed to deploy web package to App Service.
Bad Gateway (CODE: 502)
    at Kudu.<anonymous> (/home/runner/work/_actions/Azure/functions-action/v1/lib/appservice-rest/Kudu/azure-app-kudu-service.js:238:41)
    at Generator.next (<anonymous>)
    at fulfilled (/home/runner/work/_actions/Azure/functions-action/v1/lib/appservice-rest/Kudu/azure-app-kudu-service.js:5:58)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
Error: Deployment Failed!
```

#### ローカルからのデプロイ
CLIでのデプロイは、以下のコマンドで実行可能ですが、どうにもこれがエラーになりました。

```
$ func azure functionapp publish ${FUNCTION_APP_NAME} --subscription ${SUBSCRIPTION_ID}
Getting site publishing info...
[2024-12-21T18:33:11.010Z] Starting the function app deployment...
[2024-12-21T18:33:11.016Z] Creating archive for current directory...
Uploading 7.74 MB [###############################################################################]
Deployment in progress, please wait...
Starting deployment pipeline.
...
```

Flex従量課金以外のプランではデプロイに成功するのですが、Flexプランではパッケージのアップロード後に「404 Not Found」のエラーが発生してデプロイに失敗します。
試行錯誤の結果、最終的に Azure Functions Core Tools のバージョンが古いことが原因だと分かりました。4.0.5455→4.0.6610へのバージョンアップで解消しました。パッチバージョンの違いにも注意が要りますね。

#### SCM基本認証の設定（Flexプラン以外でも同様）
![functions-scm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162839/bf4b0e2c-4b05-fcbc-d657-ebce39398a4a.png)

Function Appの作成画面ですが、こんな指定、前からあったっけ...？
発行プロファイルを使ってデプロイする場合はSCM基本認証を有効にする必要があります。デフォルトは無効になっています。
なお、CLIでFunction Appを作成した場合、デフォルトで有効になります。なんでや。

# 参考サイト
* [Azure OpenAI Service リソースを作成してデプロイする](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/how-to/create-resource?pivots=cli)（MS Learn）
* [Microsoft Entra ID 認証を使用して Azure OpenAI Service を構成する方法](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/how-to/managed-identity)（MS Learn）
* [az cognitiveservices リファレンス](https://learn.microsoft.com/ja-jp/cli/azure/cognitiveservices?view=azure-cli-latest)（MS Learn）
* [GitHub Actions を使用した継続的デリバリー](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-how-to-github-actions?tabs=windows%2Cdotnet&pivots=method-template)（MS Learn）
* [App Service デプロイで基本認証を無効にする](https://learn.microsoft.com/ja-jp/azure/app-service/configure-basic-auth-disable?tabs=cli)（MS Learn）
* [Azure CLIでAzure OpenAI Serviceの各リージョンで提供されているモデルを調べてみる](https://zenn.dev/kun432/scraps/ed4bdaa6c98a01)（Zenn）
* [miro Web開発者ガイド](https://developers.miro.com/docs/miro-web-sdk-introduction)（Miro）
* [miro SDKリファレンス](https://developers.miro.com/docs/sdk-reference)（Miro）
* [miro APIリファレンス](https://developers.miro.com/reference/overview#create-board)（Miro）
* [オンラインSVG作成ツール Corel Vector](https://app.corelvector.com/)（Corel Vector）