Neohosseinism commited on
Commit
ea3571c
·
1 Parent(s): 89bf59d

Add openwebui functions; exclude personal data backups

Browse files
.gitignore CHANGED
@@ -4,8 +4,9 @@
4
  # rendered llama-swap config
5
  llama-swap/config.yaml
6
 
7
- # Open WebUI persistent data (db, vector store, uploads)
8
  openwebui/data/
 
9
 
10
  # bench output
11
  scripts/bench-results/
 
4
  # rendered llama-swap config
5
  llama-swap/config.yaml
6
 
7
+ # Open WebUI persistent data (db, vector store, uploads) and its backups
8
  openwebui/data/
9
+ openwebui/data.bak-*/
10
 
11
  # bench output
12
  scripts/bench-results/
openwebui/functions/gemma4_audio_pipe.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ title: Gemma 4 Omni (Audio)
3
+ author: gemma4-stack
4
+ version: 0.1.0
5
+ required_open_webui_version: 0.5.0
6
+ description: >
7
+ Sends an attached audio clip to Gemma 4 as a NATIVE multimodal input
8
+ (OpenAI `input_audio` content part) via llama-swap/llama-server — the model
9
+ "hears" the audio instead of transcribing it with Whisper. Resamples to
10
+ 16 kHz mono first (Gemma 4's reliable envelope, clips <= ~30 s).
11
+
12
+ HOW TO USE
13
+ Admin → Functions → "+" → paste this file → Save → enable it.
14
+ In a new chat pick the model "Gemma 4 · Omni (audio)", attach a short audio
15
+ clip, type your question (Persian or any language), send.
16
+
17
+ FRAGILE BIT (read me)
18
+ Open WebUI hands a Pipe only FILE REFERENCES, not bytes. _resolve_local_path()
19
+ below fetches the real file from Open WebUI's store. That store API moves
20
+ between versions — if audio isn't found, adjust _resolve_local_path() to your
21
+ installed Open WebUI version first (see the strategies inside).
22
+ """
23
+
24
+ import os
25
+ import json
26
+ import base64
27
+ import glob
28
+ import shutil
29
+ import subprocess
30
+ import tempfile
31
+ from typing import List, Optional
32
+
33
+ import requests
34
+ from pydantic import BaseModel, Field
35
+
36
+ AUDIO_EXTS = (".wav", ".mp3", ".flac", ".ogg", ".m4a", ".webm", ".aac", ".opus")
37
+ DATA_DIR = os.environ.get("DATA_DIR", "/app/backend/data")
38
+ UPLOADS_DIR = os.path.join(DATA_DIR, "uploads")
39
+
40
+
41
+ class Pipe:
42
+ class Valves(BaseModel):
43
+ LLAMASWAP_URL: str = Field(
44
+ default="http://llama-swap:8080/v1",
45
+ description="OpenAI-compatible base URL of llama-swap.",
46
+ )
47
+ MODEL: str = Field(
48
+ default="gemma-e4b",
49
+ description="llama-swap model key (audio-capable: gemma-e4b/gemma-12b).",
50
+ )
51
+ API_KEY: str = Field(default="sk-local", description="Bearer key for llama-swap.")
52
+ TEMPERATURE: float = Field(default=1.0)
53
+ TOP_K: int = Field(default=64)
54
+ TOP_P: float = Field(default=0.95)
55
+ MAX_TOKENS: int = Field(default=512)
56
+ TARGET_SR: int = Field(default=16000, description="Resample rate (Gemma wants 16 kHz mono).")
57
+ DEFAULT_PROMPT: str = Field(
58
+ default="این فایل صوتی را دقیق بنویس و در صورت نیاز توضیح بده.",
59
+ description="Used when the user attaches audio without typing a question.",
60
+ )
61
+
62
+ def __init__(self):
63
+ self.valves = self.Valves()
64
+
65
+ def pipes(self):
66
+ return [{"id": "gemma4-audio", "name": "Gemma 4 · Omni (audio)"}]
67
+
68
+ # ------------------------------------------------------------------ helpers
69
+ def _collect_file_refs(self, body, __files__, __metadata__) -> List[dict]:
70
+ refs = []
71
+ if __files__:
72
+ refs += __files__
73
+ if isinstance(__metadata__, dict):
74
+ refs += __metadata__.get("files", []) or []
75
+ meta = (body or {}).get("metadata", {}) or {}
76
+ refs += meta.get("files", []) or []
77
+ refs += (body or {}).get("files", []) or []
78
+ return refs
79
+
80
+ def _ref_id_and_name(self, ref: dict):
81
+ # Open WebUI nests the actual record under "file" in some versions.
82
+ inner = ref.get("file", ref) if isinstance(ref, dict) else {}
83
+ fid = ref.get("id") or inner.get("id")
84
+ name = (
85
+ ref.get("name")
86
+ or inner.get("filename")
87
+ or (inner.get("meta") or {}).get("name")
88
+ or ""
89
+ )
90
+ ctype = (inner.get("meta") or {}).get("content_type", "") or ref.get("type", "")
91
+ return fid, name, ctype
92
+
93
+ def _resolve_local_path(self, fid, name) -> Optional[str]:
94
+ """Turn a file reference into a real on-disk path. Version-sensitive."""
95
+ # Strategy 1: official Files model.
96
+ try:
97
+ from open_webui.models.files import Files # type: ignore
98
+
99
+ rec = Files.get_file_by_id(fid)
100
+ if rec is not None:
101
+ p = getattr(rec, "path", None) or (getattr(rec, "meta", {}) or {}).get("path")
102
+ if p:
103
+ if not os.path.isabs(p):
104
+ p = os.path.join(DATA_DIR, p)
105
+ if os.path.exists(p):
106
+ return p
107
+ except Exception:
108
+ pass
109
+ # Strategy 2: storage provider abstraction.
110
+ try:
111
+ from open_webui.storage.provider import Storage # type: ignore
112
+
113
+ p = Storage.get_file(f"uploads/{fid}") # may raise / vary
114
+ if p and os.path.exists(p):
115
+ return p
116
+ except Exception:
117
+ pass
118
+ # Strategy 3: scan the uploads dir for <id> or <name>.
119
+ for pattern in (f"*{fid}*", f"*{name}*"):
120
+ if not pattern.strip("*"):
121
+ continue
122
+ hits = glob.glob(os.path.join(UPLOADS_DIR, pattern))
123
+ hits = [h for h in hits if os.path.isfile(h)]
124
+ if hits:
125
+ return max(hits, key=os.path.getmtime)
126
+ return None
127
+
128
+ def _to_16k_mono_wav(self, src: str) -> (str, str):
129
+ """Return (path, format). Resample via ffmpeg if available, else pass through."""
130
+ ffmpeg = shutil.which("ffmpeg")
131
+ if ffmpeg:
132
+ out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
133
+ try:
134
+ subprocess.run(
135
+ [ffmpeg, "-y", "-i", src, "-ar", str(self.valves.TARGET_SR),
136
+ "-ac", "1", "-f", "wav", out],
137
+ check=True, capture_output=True,
138
+ )
139
+ return out, "wav"
140
+ except Exception:
141
+ pass
142
+ ext = os.path.splitext(src)[1].lower().lstrip(".") or "wav"
143
+ return src, ("wav" if ext not in ("mp3", "flac", "wav") else ext)
144
+
145
+ def _latest_user_text(self, body) -> str:
146
+ for msg in reversed((body or {}).get("messages", [])):
147
+ if msg.get("role") == "user":
148
+ c = msg.get("content")
149
+ if isinstance(c, str):
150
+ return c.strip()
151
+ if isinstance(c, list):
152
+ parts = [p.get("text", "") for p in c if p.get("type") == "text"]
153
+ return " ".join(t for t in parts if t).strip()
154
+ return ""
155
+
156
+ # --------------------------------------------------------------------- main
157
+ def pipe(self, body: dict, __user__=None, __request__=None,
158
+ __files__=None, __metadata__=None):
159
+ refs = self._collect_file_refs(body, __files__, __metadata__)
160
+ audio_paths = []
161
+ for ref in refs:
162
+ fid, name, ctype = self._ref_id_and_name(ref)
163
+ is_audio = ctype.startswith("audio") or name.lower().endswith(AUDIO_EXTS)
164
+ if not is_audio:
165
+ continue
166
+ local = self._resolve_local_path(fid, name)
167
+ if local:
168
+ audio_paths.append(local)
169
+
170
+ if not audio_paths:
171
+ return (
172
+ "⚠️ No audio found. Attach a short clip (≤ ~30 s) and ask your "
173
+ "question. If you *did* attach audio, the file-store lookup needs "
174
+ "adapting to your Open WebUI version — see _resolve_local_path()."
175
+ )
176
+
177
+ text = self._latest_user_text(body) or self.valves.DEFAULT_PROMPT
178
+ content = [{"type": "text", "text": text}]
179
+ for p in audio_paths:
180
+ wav, fmt = self._to_16k_mono_wav(p)
181
+ with open(wav, "rb") as f:
182
+ content.append({
183
+ "type": "input_audio",
184
+ "input_audio": {
185
+ "data": base64.b64encode(f.read()).decode("ascii"),
186
+ "format": fmt,
187
+ },
188
+ })
189
+
190
+ payload = {
191
+ "model": self.valves.MODEL,
192
+ "messages": [{"role": "user", "content": content}],
193
+ "temperature": self.valves.TEMPERATURE,
194
+ "top_k": self.valves.TOP_K,
195
+ "top_p": self.valves.TOP_P,
196
+ "max_tokens": self.valves.MAX_TOKENS,
197
+ "stream": True,
198
+ }
199
+ headers = {"Authorization": f"Bearer {self.valves.API_KEY}"}
200
+ url = self.valves.LLAMASWAP_URL.rstrip("/") + "/chat/completions"
201
+
202
+ def gen():
203
+ with requests.post(url, json=payload, headers=headers,
204
+ stream=True, timeout=600) as r:
205
+ r.raise_for_status()
206
+ for line in r.iter_lines(decode_unicode=True):
207
+ if not line or not line.startswith("data:"):
208
+ continue
209
+ data = line[len("data:"):].strip()
210
+ if data == "[DONE]":
211
+ break
212
+ try:
213
+ delta = json.loads(data)["choices"][0]["delta"]
214
+ piece = delta.get("content")
215
+ if piece:
216
+ yield piece
217
+ except Exception:
218
+ continue
219
+
220
+ return gen()