🐈

VPSで「認証つきDify環境」を作る⑦:添付ファイルを使えるようにする

に公開

💡 この記事はシリーズ⑦です。
前章までで、ChatbotUIからDifyのフローへメッセージを送り、SSE(ストリーミング)でリアルタイムに返答を受け取る仕組みが完成しました。
今回は、画像やPDFなどの添付ファイルをチャットで扱えるようにします。


⑦添付ファイルを使えるようにする

ここでは、画像やPDFなどの添付ファイルをチャットで扱えるようにします。
やることは「Difyでアップロード機能ON」→「Supabaseに「許可ルール」を追加」→「アップロード中継APIを作る」→「画面(ChatbotUI)でルールに従いアップロード」→「Difyのフロー側で受け取り方を整える(+掃除の仕組み)」といった流れになります。

では、いってみましょう。


1.Difyのフローで「ファイルアップロード」を有効化

Difyのフロー画面で「機能」→ ファイルアップロードをONにします。Dify側の容量・種類の上限は、このあとSupabaseに入れるポリシーと合わせます。


2.Supabaseに「添付ルール」を持たせる

さきほど作成したSupabaseのpublic.dify_apps に file_policy(JSONB) を追加します。ここに「添付OK/NG」「最大サイズ」「許可MIME」「最大枚数」を入れて、モデルごとにルールを持たせます。

public.dify_apps テーブルに file_policy という新しい列を追加。この列は JSONB型(バイナリ形式のJSON)で、ファイルアップロードに関するポリシーを格納します。

-- 1回だけ実行
ALTER TABLE public.dify_apps
ADD COLUMN file_policy jsonb NOT NULL DEFAULT jsonb_build_object(
  'enabled', false,                                      -- 既定はNG
  'max_bytes', 10485760,                                 -- 10MB 既定
  'allowed_mime', ARRAY['application/pdf','image/jpeg','image/png']::text[],
  'max_files', 3                                         -- 表示用/将来用
);

続いて、テストデータを挿入。

UPDATE public.dify_apps
SET file_policy = jsonb_build_object(
  'enabled', true,
  'max_bytes', 10485760,  -- Dify側の一番厳しいカテゴリ上限に合わせる
  'allowed_mime', ARRAY[
    'text/plain','text/markdown','text/csv','text/html',
    'application/pdf','application/msword',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'application/vnd.ms-excel',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    'image/jpeg','image/png','image/gif','image/webp','image/svg+xml'
  ]::text[],
  'max_files', 3
)
WHERE slug = 'あなたの-slug';

'あなたの-slug'; 部分は登録済みのSlug(support-botなど)の名称に置き換えてください。
また、ここはDifyの「機能>ファイルアップロード」設定と揃えてください(容量は“最小の上限”に寄せると安全です)。


3.「アップロード中継API」を作る(サーバ側)

ブラウザからのファイルを、サーバ経由で Dify の /v1/files/upload へ転送します。その前に、ログイン確認と file_policy のチェック(サイズ・MIME)を行います。

●作成するファイル
app/api/chat/custom/files/upload/route.ts

  • ポイント
    • クエリ ?model=<slug> と FormData(file=...) を受け取ります
    • SupabaseのログインユーザーIDを確認
    • dify_apps.file_policy を読み、サイズ/MIMEをチェック
    • OKなら、Authorization: Bearer <api_key> で Dify にそのまま転送
    • Difyが返す id(upload_file_id)や mime_type などのメタを返却

●作成

cd /opt/chatbot-ui
sudo mkdir -p /opt/chatbot-ui/app/api/chat/custom/files/upload
sudo nano /opt/chatbot-ui/app/api/chat/custom/files/upload/route.ts

中身は以下のコピペしてください。

app/api/chat/custom/files/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabaseAdmin'
import { cookies } from 'next/headers'
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function POST(req: NextRequest) {
  const url = new URL(req.url)
  const slug = url.searchParams.get('model')
  if (!slug) return NextResponse.json({ error: 'model required' }, { status: 400 })

  const form = await req.formData()
  const file = form.get('file') as File | null
  if (!file) return NextResponse.json({ error: 'file required' }, { status: 400 })

  const supabaseServer = createRouteHandlerClient({ cookies })
  const { data: { user } } = await supabaseServer.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  const endUser = user.id

  const { data, error } = await supabaseAdmin
    .from('dify_apps')
    .select('base_url, api_key, file_policy')
    .eq('slug', slug)
    .single()

  if (error || !data) return NextResponse.json({ error: 'unknown model slug' }, { status: 404 })

  const policy = (data as any).file_policy || {}
  if (!policy.enabled) return new NextResponse('File upload disabled for this app', { status: 403 })

  const maxBytes = Number(policy.max_bytes ?? 10 * 1024 * 1024)
  const allowed: string[] = Array.isArray(policy.allowed_mime) ? policy.allowed_mime : []

  if (file.size > maxBytes) return new NextResponse('File too large', { status: 413 })
  if (allowed.length && !allowed.includes(file.type)) {
    return new NextResponse('Unsupported media type', { status: 415 })
  }

  const fd = new FormData()
  fd.set('file', file)
  fd.set('user', endUser) // これで sys.files に紐づく

  const r = await fetch(`${data.base_url.replace(/\/+$/, '')}/v1/files/upload`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${data.api_key}` },
    body: fd
  })

  if (!r.ok) return new NextResponse(await r.text(), { status: r.status })
  return NextResponse.json(await r.json())
}

4.「モデル一覧API」に添付ルールを追加して返す

画面側が「このモデルは添付OK?」を知れるよう、/api/models/custom の返り値に file_policy を要約して同梱します。

編集するファイル
app/api/models/custom/route.ts

  • ポイント
    返却フィールドに filesEnabled / maxBytes / allowedMime / maxFiles を追加します。

●編集

cd /opt/chatbot-ui
sudo nano /opt/chatbot-ui/app/api/models/custom/route.ts 

「-」部分は削除し、「+」部分を追記します。

// app/api/models/custom/route.ts
import { NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabaseAdmin'

export const dynamic = 'force-dynamic'  // キャッシュ抑止(一覧が変わる可能性があるため)

export async function GET() {
  const { data, error } = await supabaseAdmin
    .from('dify_apps')
    .select('slug, display_name, file_policy')
    .eq('is_active', true)
    .order('display_name', { ascending: true })

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

 -  const models = (data ?? []).map((r) => ({
 -    id: r.slug,           // chats.model に入る値
 -    name: r.display_name, // UI表示名

+  const models = (data ?? []).map((r) => ({
+     id: r.slug,                 // chats.model に入る値
+     name: r.display_name,       // UI表示名
+     filesEnabled: !!r.file_policy?.enabled,
+     maxBytes: r.file_policy?.max_bytes ?? null,
+     allowedMime: r.file_policy?.allowed_mime ?? [],
+     maxFiles: r.file_policy?.max_files ?? null,
  }))

  return NextResponse.json(models, { status: 200 })
}

5.画面で「ルールに従って選択→サーバに中継」

chat-input.tsx を少し拡張し、選択中モデルの添付ポリシーを取得します。ポリシーに合う拡張子だけ選べるようにして、選んだらアップロード中継APIへ送るようにします。Difyが返す upload_file_id を一時キューに保存して、次の送信で使うようにします。

編集するファイル
components/chat/chat-input.tsx

  • ポイント
    • /api/models/custom を読んで filePolicy をstateに保持
    • +ボタンクリック時:enabled=false ならエラー表示
    • <input type="file"> の accept を allowedMime から生成
    • onChange:ファイルごとにサイズ/MIMEをチェック
        →api/chat/custom/files/upload?model=<slug> へ FormData でPOST
        →返ってきた id と種類(image or document)を globalThis.__difyPendingFiles に push

●編集

cd /opt/chatbot-ui
sudo nano /opt/chatbot-ui/components/chat/chat-input.tsx

「-」部分は削除し、「+」部分を追記します。

import { ChatbotUIContext } from "@/context/context"
import useHotkey from "@/lib/hooks/use-hotkey"
import { LLM_LIST } from "@/lib/models/llm/llm-list"

// --- 中略 ---

export const ChatInput: FC<ChatInputProps> = ({}) => {
  const { t } = useTranslation()

  useHotkey("l", () => {
    handleFocusChatInput()
  })

  const [isTyping, setIsTyping] = useState<boolean>(false)

  const {
+    profile,
    isAssistantPickerOpen,
    focusAssistant,
 
// --- 中略 ---

const fileInputRef = useRef<HTMLInputElement>(null)

+   // ★追加: dify_apps.file_policy を /api/models/custom から自動取得してキャッシュ
+   const [filePolicy, setFilePolicy] = useState<{
+     enabled: boolean
+     maxBytes: number | null
+     allowedMime: string[]
+     maxFiles?: number | null
+   } | null>(null)

+   // ★追加: モデル変更ごとにポリシーを取り直す
+   useEffect(() => {
+     let ignore = false
+     ;(async () => {
+       try {
+         const res = await fetch("/api/models/custom", { cache: "no-store" })
+         if (!res.ok) return
+         const list: Array<{
+           id: string
+           name: string
+           filesEnabled?: boolean
+           maxBytes?: number | null
+           allowedMime?: string[]
+           maxFiles?: number | null
+         }> = await res.json()

+        if (!chatSettings || !chatSettings.model) {
+          if (!ignore) setFilePolicy({ enabled: false, maxBytes: null, allowedMime: [] })
+          return
+        }

+         const item = list.find(m => m.id === chatSettings.model)
+         if (ignore) return
+         if (item?.filesEnabled) {
+           setFilePolicy({
+             enabled: true,
+             maxBytes: item.maxBytes ?? null,
+             allowedMime: item.allowedMime ?? [],
+             maxFiles: item.maxFiles ?? null
+           })
+         } else {
+           setFilePolicy({ enabled: false, maxBytes: null, allowedMime: [] })
+         }
+       } catch (e) {
+         // 取得失敗時は添付不可扱いに倒す
+         setFilePolicy({ enabled: false, maxBytes: null, allowedMime: [] })
+       }
+     })()
+     return () => {
+       ignore = true
+     }
+   }, [chatSettings?.model])

useEffect(() => {

// --- 中略 ---

        handleSelectDeviceFile(file)
      }
    }
  }

+   // ★追加: accept属性を policy.allowedMime から動的生成(なければ既存の filesToAccept)
+   const acceptAttr =
+     filePolicy?.allowedMime && filePolicy.allowedMime.length > 0
+       ? filePolicy.allowedMime.join(",")
+       : filesToAccept

  return (
    <>
      <div className="flex flex-col flex-wrap justify-center gap-2">

// --- 中略 ---

        <>
          <IconCirclePlus
            className="absolute bottom-[12px] left-3 cursor-pointer p-1 hover:opacity-50"
            size={32}
-             onClick={() => fileInputRef.current?.click()}
-           />

-           {/* Hidden input to select files from device */}
-           <Input
-             ref={fileInputRef}
-             className="hidden"
-             type="file"
-             onChange={e => {
-               if (!e.target.files) return
-               handleSelectDeviceFile(e.target.files[0])
-             }}
-             accept={filesToAccept}
-           />
-         </>

+         <>
+           <IconCirclePlus
+             className="absolute bottom-[12px] left-3 cursor-pointer p-1 hover:opacity-50"
+             size={32}

+             // ▲ 既存: 無条件で file input を開いて、dify_apps のポリシーに従って制御
+             onClick={() => {
+               if (filePolicy?.enabled) {
+                 fileInputRef.current?.click()
+               } else {
+                 toast.error("This model does not accept attachments.")
+               }
+             }}
+           />

+           {/* Hidden input to select files from device */}
+           <Input
+             ref={fileInputRef}
+             className="hidden"
+             type="file"
+             // ▲ 既存: ChatbotUI内のローカル添付に回す
+             // ▼ 変更: Difyへ中継
+             multiple
+             accept={acceptAttr} 
+             onChange={async e => {
+               const files = e.target.files ? Array.from(e.target.files) : []
+               if (files.length === 0) return

+              if (!chatSettings || !chatSettings.model) {
+                toast.error("Select a model first.")
+                e.currentTarget.value = ""
+                return
+              }

+               if (!filePolicy?.enabled) {
+                 toast.error("This model does not accept attachments.")
+                 e.currentTarget.value = ""
+                 return
+               }
+               for (const f of files) {
+                 if (filePolicy.maxBytes && f.size > filePolicy.maxBytes) {
+                   toast.error(`File too large: ${f.name}`)
+                   continue
+                 }
+                 if (
+                   filePolicy.allowedMime?.length &&
+                   !filePolicy.allowedMime.includes(f.type)
+                 ) {
+                   toast.error(`Unsupported type: ${f.name}`)
+                   continue
+                 }

+                 const fd = new FormData()
+                 fd.set("file", f)
+                 // ★重要: /api/chat/custom に渡している user と同じ値を入れる
+                 fd.set(
+                   "user",
+                   (profile?.id as string) ||
+                     (profile?.username as string) ||
+                     "anonymous"
+                 )

+                 try {
+                   const res = await fetch(
+                     `/api/chat/custom/files/upload?model=${encodeURIComponent(
+                       chatSettings.model
+                     )}`,
+                     { method: "POST", body: fd }
+                   )
+                   if (!res.ok) {
+                     const msg = await res.text()
+                     toast.error(msg || `Upload failed: ${f.name}`)
+                     continue
+                   }
+                   const meta = await res.json()
+                   toast.success(`Uploaded: ${meta.name || f.name}`)

+                   // ★追加: 次の送信で添付するため、一時キューに積む(最小実装)
+                   ;(globalThis as any).__difyPendingFiles ||= []
+                   const fileType =
+                     meta?.type ??
+                     (meta?.mime_type?.startsWith?.("image/") ? "image" : "document")
+                   ;(globalThis as any).__difyPendingFiles.push({
+                     id: meta.id,
+                     type: fileType || "document"
+                   })

+                   // ★必要なら: ここでローカルのプレビュー配列に積む処理を追加
+                 } catch (err) {
+                   toast.error(`Upload error: ${f.name}`)
+                 }
+               }

+               // 同じファイルを連続で選べるようにリセット
+               e.currentTarget.value = ""
+             }}
+           />
+         </>

        <TextareaAutosize
          textareaRef={chatInputRef}

// --- 以下略 ---


6.送信APIに「files」を中継

第5章で作った送信API(/api/chat/custom)に、添付の配列を素通しで渡せるようにします。

編集するファイル
app/api/chat/custom/route.ts

  • ポイント
    リクエストBodyで files を受け取り、Difyへ送る payload.files にそのまま載せます。
    形式は、[{ type: 'image'|'document', transfer_method: 'local_file', upload_file_id: '...' }] という感じ。

●編集

cd /opt/chatbot-ui
sudo nano /opt/chatbot-ui/app/api/chat/custom/route.ts

「-」部分は削除し、「+」部分を追記します。

// /app/api/chat/custom/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { supabaseAdmin } from '@/lib/supabaseAdmin';

+ type ChatFileItem = {
+   type: 'image' | 'document';
+   transfer_method: 'local_file' | 'remote_url';
+   upload_file_id: string;
+ };

type ChatRequestBody = {
  chatId: string;
  message?: string;
  messages?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>;
  model?: string;      // ← Dify の slug をここに入れる運用(UIの「モデル」に相当)
  inputs?: Record<string, any>; // Dify Flow の inputs(任意)
  // ★追加: 添付ファイル(/v1/chat-messages の files にそのまま渡す)
  files?: ChatFileItem[];
};

// --- 中略 ---

    // 4) Dify へストリーミング中継(SSE)
    const payload: Record<string, any> = {
      inputs,
      query: userText,
      response_mode: 'streaming',
      user: user.id,
    };
    if (chat.provider_thread_id) payload.conversation_id = chat.provider_thread_id;

+     // ★追加: 添付ファイル(あればそのまま中継)
+     if (Array.isArray(body.files) && body.files.length > 0) {
+       payload.files = body.files;
+     }
  
// --- 以下略 ---

ここまでできたら再ビルドしておいてください。


7.Difyフロー側の受け取り方

テキストファイル(例:.txt や .pdf)が添付された場合は、フローの中で「テキスト抽出」の処理を追加する必要があります。これにより、ファイルの中身を読み取って活用できます。

ただし、同じフローに画像ファイルが送られてくると、テキスト抽出では画像の内容を読み取れないため、処理が止まってしまいます。

そこで、LLMノードの「ビジョン」機能を有効にしておくと、画像の内容も読み取れるようになり、フローが止まらずに処理を続けられます。

テキストが添付されても、画像が添付されても返答できるフローにするため、以下の4パターンに対応したフローを作成します。
A.画像が添付されている場合
B.テキストが添付されている場合
C.画像とテキストの両方が添付されている場合
D.添付がない場合

ちょっと(かなり)ややこしいですが、以下のようになります。

では、順番に解説します。

①「開始」


特に何も設定していません。

②コード実行


入力変数は、「files」として「sys.files」を設定します。

Pythonコードを以下のように書きます。

# 入力: files(UIで システム変数 → sys.files を割り当て)
# 出力: output("mixed" | "image_only" | "doc_only" | "")

from typing import List, Dict, Any

def main(files: List[Dict[str, Any]] = None) -> Dict[str, Any]:
    files = files or []
    if not files:
        return {"output": ""}  # 添付なし

    has_img = False
    has_doc = False

    for f in files:
        mt = (f.get("mime_type") or f.get("type") or "").lower()
        name = (f.get("name") or "").lower()

        # MIME優先、無ければ拡張子で判定
        if mt.startswith("image/") or name.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")):
            has_img = True
        elif mt or name:  # 何かしら情報があれば「画像以外=文書」とみなす
            has_doc = True

    if has_img and has_doc:
        return {"output": "mixed"}
    if has_img:
        return {"output": "image_only"}
    if has_doc:
        return {"output": "doc_only"}
    return {"output": ""}  # 判定不能=添付なし扱い

出力変数は、「output」として型はStringとします。

③IF/ELSE


ここで、先ほどのPthonコードで作った変数「output」に応じて、以下の4パターンに分岐します。
A.画像が添付されている場合:変数「output」がimage_only
B.テキストが添付されている場合:変数「output」がdoc_only
C.画像とテキストの両方が添付されている場合:変数「output」がmixed
D.添付がない場合:それ以外(ELSE)

あとは、パターン毎に返答させます。
A.画像が添付されている場合

画像の読み込みが必要なので、ビジョンをONにしておきます。

B. テキストが添付されている場合

テキスト抽出→LLM→回答の順に設定します。
テキスト抽出は、変数をsys.filesにしておけばOKです。
LLMのところはプロンプトとユーザーメッセージに「sys.query」と抽出したテキスト変数「text」を入れてあげるとLLMが読み取りやすくなります。

C.画像とテキストの両方が添付されている場合

画像とテキストの両方が添付された場合には処理ができませんので「できません」と直接返しています。ここではLLMを入れることなく塩対応してます。

D.添付がない場合

添付がない場合には普通に返すだけ、ですね。
ここでもユーザーメッセージに「sys.query」を追記します。

以上で終了です。
ChatbotUIから送られてくるファイルが、画像でもテキストでも、あるいはファイルが添付されていなくても、問題なく応答できるフローが完成です。


8.たまったアップロードのお掃除

Xサーバーのストレージ節約のため、30日以上前のアップロードを削除するシェルを用意し、cron で毎日実行します。
※厳密にはAPIで消すのが理想(メタと実体をセットで削除)が、ここでは簡易にファイル実体のみを片付ける運用です。

以下、コードです。

sudo tee /usr/local/bin/cleanup_dify_uploads.sh >/dev/null <<'SH'
#!/usr/bin/env bash
set -euo pipefail

# Dify のアップロード保存先
ROOT="/root/dify/docker/volumes/app/storage/upload_files"
DAYS="${1:-30}"   # 既定 30 日

# DRY_RUN=1 なら削除せず一覧だけ表示
if [ "${DRY_RUN:-0}" = "1" ]; then
  echo "[DRY-RUN] older than ${DAYS} days under: $ROOT"
  find "$ROOT" -type f -mtime +"$DAYS" -print
  find "$ROOT" -type d -empty -print
  exit 0
fi

# 期限超えファイル削除&空ディレクトリ掃除
find "$ROOT" -type f -mtime +"$DAYS" -print -delete
find "$ROOT" -type d -empty -print -delete
SH

sudo chmod +x /usr/local/bin/cleanup_dify_uploads.sh

以下で実行してみます。

/usr/local/bin/cleanup_dify_uploads.sh 30

エラーが出なければOK。

あとは、毎日、午前3:00に自動的に動くようにします。

( crontab -l 2>/dev/null; echo '0 3 * * * /usr/local/bin/cleanup_dify_uploads.sh 30 >>/var/log/cleanup_dify_uploads.log 2>&1' ) | crontab -

cron が動いているかを念のために確認します。

systemctl is-active cron || systemctl status cron

これで「active」って表示されればOKです。

あと、例えば、5日より前のデータを削除したいという時は、一回だけ以下を実行すればファイルの削除が可能です。

# まずは消す対象を確認(ドライラン)
DRY_RUN=1 /usr/local/bin/cleanup_dify_uploads.sh 5

# 問題なければ実行(削除)
/usr/local/bin/cleanup_dify_uploads.sh 5

🚀 まとめ

これで、モデルごとの添付ポリシー をSupabaseで一元管理し、ブラウザ→サーバ→Dify の中継で安全にファイルを扱う流れが整いました。フロー側も 画像/テキスト/添付なし に対応できる構成になり、実運用に近い対話体験を提供できます。
次章では、初期入力フィールド をChatbotUI側に反映し、“使われるUI” へ仕上げていきます。


🪄 次の記事:
[VPSで「認証つきDify環境」を作る⑧:初期入力フィールドをChatbotUIに表示する(予定)]

Discussion