Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
@@ -1,11 +1,10 @@
|
|
1 |
#!/usr/bin/env python3
|
2 |
"""
|
3 |
-
multimodal gpt-oss 120b — Gradio app
|
4 |
|
5 |
-
|
6 |
-
-
|
7 |
-
-
|
8 |
-
- LLM остаётся через NVIDIA Integrate (OpenAI-compatible), как и было.
|
9 |
"""
|
10 |
|
11 |
import os
|
@@ -30,7 +29,7 @@ if not NV_API_KEY:
|
|
30 |
"NV_API_KEY не задан. В Hugging Face Space зайди в Settings → Secrets и добавь NV_API_KEY."
|
31 |
)
|
32 |
|
33 |
-
# OpenAI клиент для LLM
|
34 |
llm = OpenAI(base_url=NV_BASE_URL, api_key=NV_API_KEY)
|
35 |
|
36 |
|
@@ -64,7 +63,7 @@ def _extract_text_from_stream_chunk(chunk: Any) -> str:
|
|
64 |
def chat_stream(image, user_message: str, history: Optional[List[List[str]]], caption_text: str):
|
65 |
"""
|
66 |
Основной generator для стриминга ответов LLM.
|
67 |
-
Теперь
|
68 |
"""
|
69 |
history = history or []
|
70 |
|
@@ -79,7 +78,6 @@ def chat_stream(image, user_message: str, history: Optional[List[List[str]]], ca
|
|
79 |
|
80 |
caption = caption_text or ""
|
81 |
|
82 |
-
# Системный промпт с подписью
|
83 |
system_prompt = (
|
84 |
"You are 'multimodal gpt-oss 120b', a helpful multimodal assistant. "
|
85 |
"Use the provided 'More Detailed Caption' as authoritative visual context. "
|
@@ -91,14 +89,11 @@ def chat_stream(image, user_message: str, history: Optional[List[List[str]]], ca
|
|
91 |
"Be concise unless asked for details."
|
92 |
)
|
93 |
|
94 |
-
# Добавляем сообщение пользователя
|
95 |
history.append([user_message, ""])
|
96 |
-
# Показать подпись справа от чата (как и раньше)
|
97 |
yield history, caption
|
98 |
|
99 |
assistant_accum = ""
|
100 |
try:
|
101 |
-
# Стриминг от LLM
|
102 |
stream = llm.chat.completions.create(
|
103 |
model="openai/gpt-oss-120b",
|
104 |
messages=[
|
@@ -122,7 +117,6 @@ def chat_stream(image, user_message: str, history: Optional[List[List[str]]], ca
|
|
122 |
except Exception as e:
|
123 |
print(f"Streaming error: {e}")
|
124 |
traceback.print_exc()
|
125 |
-
# Fallback на не-стриминг запрос
|
126 |
try:
|
127 |
resp = llm.chat.completions.create(
|
128 |
model="openai/gpt-oss-120b",
|
@@ -173,27 +167,60 @@ css = """
|
|
173 |
#title { text-align: center; }
|
174 |
"""
|
175 |
|
176 |
-
# JS
|
177 |
WEBGPU_CAPTION_JS = r"""
|
178 |
async (image, use_client) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
try {
|
180 |
if (!use_client) return null;
|
181 |
|
182 |
-
|
183 |
-
|
|
|
|
|
184 |
}
|
185 |
|
186 |
-
// Извлекаем источник изображения из значения Gradio Image
|
187 |
const toHTMLImage = async (imgVal) => {
|
188 |
if (!imgVal) throw new Error("Нет изображения");
|
189 |
let src = null;
|
190 |
-
if (typeof imgVal === 'string')
|
191 |
-
|
192 |
-
|
193 |
-
src = imgVal.image;
|
194 |
-
} else if (imgVal?.data) {
|
195 |
-
src = imgVal.data;
|
196 |
-
}
|
197 |
if (!src) throw new Error("Не удалось прочитать изображение");
|
198 |
const im = new Image();
|
199 |
im.crossOrigin = 'anonymous';
|
@@ -202,49 +229,81 @@ async (image, use_client) => {
|
|
202 |
return im;
|
203 |
};
|
204 |
|
205 |
-
|
206 |
-
const
|
|
|
|
|
207 |
|
208 |
-
//
|
209 |
env.allowRemoteModels = true;
|
210 |
-
env.useBrowserCache = true;
|
211 |
-
env.backends
|
212 |
-
|
213 |
-
//
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
219 |
let lastErr = null;
|
220 |
for (const model of candidates) {
|
221 |
try {
|
222 |
-
|
223 |
-
'
|
224 |
-
|
225 |
-
|
226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
227 |
break;
|
228 |
} catch (e) {
|
229 |
lastErr = e;
|
230 |
-
console.warn(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
231 |
}
|
232 |
}
|
233 |
-
if (!window.__webgpu_captioner) throw lastErr || new Error("Не удалось инициализировать captioner");
|
234 |
}
|
235 |
|
236 |
const imgEl = await toHTMLImage(image);
|
237 |
-
|
238 |
-
// Для Florence-2 более детальная подпись через специальный токен задачи
|
239 |
-
const out = await window.__webgpu_captioner(imgEl, { text: '<MORE_DETAILED_CAPTION>' });
|
240 |
-
|
241 |
const text = Array.isArray(out)
|
242 |
? (out[0]?.generated_text ?? out[0]?.text ?? JSON.stringify(out[0]))
|
243 |
: (out?.generated_text ?? out?.text ?? String(out));
|
244 |
-
|
245 |
return text;
|
246 |
} catch (e) {
|
247 |
-
return `[WebGPU caption error: ${
|
248 |
}
|
249 |
}
|
250 |
"""
|
@@ -255,12 +314,12 @@ with gr.Blocks(css=css, analytics_enabled=False) as demo:
|
|
255 |
with gr.Row():
|
256 |
with gr.Column(scale=4):
|
257 |
image_input = gr.Image(label="Загрузите картинку", type="filepath")
|
258 |
-
use_webgpu = gr.Checkbox(value=True, label="Генерировать подпись к изображению в браузере (WebGPU)")
|
259 |
raw_caption = gr.Textbox(
|
260 |
-
label="More Detailed Caption (
|
261 |
-
interactive=True,
|
262 |
lines=6,
|
263 |
-
placeholder="Подпись появится тут (
|
264 |
)
|
265 |
user_input = gr.Textbox(
|
266 |
label="Вопрос по изображению",
|
@@ -270,7 +329,7 @@ with gr.Blocks(css=css, analytics_enabled=False) as demo:
|
|
270 |
send_btn = gr.Button("Отправить", variant="primary")
|
271 |
clear_btn = gr.Button("Очистить чат")
|
272 |
|
273 |
-
gr.Markdown("
|
274 |
gallery = gr.Gallery(
|
275 |
value=EXAMPLE_IMAGES,
|
276 |
label="Примеры",
|
@@ -284,7 +343,7 @@ with gr.Blocks(css=css, analytics_enabled=False) as demo:
|
|
284 |
with gr.Column(scale=6):
|
285 |
chatbot = gr.Chatbot(label="Чат с моделью", height=640)
|
286 |
|
287 |
-
# Клик по галерее:
|
288 |
def on_gallery_select(evt: gr.SelectData):
|
289 |
img = EXAMPLE_IMAGES[evt.index]
|
290 |
return img, ""
|
@@ -295,7 +354,7 @@ with gr.Blocks(css=css, analytics_enabled=False) as demo:
|
|
295 |
outputs=[image_input, raw_caption]
|
296 |
)
|
297 |
|
298 |
-
# Изменение картинки: считаем подпись на клиенте (WebGPU)
|
299 |
image_input.change(
|
300 |
None,
|
301 |
inputs=[image_input, use_webgpu],
|
@@ -303,13 +362,20 @@ with gr.Blocks(css=css, analytics_enabled=False) as demo:
|
|
303 |
js=WEBGPU_CAPTION_JS
|
304 |
)
|
305 |
|
306 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
307 |
send_btn.click(
|
308 |
chat_stream,
|
309 |
inputs=[image_input, user_input, chatbot, raw_caption],
|
310 |
outputs=[chatbot, raw_caption]
|
311 |
)
|
312 |
-
|
313 |
user_input.submit(
|
314 |
chat_stream,
|
315 |
inputs=[image_input, user_input, chatbot, raw_caption],
|
@@ -318,12 +384,12 @@ with gr.Blocks(css=css, analytics_enabled=False) as demo:
|
|
318 |
|
319 |
# Очистка чата + подписи
|
320 |
def clear_all():
|
321 |
-
return [], ""
|
322 |
|
323 |
clear_btn.click(
|
324 |
clear_all,
|
325 |
inputs=None,
|
326 |
-
outputs=[chatbot, raw_caption]
|
327 |
)
|
328 |
|
329 |
# Запуск
|
|
|
1 |
#!/usr/bin/env python3
|
2 |
"""
|
3 |
+
multimodal gpt-oss 120b — Gradio app: Florence в браузере (WebGPU), LLM через NVIDIA Integrate
|
4 |
|
5 |
+
- Подпись к изображению генерится на клиенте (Transformers.js + WebGPU / wasm фоллбэк).
|
6 |
+
- Сервер НЕ грузит torch/Florence.
|
7 |
+
- LLM-стриминг как раньше (openai/gpt-oss-120b).
|
|
|
8 |
"""
|
9 |
|
10 |
import os
|
|
|
29 |
"NV_API_KEY не задан. В Hugging Face Space зайди в Settings → Secrets и добавь NV_API_KEY."
|
30 |
)
|
31 |
|
32 |
+
# OpenAI совместимый клиент для LLM
|
33 |
llm = OpenAI(base_url=NV_BASE_URL, api_key=NV_API_KEY)
|
34 |
|
35 |
|
|
|
63 |
def chat_stream(image, user_message: str, history: Optional[List[List[str]]], caption_text: str):
|
64 |
"""
|
65 |
Основной generator для стриминга ответов LLM.
|
66 |
+
Теперь caption_text приходит напрямую из браузера (WebGPU/wasm).
|
67 |
"""
|
68 |
history = history or []
|
69 |
|
|
|
78 |
|
79 |
caption = caption_text or ""
|
80 |
|
|
|
81 |
system_prompt = (
|
82 |
"You are 'multimodal gpt-oss 120b', a helpful multimodal assistant. "
|
83 |
"Use the provided 'More Detailed Caption' as authoritative visual context. "
|
|
|
89 |
"Be concise unless asked for details."
|
90 |
)
|
91 |
|
|
|
92 |
history.append([user_message, ""])
|
|
|
93 |
yield history, caption
|
94 |
|
95 |
assistant_accum = ""
|
96 |
try:
|
|
|
97 |
stream = llm.chat.completions.create(
|
98 |
model="openai/gpt-oss-120b",
|
99 |
messages=[
|
|
|
117 |
except Exception as e:
|
118 |
print(f"Streaming error: {e}")
|
119 |
traceback.print_exc()
|
|
|
120 |
try:
|
121 |
resp = llm.chat.completions.create(
|
122 |
model="openai/gpt-oss-120b",
|
|
|
167 |
#title { text-align: center; }
|
168 |
"""
|
169 |
|
170 |
+
# JS: надёжная загрузка Transformers.js (ESM → UMD → локальный), WebGPU/wasm, Florence-2 large→base
|
171 |
WEBGPU_CAPTION_JS = r"""
|
172 |
async (image, use_client) => {
|
173 |
+
const loadWithScript = (url) => new Promise((res, rej) => {
|
174 |
+
const s = document.createElement('script');
|
175 |
+
s.src = url; s.async = true;
|
176 |
+
s.onload = () => res();
|
177 |
+
s.onerror = (e) => rej(e);
|
178 |
+
document.head.appendChild(s);
|
179 |
+
});
|
180 |
+
|
181 |
+
const loadTransformers = async () => {
|
182 |
+
// 1) ESM: несколько CDN
|
183 |
+
const esms = [
|
184 |
+
'https://cdn.jsdelivr.net/npm/@xenova/[email protected]',
|
185 |
+
'https://cdn.jsdelivr.net/npm/@xenova/[email protected]?module',
|
186 |
+
'https://unpkg.com/@xenova/[email protected]?module',
|
187 |
+
'https://esm.run/@xenova/[email protected]',
|
188 |
+
];
|
189 |
+
for (const url of esms) {
|
190 |
+
try { return await import(url); }
|
191 |
+
catch (e) { console.warn('ESM import failed:', url, e); }
|
192 |
+
}
|
193 |
+
// 2) UMD: глобальная window.transformers
|
194 |
+
const umds = [
|
195 |
+
'https://cdn.jsdelivr.net/npm/@xenova/[email protected]/dist/transformers.min.js',
|
196 |
+
'https://unpkg.com/@xenova/[email protected]/dist/transformers.min.js',
|
197 |
+
// локальный фоллбэк (если положите файл в репозиторий)
|
198 |
+
'/file=assets/transformers-3.2.2.min.js',
|
199 |
+
];
|
200 |
+
for (const url of umds) {
|
201 |
+
try {
|
202 |
+
await loadWithScript(url);
|
203 |
+
if (window.transformers) return window.transformers;
|
204 |
+
} catch (e) { console.warn('UMD load failed:', url, e); }
|
205 |
+
}
|
206 |
+
throw new Error('Transformers.js: все CDN/UMD источники недоступны');
|
207 |
+
};
|
208 |
+
|
209 |
try {
|
210 |
if (!use_client) return null;
|
211 |
|
212 |
+
// WebGPU наличие
|
213 |
+
const hasWebGPU = 'gpu' in navigator;
|
214 |
+
if (!hasWebGPU) {
|
215 |
+
console.warn('WebGPU недоступен, будет попытка wasm-фоллбэка.');
|
216 |
}
|
217 |
|
|
|
218 |
const toHTMLImage = async (imgVal) => {
|
219 |
if (!imgVal) throw new Error("Нет изображения");
|
220 |
let src = null;
|
221 |
+
if (typeof imgVal === 'string') src = imgVal;
|
222 |
+
else if (imgVal?.image) src = imgVal.image;
|
223 |
+
else if (imgVal?.data) src = imgVal.data;
|
|
|
|
|
|
|
|
|
224 |
if (!src) throw new Error("Не удалось прочитать изображение");
|
225 |
const im = new Image();
|
226 |
im.crossOrigin = 'anonymous';
|
|
|
229 |
return im;
|
230 |
};
|
231 |
|
232 |
+
const tjs = await loadTransformers();
|
233 |
+
const pipeline = tjs.pipeline ?? tjs?.default?.pipeline;
|
234 |
+
const env = tjs.env ?? tjs?.default?.env;
|
235 |
+
if (!pipeline || !env) throw new Error("Transformers.js загрузился без pipeline/env");
|
236 |
|
237 |
+
// Настройка бэкенда и кэша
|
238 |
env.allowRemoteModels = true;
|
239 |
+
env.useBrowserCache = true;
|
240 |
+
env.backends ??= {};
|
241 |
+
env.backends.onnx ??= {};
|
242 |
+
// Путь к wasm-артефактам (на всякий)
|
243 |
+
env.backends.onnx.wasm ??= {};
|
244 |
+
env.backends.onnx.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/@xenova/[email protected]/dist/wasm/';
|
245 |
+
env.backends.onnx.wasm.numThreads = 1;
|
246 |
+
|
247 |
+
// Пробуем WebGPU, fallback → wasm
|
248 |
+
let backend = 'webgpu';
|
249 |
+
if (!hasWebGPU) backend = 'wasm';
|
250 |
+
|
251 |
+
// Инициализация captioner (один раз)
|
252 |
+
if (!window.__web_captioner || window.__web_captioner_backend !== backend) {
|
253 |
+
const candidates = backend === 'webgpu'
|
254 |
+
? ['Xenova/Florence-2-large-ft', 'Xenova/Florence-2-base-ft']
|
255 |
+
: ['Xenova/Florence-2-base-ft'];
|
256 |
+
|
257 |
let lastErr = null;
|
258 |
for (const model of candidates) {
|
259 |
try {
|
260 |
+
if (backend === 'webgpu') {
|
261 |
+
env.backends.onnx.backend = 'webgpu';
|
262 |
+
window.__web_captioner = await pipeline(
|
263 |
+
'image-to-text', model,
|
264 |
+
{ device: 'webgpu', dtype: 'fp16', quantized: true }
|
265 |
+
);
|
266 |
+
} else {
|
267 |
+
env.backends.onnx.backend = 'wasm';
|
268 |
+
window.__web_captioner = await pipeline(
|
269 |
+
'image-to-text', model,
|
270 |
+
{ device: 'wasm', quantized: true }
|
271 |
+
);
|
272 |
+
}
|
273 |
+
window.__web_captioner_backend = backend;
|
274 |
break;
|
275 |
} catch (e) {
|
276 |
lastErr = e;
|
277 |
+
console.warn(`Init failed (${backend}) for ${model}`, e);
|
278 |
+
}
|
279 |
+
}
|
280 |
+
if (!window.__web_captioner) {
|
281 |
+
// Последняя попытка: если WebGPU падал — откат на wasm
|
282 |
+
if (backend === 'webgpu') {
|
283 |
+
try {
|
284 |
+
env.backends.onnx.backend = 'wasm';
|
285 |
+
window.__web_captioner = await pipeline(
|
286 |
+
'image-to-text', 'Xenova/Florence-2-base-ft',
|
287 |
+
{ device: 'wasm', quantized: true }
|
288 |
+
);
|
289 |
+
window.__web_captioner_backend = 'wasm';
|
290 |
+
} catch (e2) {
|
291 |
+
throw lastErr || e2;
|
292 |
+
}
|
293 |
+
} else {
|
294 |
+
throw lastErr || new Error('Не удалось инициализировать captioner');
|
295 |
}
|
296 |
}
|
|
|
297 |
}
|
298 |
|
299 |
const imgEl = await toHTMLImage(image);
|
300 |
+
const out = await window.__web_captioner(imgEl, { text: '<MORE_DETAILED_CAPTION>' });
|
|
|
|
|
|
|
301 |
const text = Array.isArray(out)
|
302 |
? (out[0]?.generated_text ?? out[0]?.text ?? JSON.stringify(out[0]))
|
303 |
: (out?.generated_text ?? out?.text ?? String(out));
|
|
|
304 |
return text;
|
305 |
} catch (e) {
|
306 |
+
return `[WebGPU caption error: ${e?.message || e}]`;
|
307 |
}
|
308 |
}
|
309 |
"""
|
|
|
314 |
with gr.Row():
|
315 |
with gr.Column(scale=4):
|
316 |
image_input = gr.Image(label="Загрузите картинку", type="filepath")
|
317 |
+
use_webgpu = gr.Checkbox(value=True, label="Генерировать подпись к изображению в браузере (WebGPU/wasm)")
|
318 |
raw_caption = gr.Textbox(
|
319 |
+
label="More Detailed Caption (клиентский Florence-2)",
|
320 |
+
interactive=True,
|
321 |
lines=6,
|
322 |
+
placeholder="Подпись появится тут (WebGPU/wasm)"
|
323 |
)
|
324 |
user_input = gr.Textbox(
|
325 |
label="Вопрос по изображению",
|
|
|
329 |
send_btn = gr.Button("Отправить", variant="primary")
|
330 |
clear_btn = gr.Button("Очистить чат")
|
331 |
|
332 |
+
gr.Markdown("Галерея примеров (клик — подставить в загрузчик, подпись посчитается на клиенте)")
|
333 |
gallery = gr.Gallery(
|
334 |
value=EXAMPLE_IMAGES,
|
335 |
label="Примеры",
|
|
|
343 |
with gr.Column(scale=6):
|
344 |
chatbot = gr.Chatbot(label="Чат с моделью", height=640)
|
345 |
|
346 |
+
# Клик по галерее: подставить изображение и очистить подпись (капшенер сработает на change)
|
347 |
def on_gallery_select(evt: gr.SelectData):
|
348 |
img = EXAMPLE_IMAGES[evt.index]
|
349 |
return img, ""
|
|
|
354 |
outputs=[image_input, raw_caption]
|
355 |
)
|
356 |
|
357 |
+
# Изменение картинки: считаем подпись на клиенте (WebGPU/wasm)
|
358 |
image_input.change(
|
359 |
None,
|
360 |
inputs=[image_input, use_webgpu],
|
|
|
362 |
js=WEBGPU_CAPTION_JS
|
363 |
)
|
364 |
|
365 |
+
# Переключение флажка: если включили — пересчитать подпись; если выключили — очистить
|
366 |
+
use_webgpu.change(
|
367 |
+
None,
|
368 |
+
inputs=[image_input, use_webgpu],
|
369 |
+
outputs=[raw_caption],
|
370 |
+
js=WEBGPU_CAPTION_JS
|
371 |
+
)
|
372 |
+
|
373 |
+
# Отправка сообщения: берём caption прямо из текстбокса (клиентский Florence)
|
374 |
send_btn.click(
|
375 |
chat_stream,
|
376 |
inputs=[image_input, user_input, chatbot, raw_caption],
|
377 |
outputs=[chatbot, raw_caption]
|
378 |
)
|
|
|
379 |
user_input.submit(
|
380 |
chat_stream,
|
381 |
inputs=[image_input, user_input, chatbot, raw_caption],
|
|
|
384 |
|
385 |
# Очистка чата + подписи
|
386 |
def clear_all():
|
387 |
+
return [], "", ""
|
388 |
|
389 |
clear_btn.click(
|
390 |
clear_all,
|
391 |
inputs=None,
|
392 |
+
outputs=[chatbot, raw_caption, user_input]
|
393 |
)
|
394 |
|
395 |
# Запуск
|