File size: 8,895 Bytes
ea3571c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
"""
title: Gemma 4 Omni (Audio)
author: gemma4-stack
version: 0.1.0
required_open_webui_version: 0.5.0
description: >
  Sends an attached audio clip to Gemma 4 as a NATIVE multimodal input
  (OpenAI `input_audio` content part) via llama-swap/llama-server — the model
  "hears" the audio instead of transcribing it with Whisper. Resamples to
  16 kHz mono first (Gemma 4's reliable envelope, clips <= ~30 s).

HOW TO USE
  Admin → Functions → "+" → paste this file → Save → enable it.
  In a new chat pick the model "Gemma 4 · Omni (audio)", attach a short audio
  clip, type your question (Persian or any language), send.

FRAGILE BIT (read me)
  Open WebUI hands a Pipe only FILE REFERENCES, not bytes. _resolve_local_path()
  below fetches the real file from Open WebUI's store. That store API moves
  between versions — if audio isn't found, adjust _resolve_local_path() to your
  installed Open WebUI version first (see the strategies inside).
"""

import os
import json
import base64
import glob
import shutil
import subprocess
import tempfile
from typing import List, Optional

import requests
from pydantic import BaseModel, Field

AUDIO_EXTS = (".wav", ".mp3", ".flac", ".ogg", ".m4a", ".webm", ".aac", ".opus")
DATA_DIR = os.environ.get("DATA_DIR", "/app/backend/data")
UPLOADS_DIR = os.path.join(DATA_DIR, "uploads")


class Pipe:
    class Valves(BaseModel):
        LLAMASWAP_URL: str = Field(
            default="http://llama-swap:8080/v1",
            description="OpenAI-compatible base URL of llama-swap.",
        )
        MODEL: str = Field(
            default="gemma-e4b",
            description="llama-swap model key (audio-capable: gemma-e4b/gemma-12b).",
        )
        API_KEY: str = Field(default="sk-local", description="Bearer key for llama-swap.")
        TEMPERATURE: float = Field(default=1.0)
        TOP_K: int = Field(default=64)
        TOP_P: float = Field(default=0.95)
        MAX_TOKENS: int = Field(default=512)
        TARGET_SR: int = Field(default=16000, description="Resample rate (Gemma wants 16 kHz mono).")
        DEFAULT_PROMPT: str = Field(
            default="این فایل صوتی را دقیق بنویس و در صورت نیاز توضیح بده.",
            description="Used when the user attaches audio without typing a question.",
        )

    def __init__(self):
        self.valves = self.Valves()

    def pipes(self):
        return [{"id": "gemma4-audio", "name": "Gemma 4 · Omni (audio)"}]

    # ------------------------------------------------------------------ helpers
    def _collect_file_refs(self, body, __files__, __metadata__) -> List[dict]:
        refs = []
        if __files__:
            refs += __files__
        if isinstance(__metadata__, dict):
            refs += __metadata__.get("files", []) or []
        meta = (body or {}).get("metadata", {}) or {}
        refs += meta.get("files", []) or []
        refs += (body or {}).get("files", []) or []
        return refs

    def _ref_id_and_name(self, ref: dict):
        # Open WebUI nests the actual record under "file" in some versions.
        inner = ref.get("file", ref) if isinstance(ref, dict) else {}
        fid = ref.get("id") or inner.get("id")
        name = (
            ref.get("name")
            or inner.get("filename")
            or (inner.get("meta") or {}).get("name")
            or ""
        )
        ctype = (inner.get("meta") or {}).get("content_type", "") or ref.get("type", "")
        return fid, name, ctype

    def _resolve_local_path(self, fid, name) -> Optional[str]:
        """Turn a file reference into a real on-disk path. Version-sensitive."""
        # Strategy 1: official Files model.
        try:
            from open_webui.models.files import Files  # type: ignore

            rec = Files.get_file_by_id(fid)
            if rec is not None:
                p = getattr(rec, "path", None) or (getattr(rec, "meta", {}) or {}).get("path")
                if p:
                    if not os.path.isabs(p):
                        p = os.path.join(DATA_DIR, p)
                    if os.path.exists(p):
                        return p
        except Exception:
            pass
        # Strategy 2: storage provider abstraction.
        try:
            from open_webui.storage.provider import Storage  # type: ignore

            p = Storage.get_file(f"uploads/{fid}")  # may raise / vary
            if p and os.path.exists(p):
                return p
        except Exception:
            pass
        # Strategy 3: scan the uploads dir for <id> or <name>.
        for pattern in (f"*{fid}*", f"*{name}*"):
            if not pattern.strip("*"):
                continue
            hits = glob.glob(os.path.join(UPLOADS_DIR, pattern))
            hits = [h for h in hits if os.path.isfile(h)]
            if hits:
                return max(hits, key=os.path.getmtime)
        return None

    def _to_16k_mono_wav(self, src: str) -> (str, str):
        """Return (path, format). Resample via ffmpeg if available, else pass through."""
        ffmpeg = shutil.which("ffmpeg")
        if ffmpeg:
            out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
            try:
                subprocess.run(
                    [ffmpeg, "-y", "-i", src, "-ar", str(self.valves.TARGET_SR),
                     "-ac", "1", "-f", "wav", out],
                    check=True, capture_output=True,
                )
                return out, "wav"
            except Exception:
                pass
        ext = os.path.splitext(src)[1].lower().lstrip(".") or "wav"
        return src, ("wav" if ext not in ("mp3", "flac", "wav") else ext)

    def _latest_user_text(self, body) -> str:
        for msg in reversed((body or {}).get("messages", [])):
            if msg.get("role") == "user":
                c = msg.get("content")
                if isinstance(c, str):
                    return c.strip()
                if isinstance(c, list):
                    parts = [p.get("text", "") for p in c if p.get("type") == "text"]
                    return " ".join(t for t in parts if t).strip()
        return ""

    # --------------------------------------------------------------------- main
    def pipe(self, body: dict, __user__=None, __request__=None,
             __files__=None, __metadata__=None):
        refs = self._collect_file_refs(body, __files__, __metadata__)
        audio_paths = []
        for ref in refs:
            fid, name, ctype = self._ref_id_and_name(ref)
            is_audio = ctype.startswith("audio") or name.lower().endswith(AUDIO_EXTS)
            if not is_audio:
                continue
            local = self._resolve_local_path(fid, name)
            if local:
                audio_paths.append(local)

        if not audio_paths:
            return (
                "⚠️ No audio found. Attach a short clip (≤ ~30 s) and ask your "
                "question. If you *did* attach audio, the file-store lookup needs "
                "adapting to your Open WebUI version — see _resolve_local_path()."
            )

        text = self._latest_user_text(body) or self.valves.DEFAULT_PROMPT
        content = [{"type": "text", "text": text}]
        for p in audio_paths:
            wav, fmt = self._to_16k_mono_wav(p)
            with open(wav, "rb") as f:
                content.append({
                    "type": "input_audio",
                    "input_audio": {
                        "data": base64.b64encode(f.read()).decode("ascii"),
                        "format": fmt,
                    },
                })

        payload = {
            "model": self.valves.MODEL,
            "messages": [{"role": "user", "content": content}],
            "temperature": self.valves.TEMPERATURE,
            "top_k": self.valves.TOP_K,
            "top_p": self.valves.TOP_P,
            "max_tokens": self.valves.MAX_TOKENS,
            "stream": True,
        }
        headers = {"Authorization": f"Bearer {self.valves.API_KEY}"}
        url = self.valves.LLAMASWAP_URL.rstrip("/") + "/chat/completions"

        def gen():
            with requests.post(url, json=payload, headers=headers,
                               stream=True, timeout=600) as r:
                r.raise_for_status()
                for line in r.iter_lines(decode_unicode=True):
                    if not line or not line.startswith("data:"):
                        continue
                    data = line[len("data:"):].strip()
                    if data == "[DONE]":
                        break
                    try:
                        delta = json.loads(data)["choices"][0]["delta"]
                        piece = delta.get("content")
                        if piece:
                            yield piece
                    except Exception:
                        continue

        return gen()