🤳

貼り紙やメモを撮ってWi‑Fi接続!QR生成までサクッとやってくれるAIエージェントを作った

2025/02/11に公開

概要

AI Agent Hackathon with Google Cloudが盛り上がっていますね!!

ところで、飲食店やホテルなど、さまざまな施設で掲示されるWi‑Fi接続情報。手入力だと面倒なうえに、入力ミスで接続トラブルが発生することも…。

喫茶店とかうどん屋さんとかにあるこういう貼り紙(あくまで手作りの一例です)

そこで今回作ったアプリは、スマホのブラウザで動き、Wi‑FiのSSIDやパスワードが記載された書類やポスターの写真をアップロードするだけで、LLMが即座に接続用のQRコードを生成しちゃうという画期的なAIエージェント。

名付けて「VTQ Wi‑Fi Reader」(Vision to QRの略)です!
https://youtube.com/watch?v=mv6oilYFt-Q
後述しますが、Difyというローコードツールを使って作っています。

この記事では、開発の背景から実装まで、初心者でも分かりやすく解説していきます。最後にはDSLファイルでインポート可能なプロジェクトファイルも公開しているので、ぜひ使ってみてください!

Difyとは?

今回の開発では、アプリ開発の基盤として「Dify」を使っています。

Difyは、プログラミングの知識がなくても直感的な操作だけでAIアプリが作れる、オープンソースのノーコードプラットフォームなんです。画面上でブロックを組み合わせるだけで、RAGや画像認識など、LLMを使った複雑な処理を簡単に実現できちゃうので、エンジニア以外の方にもおすすめ。

Difyの使い方や特徴、料金体系については、以下の外部記事も参考にしてみてくださいね。

プロジェクトについて

アプリの目的と背景

みなさんもご存知の通り、Wi‑Fi情報って紙に書かれて掲示されることが多いんですが、実際に接続するときには手入力が必要。これが面倒だったり、入力ミスで困ったりすることがたくさんあります

そこで生まれたのが、スマホで撮影したWi‑Fi情報の写真からOCRでSSIDとパスワードを抽出し、その情報をもとにQRコードを作るAIエージェント「VTQ Wi‑Fi Reader」です。これで、手入力の煩わしさを解決したいと考えました。

対象ユーザー

このアプリは、以下のような方々におすすめします。

  • 飲食店やホテルでのWi-Fi接続でポスターやカードに書かれた情報の手入力が煩わしい方
  • 複数人のイベントや会議などで、グループ内へすぐにWi‑Fi情報を共有したい方。

課題

  • 面倒な手入力を減らす
  • 入力ミスが原因で接続できないことを防ぐ
  • グループ内でのWi-Fi共有を簡単にする

ソリューション

写真から自動でQRコード生成!「VTQ Wi‑Fi Reader」は、さまざまな形で書かれたWi-Fi情報を掲示物から撮影し、そこからWi‑Fi設定のための情報を読み取って接続用のQRコードを生成します。

自分の入力の手間が減る&一緒に旅行に来ている友達にもQRコードを共有すれば、人気者になれるかも!?

設計

ワークフローの構成

※ワークフロー図

  • 入力ブロック
    • ファイル入力の機能を使い、画像の入力を受け取っています
  • 読み取りブロック
    • LLMブロックを使い、後述のプロンプトで画像の内容を読み取り、Wi-Fi接続のQRコードの変換元になる文字列を生成します
  • 結果判定ブロック
    • 条件分岐ブロックを使い、前のブロックの出力がWi-Fi接続QRコード用のフォーマットになっているかバリデートします。OKなら次に進み、ダメならエラーブロックに処理を流します
  • QR生成ブロック
    • SSID、PW等が含まれる文字列を受け取り、QRコードに変換します
  • 出力ブロック
    • QR生成ブロックが生成したQRコードの画像をユーザーに表示します
  • テンプレートブロック
    • エラー時の日本語の案内文を設定しています(例:"QRコード生成でエラーが発生しました。もう一度やり直してみてください。")
  • エラーブロック
    • 読み取り、結果判定、QR生成の過程でエラーが起こった場合、このブロックに処理が分岐します。

設計時の思想と設計のポイント

設計において意識したり、工夫したりしたポイントを補足的に紹介します。

Gemini 1.5 flash モデルを使った高速化

画像から文字情報を抽出するために、Gemini APIでGeminiのLLMを使っています。画像処理が可能かつ高速な処理ができる「Gemini 1.5 flash」モデルを使っています。これにより、撮影された画像からSSIDやパスワードを素早いレスポンスで抽出できるようになっています。

Few Shot プロンプトとパラメータ設定による動作の安定化

出力例を3つ使ったFew Shot プロンプトと、低めのtemperature設定で、入力画像に対して安定した結果が得られるように工夫しました。これにより、どんな画像でも一貫した結果を出せるのがポイントです。

▼システムプロンプト

あなたは優秀なAIアシスタントです。

▼ユーザープロンプト

写真に含まれるSSID、パスワードの情報を読み取り、以下の形式で出力してください。SSIDはIDやid、パスワードはpassword、PASS、PW、など別の呼び方で書かれている場合もありますので可能な限り意味を汲み取って判定してください。
## 制約
以下の出力形式を守り、前置きや注釈は絶対に入れないでください。
## 出力形式
WIFI:S:{SSID};T:WPA;R:1;P:{パスワード};;
## 出力例
### 例1
WIFI:S:abcdefg;T:WPA;R:1;P:password;;
### 例2
WIFI:S:aiueo;T:WPA;R:1;P:kakikukeko;;
### 例3
WIFI:S:tanoshiwifi;T:WPA;R:1;P:tanoshipass;;

エラーハンドリング

Dify v0.14.2で新しく加わったエラーハンドリング機能を活用して、各処理で発生する可能性のあるエラーをしっかりキャッチするようにしました!

エラーの際には即座にエラーメッセージが表示され、再試行がスムーズに行えるユーザーに優しい設計になっています。

補足

QR生成にはDify組み込みの QR Code Generator が使えました

Difyに搭載されているQR Code Generatorを利用すれば、追加の開発なしで文字列をQRコードに変換できます。

上記のプロンプトで示している形式の文字列をQRコードに変換することで、自動的にWi-Fi接続するQRコードの完成となります。
参考:Wi-Fi QRコード仕様のメモ

インフラには GCP Compute Engine を使っています

システム全体は、Google Cloud PlatformのCompute Engine上に構築しています。OSS(オープンソースソフトウェア)を自前で運用することで、カスタマイズもしやすく、コスパも良いです。DIfyを GCE上に構築する詳細の手順については、下記の別記事を参考にしてみてください!

GCEへのDify構築とアプリ公開まで | 政党比較アプリ「elecnecta」

DifyワークフローのDSLを公開

DifyのワークフローはDSLというyml形式でインポート・エクスポートできるようになっています。
そのまま読み込んで使えるDSLを下記に共有するので、Difyの開発環境がある方はぜひご活用ください!
※任意のymlファイルに下記のデータをコピーしてインポートしてください。

app:
  description: ''
  icon: 🎛️
  icon_background: '#EFF1F5'
  mode: workflow
  name: VTQ Wi‑Fi Reader
  use_icon_as_answer_icon: false
kind: app
version: 0.1.5
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      allowed_file_extensions:
      - .JPG
      - .JPEG
      - .PNG
      - .GIF
      - .WEBP
      - .SVG
      allowed_file_types:
      - image
      allowed_file_upload_methods:
      - local_file
      enabled: false
      fileUploadConfig:
        audio_file_size_limit: 50
        batch_count_limit: 5
        file_size_limit: 15
        image_file_size_limit: 10
        video_file_size_limit: 100
        workflow_file_upload_limit: 10
      image:
        enabled: false
        number_limits: 3
        transfer_methods:
        - local_file
        - remote_url
      number_limits: 1
    opening_statement: ''
    retriever_resource:
      enabled: true
    sensitive_word_avoidance:
      enabled: false
    speech_to_text:
      enabled: false
    suggested_questions: []
    suggested_questions_after_answer:
      enabled: false
    text_to_speech:
      enabled: false
      language: ''
      voice: ''
  graph:
    edges:
    - data:
        isInIteration: false
        sourceType: tool
        targetType: end
      id: 1739148258480-source-1739148598849-target
      source: '1739148258480'
      sourceHandle: source
      target: '1739148598849'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: start
        targetType: llm
      id: 1739147741488-source-1739227949027-target
      source: '1739147741488'
      sourceHandle: source
      target: '1739227949027'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: llm
        targetType: if-else
      id: 1739227949027-source-1739228623530-target
      source: '1739227949027'
      sourceHandle: source
      target: '1739228623530'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: if-else
        targetType: tool
      id: 1739228623530-true-1739148258480-target
      source: '1739228623530'
      sourceHandle: 'true'
      target: '1739148258480'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: if-else
        targetType: template-transform
      id: 1739228623530-false-1739260060776-target
      source: '1739228623530'
      sourceHandle: 'false'
      target: '1739260060776'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: template-transform
        targetType: end
      id: 1739260060776-source-1739260006592-target
      source: '1739260060776'
      sourceHandle: source
      target: '1739260006592'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: tool
        targetType: template-transform
      id: 1739148258480-fail-branch-1739260177287-target
      source: '1739148258480'
      sourceHandle: fail-branch
      target: '1739260177287'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: template-transform
        targetType: end
      id: 1739260177287-source-1739228459344-target
      source: '1739260177287'
      sourceHandle: source
      target: '1739228459344'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: template-transform
        targetType: end
      id: 17392602416500-source-1739260265322-target
      source: '17392602416500'
      sourceHandle: source
      target: '1739260265322'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: llm
        targetType: template-transform
      id: 1739227949027-fail-branch-17392602416500-target
      source: '1739227949027'
      sourceHandle: fail-branch
      target: '17392602416500'
      targetHandle: target
      type: custom
      zIndex: 0
    nodes:
    - data:
        desc: ''
        selected: false
        title: 入力
        type: start
        variables:
        - allowed_file_extensions: []
          allowed_file_types:
          - image
          allowed_file_upload_methods:
          - local_file
          label: image
          max_length: 48
          options: []
          required: true
          type: file
          variable: image
      height: 90
      id: '1739147741488'
      position:
        x: 30
        y: 267
      positionAbsolute:
        x: 30
        y: 267
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        error_strategy: fail-branch
        provider_id: qrcode
        provider_name: qrcode
        provider_type: builtin
        selected: false
        title: QR生成
        tool_configurations:
          border: 2
          error_correction: M
        tool_label: Generate QR Code
        tool_name: qrcode_generator
        tool_parameters:
          content:
            type: mixed
            value: '{{#1739227949027.text#}}'
        type: tool
      height: 152
      id: '1739148258480'
      position:
        x: 947.7142857142856
        y: 267
      positionAbsolute:
        x: 947.7142857142856
        y: 267
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        outputs:
        - value_selector:
          - '1739233262907'
          - text
          variable: text
        - value_selector:
          - '1739148258480'
          - files
          variable: files
        selected: false
        title: 出力
        type: end
      height: 116
      id: '1739148598849'
      position:
        x: 1237.4285714285716
        y: 267
      positionAbsolute:
        x: 1237.4285714285716
        y: 267
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        error_strategy: fail-branch
        model:
          completion_params:
            temperature: 0.4
          mode: chat
          name: gemini-1.5-flash-002
          provider: google
        prompt_template:
        - id: b6bfe8c8-4e07-4eb4-85a6-8fec04b2ac29
          role: system
          text: あなたは優秀なAIアシスタントです。
        - id: b3de08ed-b179-43f7-821c-d19171d49e29
          role: user
          text: '写真に含まれるSSID、パスワードの情報を読み取り、以下の形式で出力してください。SSIDはIDやid、パスワードはpassword、PASS、PW、など別の呼び方で書かれている場合もありますので可能な限り意味を汲み取って判定してください。

            ## 制約

            以下の出力形式を守り、前置きや注釈は絶対に入れないでください。

            ## 出力形式

            WIFI:S:{SSID};T:WPA;R:1;P:{パスワード};;

            ## 出力例

            ### 例1

            WIFI:S:abcdefg;T:WPA;R:1;P:password;;

            ### 例2

            WIFI:S:aiueo;T:WPA;R:1;P:kakikukeko;;

            ### 例3

            WIFI:S:tanoshiwifi;T:WPA;R:1;P:tanoshipass;;'
        retry_config:
          max_retries: 3
          retry_enabled: false
          retry_interval: 100
        selected: false
        title: 読み取り
        type: llm
        variables: []
        vision:
          configs:
            detail: high
            variable_selector:
            - '1739147741488'
            - image
          enabled: true
      height: 134
      id: '1739227949027'
      position:
        x: 334
        y: 267
      positionAbsolute:
        x: 334
        y: 267
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        outputs:
        - value_selector:
          - '1739260177287'
          - output
          variable: output
        selected: false
        title: QR生成エラー
        type: end
      height: 90
      id: '1739228459344'
      position:
        x: 1237.4285714285716
        y: 461.59142750250976
      positionAbsolute:
        x: 1237.4285714285716
        y: 461.59142750250976
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        cases:
        - case_id: 'true'
          conditions:
          - comparison_operator: start with
            id: f6ad7a10-f630-4317-a1db-70df00f05031
            value: 'WIFI:'
            varType: string
            variable_selector:
            - '1739227949027'
            - text
          - comparison_operator: contains
            id: ba3eedef-d1bc-4f01-85d2-39a0d5fccc10
            value: ;;
            varType: string
            variable_selector:
            - '1739227949027'
            - text
          id: 'true'
          logical_operator: and
        desc: ''
        selected: false
        title: 結果判定
        type: if-else
      height: 152
      id: '1739228623530'
      position:
        x: 639.4285714285714
        y: 267
      positionAbsolute:
        x: 639.4285714285714
        y: 267
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        outputs:
        - value_selector:
          - '1739260060776'
          - output
          variable: output
        selected: false
        title: フォーマットエラー
        type: end
      height: 90
      id: '1739260006592'
      position:
        x: 1237.4285714285716
        y: 577.0258225588977
      positionAbsolute:
        x: 1237.4285714285716
        y: 577.0258225588977
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        selected: false
        template: 無効な文字列が読み込まれました。もう一度やり直してみてください。
        title: エラー2
        type: template-transform
        variables: []
      height: 54
      id: '1739260060776'
      position:
        x: 947.7142857142856
        y: 577.0258225588977
      positionAbsolute:
        x: 947.7142857142856
        y: 577.0258225588977
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        selected: false
        template: 'QRコード生成でエラーが発生しました。もう一度やり直してみてください。

          '
        title: エラー3
        type: template-transform
        variables: []
      height: 54
      id: '1739260177287'
      position:
        x: 947.7142857142856
        y: 461.59142750250976
      positionAbsolute:
        x: 947.7142857142856
        y: 461.59142750250976
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        selected: false
        template: 読み取り時にエラーが発生しました。もう一度やり直してみてください。
        title: エラー1
        type: template-transform
        variables: []
      height: 54
      id: '17392602416500'
      position:
        x: 947.7142857142856
        y: 693.5209932534674
      positionAbsolute:
        x: 947.7142857142856
        y: 693.5209932534674
      selected: true
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        outputs:
        - value_selector:
          - '17392602416500'
          - output
          variable: output
        selected: false
        title: 終了 4
        type: end
      height: 90
      id: '1739260265322'
      position:
        x: 1237.4285714285716
        y: 693.5209932534674
      positionAbsolute:
        x: 1237.4285714285716
        y: 693.5209932534674
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    viewport:
      x: 17.477273307867563
      y: -168.46961960478973
      zoom: 0.9972185713510199

※DIfyとGemini APIの連携をしていない場合、LLMプロバイダの設定を別途行う必要があります。

まとめと今後の展望

今回は「VTQ Wi‑Fi Reader」を通して、スマホで手軽にWi‑Fi接続QRコードを発行するアプリの開発事例と、その技術的なポイントを解説しました。

今後はフロントエンドの改善をしていきたいと思っています。

今回はDify組み込みのフロントエンド表示機能を使いましたが、DifyにはバックエンドAPIとしてワークフローを公開する機能があるので、よりシンプルかつユーザーフレンドリーなUIの実装に挑戦したいですね。例えば、アプリ内でカメラを起動して画像撮影するとか・・・

Discussion