Streamlit in Snowflake (SiS) で手書きフロー図の清書アプリを作ってみた
はじめに
前回の記事「Snowflake の COMPLETE関数のマルチモーダル機能の価値」では、Snowflake の生成AI機能 Cortex AI において利用可能になった COMPLETE 関数のマルチモーダル機能について紹介しました。今回は、この機能を活用した実用的なアプリケーションとして「フロー図 AI 清書アプリ」を実装していきます。(実装と言ってもコピペですぐに動かせます!)
本アプリケーションは、手書きのフロー図をアップロードすると、AIがその内容を解析して、Graphviz 形式の DOT コードを生成し、整形されたフロー図を表示するというものです。会議でホワイトボードに書いた図や、紙に手書きしたアイデアスケッチを、簡単にキレイな図表に変換できる便利ツールとして活用できます。
特に私は仕事柄ホワイトボードにアーキテクチャ図を描くことが多いため、自動的に清書してくれる仕組みがあれば仕事の効率化に繋がると思いチャレンジしてみました!
Graphviz の活用方法については「Streamlit in Snowflake (SiS) と Cortex AI で実現するフロー図自動作成アプリ」を、Streamlit in Snowflake のファイルアップロード機能については「Streamlit in Snowflake でファイルのアップロードとダウンロードをしよう」も見ていただくと理解が深まると思いますのでご活用ください。
実装するアプリケーションの機能概要
今回実装する「フロー図 AI 清書アプリ」は以下の機能を持ちます:
-
画像アップロード: ユーザーが手書きのフロー図画像をアップロード
- ステージへのオリジナル画像のアップロード機能
- 指定したサイズへのリサイズした画像のアップロード機能
- AI分析: Snowflake の Cortex COMPLETE Multimodal 機能を使って画像を分析
- DOTコード生成: 画像から Graphviz の DOT コードを生成
- グラフ表示: 生成された DOT コードを使って整形されたフロー図を表示
実装には Streamlit in Snowflake を利用します。これにより、Snowflake の環境内でインタラクティブなウェブアプリケーションを構築でき、外部サービスとの連携なしで完結するアプリケーションが実現できます。
完成イメージ
画像アップロード機能
アップロードした画像のリサイズとプレビュー機能
Cortex COMPLETE Multimodal による画像分析機能
Cortex COMPLETE Multimodal により生成された DOT コード
DOT コードの編集機能
Graphviz による DOT コードの可視化
前提条件
- Snowflake アカウント
- Cortex LLM が利用できる Snowflake アカウント (クロスリージョン推論のリリースによりクラウドやリージョンの制約がほぼ無くなりました)
- Streamlit in Snowflake のインストールパッケージ
- python 3.11 以降
- snowflake-ml-python 1.8.1 以降
- python-graphviz 0.20.1 以降
※Cortex LLM のリージョン対応表 (Snowflake 公式ドキュメント)
ステージの設定について補足
ステージは本アプリケーション起動時に 自動的に作成 するようにしておりますので、前回の記事のように手動での準備は不要です。ステージやファイルアップロードの詳細な仕組みについては、以前投稿した以下の記事を参考にしてください。
この過去記事では、st.file_uploader
を用いたファイルアップロードや署名付き URL の生成方法などを解説しています。本アプリでも同じアプローチで内部ステージにファイルを保存し、AI 分析に利用しています。
手順
新規で Streamlit in Snowflake のアプリを作成
Snowsight の左ペインから『Streamlit』をクリックし、『+ Streamlit』ボタンをクリックし SiS アプリを作成します。
Streamlit in Snowflake のアプリを実行
Streamlit in Snowflake アプリの編集画面で snowflake-ml-python
と python-graphviz
をインストールし、以下コードをコピー&ペーストで貼り付けて完了です。(コードが長くなってしまったためアコーディオンを開いて表示してください)
ソースコード
import os
import io
import streamlit as st
import graphviz
from snowflake.snowpark.context import get_active_session
from snowflake.cortex import Complete as CompleteText
from PIL import Image
# アプリケーション設定
st.set_page_config(
layout="wide",
initial_sidebar_state="expanded"
)
# -------------------------------------
# Snowflakeセッションの取得
# -------------------------------------
session = get_active_session()
# -------------------------------------
# 定数・設定値
# -------------------------------------
# 対応する画像ファイル拡張子
SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']
# ステージ名(@なし)
DEFAULT_STAGE_NAME = "IMAGE_FLOW_STAGE"
# 画像リサイズの最大サイズのデフォルト値(ピクセル)
MAX_IMAGE_SIZE = 1024
# 画像ファイルの最大サイズ(MB)
MAX_FILE_SIZE_MB = 10.0
# -------------------------------------
# ステージの存在チェックと作成処理
# -------------------------------------
def ensure_stage_exists(stage_name_no_at: str):
"""
指定されたステージが存在しない場合は作成する。既に存在する場合は何もしない。
ディレクトリテーブルと暗号化を有効にする。
"""
try:
# ステージが存在するかチェック
session.sql(f"DESC STAGE {stage_name_no_at}").collect()
except:
# 存在しない場合はステージ作成
try:
session.sql(f"""
CREATE STAGE {stage_name_no_at}
DIRECTORY = ( ENABLE = true )
ENCRYPTION = ( TYPE = 'SNOWFLAKE_SSE' )
""").collect()
st.sidebar.success(f"ステージ @{stage_name_no_at} を作成しました。")
except Exception as e:
st.sidebar.error(f"ステージの作成に失敗しました: {str(e)}")
st.stop()
# -------------------------------------
# ステージ上のファイル一覧を取得
# -------------------------------------
def get_stage_files(stage_name: str):
"""
指定されたステージ上のファイル一覧を取得する
"""
try:
# ステージ名が@で始まっていない場合は追加
stage_name_with_at = stage_name
if not stage_name.startswith('@'):
stage_name_with_at = f"@{stage_name}"
stage_files = session.sql(f"LIST {stage_name_with_at}").collect()
if stage_files:
file_names = [
row['name'].split('/', 1)[1] if '/' in row['name'] else row['name']
for row in stage_files
]
return file_names
else:
return []
except Exception as e:
st.error(f"ステージのファイル一覧取得に失敗しました: {str(e)}")
return []
# -------------------------------------
# 画像のリサイズ処理
# -------------------------------------
def resize_image_if_needed(img_data: bytes, filename: str, max_size: int = MAX_IMAGE_SIZE) -> tuple:
"""
画像データを読み込み、必要に応じてリサイズする
Parameters:
- img_data: 画像のバイナリデータ
- filename: 元のファイル名
- max_size: 最大サイズ(ピクセル単位)
Returns:
- resize_needed: リサイズが必要だったかどうか
- resized_data: リサイズされた画像のバイナリデータまたは元のデータ
- resized_filename: リサイズされた画像のファイル名または元のファイル名
"""
try:
# 画像をPILで開く
img = Image.open(io.BytesIO(img_data))
width, height = img.size
# サイズ確認
if width > max_size or height > max_size:
# リサイズが必要
resize_ratio = min(max_size / width, max_size / height)
new_width = int(width * resize_ratio)
new_height = int(height * resize_ratio)
# リサイズ処理(高品質なリサイズを使用)
resized_img = img.resize((new_width, new_height), Image.LANCZOS)
# リサイズした画像をバイトストリームに変換
buffered = io.BytesIO()
# 元の形式を保持
file_ext = os.path.splitext(filename)[1].lower()
if file_ext in ['.jpg', '.jpeg']:
resized_img.save(buffered, format="JPEG", quality=95)
elif file_ext == '.png':
resized_img.save(buffered, format="PNG")
elif file_ext == '.webp':
resized_img.save(buffered, format="WEBP")
elif file_ext == '.gif':
resized_img.save(buffered, format="GIF")
elif file_ext == '.bmp':
resized_img.save(buffered, format="BMP")
else:
# デフォルトはPNG
resized_img.save(buffered, format="PNG")
# ファイル名を変更(リサイズしたことを示す)
name, ext = os.path.splitext(filename)
resized_filename = f"{name}_resized{ext}"
return True, buffered.getvalue(), resized_filename, (new_width, new_height)
else:
# リサイズ不要
return False, img_data, filename, (width, height)
except Exception as e:
# エラーが発生した場合は元のデータを返す
st.warning(f"画像のリサイズ中にエラーが発生しました: {str(e)}。元のサイズで処理を続行します。")
return False, img_data, filename, (0, 0)
# -------------------------------------
# ステージ上のファイルの検証
# -------------------------------------
def verify_image_file(stage_name: str, file_name: str):
"""
指定されたファイルがステージ上に存在し、有効な画像形式かを検証する
"""
try:
# ステージ名が@で始まっていない場合は追加
stage_name_with_at = stage_name
if not stage_name.startswith('@'):
stage_name_with_at = f"@{stage_name}"
# ファイルの存在確認
file_check = session.sql(f"LIST {stage_name_with_at}/{file_name}").collect()
if not file_check:
return False, "ファイルがステージ上に存在しません"
# ファイル拡張子の確認
file_extension = os.path.splitext(file_name)[1].lower()
if file_extension not in SUPPORTED_IMAGE_EXTENSIONS:
return False, f"サポートされていないファイル形式です: {file_extension}"
# ファイルサイズの確認
file_size_bytes = file_check[0]['size']
file_size_mb = file_size_bytes / (1024 * 1024)
if file_size_mb > MAX_FILE_SIZE_MB:
return False, f"ファイルサイズが大きすぎます({file_size_mb:.2f}MB)。{MAX_FILE_SIZE_MB}MB以下である必要があります。"
return True, "検証成功"
except Exception as e:
return False, f"ファイル検証中にエラーが発生しました: {str(e)}"
# -------------------------------------
# AI関連の関数
# -------------------------------------
def analyze_image_with_ai(stage_name: str, file_name: str, model_name: str, prompt: str):
"""
ステージ上の画像ファイルをAIで分析する
"""
query = "クエリはまだ実行されていません" # 例外処理のためにデフォルト値を設定
try:
# ファイル検証
is_valid, validation_message = verify_image_file(stage_name, file_name)
if not is_valid:
return validation_message
# 特殊文字のエスケープは最小限に
sql_safe_prompt = prompt.replace("'", "''")
# ステージ名に@が必ず含まれるようにする
if not stage_name.startswith('@'):
stage_name_with_at = f"@{stage_name}"
else:
stage_name_with_at = stage_name
# Snowflakeドキュメントの例に完全に忠実なSQL構文
query = f"SELECT SNOWFLAKE.CORTEX.COMPLETE('{model_name}', '{sql_safe_prompt}', TO_FILE('{stage_name_with_at}', '{file_name}'))"
# クエリ実行
result = session.sql(query).collect()
# 結果を返す
if result and len(result) > 0:
return result[0][0]
else:
return "AIによる分析結果が取得できませんでした。"
except Exception as e:
# エラーメッセージを詳細に取得
error_str = str(e)
return f"AI分析中にエラーが発生しました: {error_str}\n実行クエリ: {query}"
# -------------------------------------
# DOTコードを生成するAIプロンプト
# -------------------------------------
def generate_dot_code_prompt():
"""
AIにDOTコードを生成させるためのプロンプトを作成する
"""
return """
与えられたフロー図やダイアグラムについて、以下のルールに従ってGraphvizのDOTコードを生成してください。
1. 与えられたフロー図やダイアグラムを把握し、可能な限り忠実に反映させたDOTコードを生成すること。
2. ノードやエッジ、フローの形状や色は、与えられたフロー図やダイアグラムを考慮しつつ、適切なものを選択すること。
3. ノードやエッジ、フローには、可能な限りラベルを付与すること。
4. 色が無い場合は必要に応じて可読性が上がるような色を選択すること。
5. 生成されたDOTコードは、Graphvizで描画可能な形式で返すこと。
6. 生成されたDOTコードは、"digraph G {" と "}" で囲むこと。
7. 応答は、生成されたDOTコードのみを返し、それ以外の応答はしないこと。
"""
# -------------------------------------
# DOTコードを整形する関数
# -------------------------------------
def format_dot_code(dot_code: str):
"""
AIが生成したDOTコードを整形する
"""
# DOTコードが「```」で囲まれている場合は取り除く
dot_code = dot_code.strip()
if dot_code.startswith("```dot"):
dot_code = dot_code[6:]
elif dot_code.startswith("```"):
dot_code = dot_code[3:]
if dot_code.endswith("```"):
dot_code = dot_code[:-3]
return dot_code.strip()
# -------------------------------------
# セッション状態の初期化
# -------------------------------------
if 'dot_code' not in st.session_state:
st.session_state['dot_code'] = ""
if 'ai_output' not in st.session_state:
st.session_state['ai_output'] = ""
if 'analyzed_image' not in st.session_state:
st.session_state['analyzed_image'] = None
if 'original_image' not in st.session_state:
st.session_state['original_image'] = None
if 'image_dimensions' not in st.session_state:
st.session_state['image_dimensions'] = None
if 'active_tab' not in st.session_state:
st.session_state['active_tab'] = "upload"
# -------------------------------------
# アプリケーションのメイン部分
# -------------------------------------
def main():
st.title("フロー図 AI 清書アプリ")
st.markdown("手書きのフロー図をアップロードして、AIによるGraphvizコードを生成・表示するツールです。")
# サイドバー設定
st.sidebar.header("設定")
# ステージ設定
stage_name = st.sidebar.text_input(
"ステージ名を入力してください (例: IMAGE_FLOW_STAGE)",
DEFAULT_STAGE_NAME
)
# ステージが存在しない場合は作成
stage_name_no_at = stage_name.lstrip('@')
ensure_stage_exists(stage_name_no_at)
# AIモデル選択
st.sidebar.header("AIモデル設定")
model_name = st.sidebar.selectbox(
"使用するAIモデルを選択してください",
[
"claude-3-5-sonnet",
"pixtral-large"
],
index=0
)
# 画像リサイズ設定
st.sidebar.header("画像処理設定")
resize_enabled = st.sidebar.checkbox("大きな画像を自動的にリサイズする", value=True)
resize_max_size = st.sidebar.slider("リサイズ時の最大サイズ(ピクセル)", 512, 2048, MAX_IMAGE_SIZE)
# タブの作成
tab_upload, tab_analyze, tab_visualize = st.tabs([
"画像アップロード",
"AI分析",
"Graphviz表示"
])
# タブ1: 画像アップロード
with tab_upload:
st.header("フロー図画像のアップロード")
st.write("フロー図やアーキテクチャ図の画像をアップロードしてください。")
uploaded_file = st.file_uploader(
"JPG, PNG, WEBP, GIF, BMPファイルを選択できます",
type=['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp']
)
if uploaded_file:
# ファイルの拡張子を確認
file_extension = os.path.splitext(uploaded_file.name)[1].lower()
if file_extension in SUPPORTED_IMAGE_EXTENSIONS:
try:
# ファイルをバイナリとして読み込み
file_data = uploaded_file.getvalue()
# 必要に応じて画像をリサイズ
if resize_enabled:
resized, resized_data, resized_filename, img_dimensions = resize_image_if_needed(
file_data, uploaded_file.name, resize_max_size
)
else:
resized = False
resized_data = file_data
resized_filename = uploaded_file.name
# サイズ情報取得
img = Image.open(io.BytesIO(file_data))
img_dimensions = img.size
# ステージにアップロード
stage_name_with_at = f"@{stage_name_no_at}"
# 元のファイルと、リサイズされた場合はそれもアップロード
if resized:
# 元のファイルをアップロード
session.file.put_stream(
io.BytesIO(file_data),
f"{stage_name_with_at}/{uploaded_file.name}",
auto_compress=False,
overwrite=True
)
# リサイズされたファイルをアップロード
session.file.put_stream(
io.BytesIO(resized_data),
f"{stage_name_with_at}/{resized_filename}",
auto_compress=False,
overwrite=True
)
st.success(f"ファイル '{uploaded_file.name}' をアップロードし、AI分析用に '{resized_filename}' としてリサイズしました!")
st.session_state['analyzed_image'] = resized_filename
st.session_state['original_image'] = uploaded_file.name
else:
# リサイズが不要な場合は元のファイルのみアップロード
session.file.put_stream(
io.BytesIO(file_data),
f"{stage_name_with_at}/{uploaded_file.name}",
auto_compress=False,
overwrite=True
)
st.success(f"ファイル '{uploaded_file.name}' のアップロードが完了しました!")
st.session_state['analyzed_image'] = uploaded_file.name
st.session_state['original_image'] = uploaded_file.name
# 画像の寸法を保存
st.session_state['image_dimensions'] = img_dimensions
# 画像プレビュー
st.subheader("アップロードした画像のプレビュー")
st.image(uploaded_file, caption=f"{uploaded_file.name} ({img_dimensions[0]}x{img_dimensions[1]}ピクセル)")
if resized:
st.info(f"画像が大きいため、AI分析用に{img_dimensions[0]}x{img_dimensions[1]}ピクセルにリサイズされました。")
except Exception as e:
st.error(f"ファイルのアップロード中にエラーが発生しました: {str(e)}")
else:
st.error("サポートされていないファイル形式です。")
# タブ2: AI分析
with tab_analyze:
st.header("Cortex COMPLETE Multimodalによる画像分析")
# ステージ上の画像ファイル一覧
st.subheader("ステージ上の画像ファイル")
files = get_stage_files(stage_name_no_at)
if files:
selected_file = st.selectbox("分析する画像を選択", files)
if selected_file:
# 選択されたファイルをセッションに保存
st.session_state['analyzed_image'] = selected_file
# resizedファイルかどうかを確認
if "_resized" in selected_file:
# 元のファイル名を推測
original_name = selected_file.replace("_resized", "")
if original_name in files:
st.session_state['original_image'] = original_name
else:
st.session_state['original_image'] = selected_file
else:
st.session_state['original_image'] = selected_file
# ステージ名が@で始まっていない場合は追加
if not stage_name.startswith('@'):
stage_name_with_at = f"@{stage_name}"
else:
stage_name_with_at = stage_name
# 画像プレビュー
try:
# 署名付きURLを生成してファイルを表示する
url_result = session.sql(f"""
SELECT GET_PRESIGNED_URL(
'{stage_name_with_at}',
'{selected_file}',
3600
)
""").collect()
signed_url = url_result[0][0]
st.image(signed_url, caption=selected_file)
except Exception as e:
st.error(f"画像の取得中にエラーが発生しました: {str(e)}")
# DOTコード生成ボタン
if st.button("DOTコードを生成"):
with st.spinner(f"AIが画像 '{selected_file}' を分析しています..."):
prompt = generate_dot_code_prompt()
ai_output = analyze_image_with_ai(
stage_name,
selected_file,
model_name,
prompt
)
# 結果をセッションに保存
st.session_state['ai_output'] = ai_output
st.session_state['dot_code'] = format_dot_code(ai_output)
# DOTコードの表示
st.subheader("生成されたDOTコード")
st.text_area("DOTコード", st.session_state['dot_code'], height=300)
else:
st.info("ステージに画像ファイルがありません。「画像アップロード」タブで画像をアップロードしてください。")
# タブ3: Graphviz表示
with tab_visualize:
st.header("Graphvizによる表示")
if st.session_state['dot_code']:
# DOTコードの編集機能
dot_code = st.text_area("DOTコード(編集可能)", st.session_state['dot_code'], height=300)
st.session_state['dot_code'] = dot_code
try:
# DOTコードをGraphvizで描画
graph = graphviz.Source(dot_code)
st.subheader("生成された図表")
st.graphviz_chart(graph)
# エクスポート案内
st.info("図表をエクスポートするには、上記のDOTコードをコピーして保存してください。")
except Exception as e:
st.error(f"DOTコードの解析中にエラーが発生しました: {str(e)}")
else:
st.info("AI分析がまだ実行されていません。「AI分析」タブでDOTコードを生成してください。")
# ヘルプ情報
with st.expander("ヘルプ・使い方"):
st.markdown("""
### 使い方
1. **画像アップロード**タブで、フロー図やアーキテクチャ図の画像をアップロードします。
2. **AI分析**タブで、ステージ上の画像を選択してDOTコードを生成します。
3. **Graphviz表示**タブで、生成されたDOTコードを使ってフロー図を表示します。
### 画像リサイズについて
大きな画像をアップロードした場合、AI処理を効率化するために自動的にリサイズされます。
サイドバーの設定で、リサイズの有効/無効や最大サイズを調整できます。
### サポートされているファイル形式
- JPG/JPEG
- PNG
- WEBP
- GIF
- BMP(Pixtral Largeモデルのみ)
### AIモデルについて
- **Claude 3.5 Sonnet**: 高度な視覚処理能力と言語理解能力を持つAnthropicのマルチモーダルモデル
- **Pixtral Large**: Mistral AIによる視覚的推論タスクに優れたモデル
### Graphvizについて
Graphvizはグラフ構造を視覚化するためのツールです。DOT言語を使って様々な図表を表現できます。
生成されたDOTコードは編集可能で、手動で修正することもできます。
""")
# アプリケーションの実行
if __name__ == "__main__":
main()
技術的な工夫ポイント
1. 画像のリサイズ処理
画像処理は AI にとって計算コストが高いため、大きな画像の場合は自動的にリサイズする機能を実装しました。こうすることで、処理時間を短縮しつつ、コストも抑えることが可能です。
リサイズには Python の PIL (Pillow) ライブラリを使用し、LANCZOS 法による高品質なリサイズを行っています。このメソッドは、特に線画やテキストが含まれる図表のリサイズに適しています。
# リサイズ処理(高品質なリサイズを使用)
resized_img = img.resize((new_width, new_height), Image.LANCZOS)
2. プロンプトエンジニアリング
AI に適切な DOT コードを生成させるためのプロンプトは、以下のように Few-Shot でルールを与えています。ここは是非カスタマイズしていただき、より良いプロンプトを模索してみてください。
# -------------------------------------
# DOTコードを生成するAIプロンプト
# -------------------------------------
def generate_dot_code_prompt():
"""
AIにDOTコードを生成させるためのプロンプトを作成する
"""
return """
与えられたフロー図やダイアグラムについて、以下のルールに従ってGraphvizのDOTコードを生成してください。
1. 与えられたフロー図やダイアグラムを把握し、可能な限り忠実に反映させたDOTコードを生成すること。
2. ノードやエッジ、フローの形状や色は、与えられたフロー図やダイアグラムを考慮しつつ、適切なものを選択すること。
3. ノードやエッジ、フローには、可能な限りラベルを付与すること。
4. 色が無い場合は必要に応じて可読性が上がるような色を選択すること。
5. 生成されたDOTコードは、Graphvizで描画可能な形式で返すこと。
6. 生成されたDOTコードは、"digraph G {" と "}" で囲むこと。
7. 応答は、生成されたDOTコードのみを返し、それ以外の応答はしないこと。
"""
最後に
この「フロー図 AI 清書アプリ」は、Snowflake の Cortex COMPLETE Multimodal 機能を活用した実用的なアプリケーションの一例です。手書きのフロー図やダイアグラムを、AI の力を借りて整形された図表に変換することができます。
この実装例からわかるように、Snowflakeのデータプラットフォームとしての能力と、最新の生成AIの機能を組み合わせることで、これまでにない価値を生み出すアプリケーションを構築することができます。
今回のアプリケーションは、主に以下のような場面で活用できます:
- 会議でのホワイトボード図の整理
- 紙に描いたアイデアスケッチのデジタル化
- アーキテクチャ図やフロー図の清書
- 図表作成の時間短縮
Snowflake の Cortex COMPLETE Multimodal 機能は、このようなビジュアルデータの処理だけでなく、様々な画像処理タスクに応用可能です。今後も新しい活用方法について試してみたことを皆様に共有できればなーと思っています。
宣伝
SNOWFLAKE DISCOVER で登壇しました!
2025/4/24-25に開催されました Snowflake のエンジニア向け大規模ウェビナー『SNOWFLAKE DISCOVER』において『ゼロから始めるSnowflake:モダンなデータ&AIプラットフォームの構築』という一番最初のセッションで登壇しました。Snowflake の概要から最新状況まで可能な限り分かりやすく説明しておりますので是非キャッチアップにご活用いただければ嬉しいです!
以下リンクでご登録いただけるとオンデマンドですぐにご視聴いただくことが可能です。
生成AI Conf 様の Webinar で登壇しました!
『生成AI時代を支えるプラットフォーム』というテーマの Webinar で NVIDIA 様、古巣の AWS 様と共に Snowflake 社員としてデータ*AI をテーマに LTをしました!以下が動画アーカイブとなりますので是非ご視聴いただければ幸いです!
X で Snowflake の What's new の配信してます
X で Snowflake の What's new の更新情報を配信しておりますので、是非お気軽にフォローしていただければ嬉しいです。
日本語版
Snowflake の What's New Bot (日本語版)
English Version
Snowflake What's New Bot (English Version)
変更履歴
(20250417) 新規投稿
(20250508) 宣伝文修正
Discussion