KillerKing93 commited on
Commit
696356a
·
verified ·
1 Parent(s): d5b5b09

Sync from GitHub 5fbeff1

Browse files
Files changed (2) hide show
  1. main.py +12 -4
  2. web/index.html +380 -0
main.py CHANGED
@@ -29,7 +29,7 @@ from fastapi import FastAPI, HTTPException, Request, Header, Query
29
  from fastapi.middleware.cors import CORSMiddleware
30
  from pydantic import BaseModel, ConfigDict, Field
31
  from starlette.responses import JSONResponse
32
- from fastapi.responses import StreamingResponse, Response
33
  import json
34
  import yaml
35
  import threading
@@ -946,10 +946,18 @@ def _startup_load_model():
946
  raise
947
 
948
 
949
- @app.get("/", tags=["meta"])
950
  def root():
951
- """Liveness check."""
952
- return JSONResponse({"ok": True})
 
 
 
 
 
 
 
 
953
 
954
 
955
  @app.get("/openapi.yaml", tags=["meta"])
 
29
  from fastapi.middleware.cors import CORSMiddleware
30
  from pydantic import BaseModel, ConfigDict, Field
31
  from starlette.responses import JSONResponse
32
+ from fastapi.responses import StreamingResponse, Response, FileResponse
33
  import json
34
  import yaml
35
  import threading
 
946
  raise
947
 
948
 
949
+ @app.get("/", tags=["meta"], include_in_schema=False)
950
  def root():
951
+ """
952
+ Serve the client web UI. The UI calls an external Hugging Face Space API
953
+ (default is KillerKing93/Transformers-InferenceServer-OpenAPI) and does NOT
954
+ use internal server endpoints for chat. You can change the base via the input
955
+ field or ?api= query string in the page.
956
+ """
957
+ index_path = os.path.join(ROOT_DIR, "web", "index.html")
958
+ if os.path.exists(index_path):
959
+ return FileResponse(index_path, media_type="text/html; charset=utf-8")
960
+ return JSONResponse({"ok": True, "message": "UI not found. Build placed under ./web/index.html"}, status_code=200)
961
 
962
 
963
  @app.get("/openapi.yaml", tags=["meta"])
web/index.html ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Qwen3‑VL Chat (HF Space API)</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <style>
8
+ :root { --bg:#0f172a; --fg:#e2e8f0; --muted:#94a3b8; --accent:#6366f1; --card:#111827; --chip:#1f2937; --border:#334155; }
9
+ html, body { height:100%; margin:0; background:var(--bg); color:var(--fg); font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
10
+ .app { display:flex; flex-direction:column; height:100%; max-width: 1200px; margin: 0 auto; }
11
+ header { padding:12px 16px; border-bottom:1px solid var(--border); display:flex; gap:12px; align-items:center; flex-wrap: wrap; }
12
+ header .title { font-weight:700; }
13
+ header input[type="text"] { flex: 1 1 360px; background:var(--card); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px; }
14
+ header .small { color: var(--muted); font-size: 12px; }
15
+ main { flex:1; overflow:auto; padding: 16px; display:flex; gap:16px; }
16
+ .chat { flex: 1 1 auto; display:flex; flex-direction:column; gap:12px; }
17
+ .msg { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:12px; }
18
+ .msg.user { border-left: 3px solid #22c55e; }
19
+ .msg.assistant { border-left: 3px solid var(--accent); }
20
+ .role { font-weight:700; margin-bottom:6px; color: var(--muted); text-transform: uppercase; font-size: 12px; }
21
+ .content pre { white-space: pre-wrap; word-break: break-word; }
22
+ .media { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
23
+ .media img, .media video { max-width: 240px; max-height: 180px; border:1px solid var(--border); border-radius:8px; }
24
+ .aside { width: 320px; flex: 0 0 auto; display:flex; flex-direction:column; gap:12px; }
25
+ .card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:12px; }
26
+ .label { font-size: 12px; color: var(--muted); margin-bottom:6px; }
27
+ .row { display:flex; gap:8px; align-items:center; flex-wrap: wrap; }
28
+ .controls textarea { width:100%; min-height: 80px; background:var(--card); border:1px solid var(--border); color:var(--fg); padding:8px; border-radius:8px; resize: vertical; }
29
+ button { background:var(--accent); color:white; border:0; padding:8px 12px; border-radius:8px; cursor:pointer; }
30
+ button.secondary { background: var(--chip); color: var(--fg); }
31
+ input[type="number"], input[type="text"] { background:var(--card); border:1px solid var(--border); color:var(--fg); padding:6px 8px; border-radius:6px; }
32
+ .chips { display:flex; gap:8px; flex-wrap: wrap; }
33
+ .chip { background:var(--chip); color:var(--fg); border:1px solid var(--border); padding:4px 8px; border-radius: 999px; font-size: 12px; }
34
+ footer { padding:10px 16px; border-top:1px solid var(--border); color: var(--muted); font-size:12px; display:flex; justify-content:space-between; gap:10px; flex-wrap: wrap; }
35
+ a { color: #93c5fd; text-decoration: none; }
36
+ a:hover { text-decoration: underline; }
37
+ .hint { font-size: 12px; color: var(--muted); }
38
+ input[type="file"] { display:none; }
39
+ .file-btn { background: var(--chip); }
40
+ .preview { display:flex; gap:8px; flex-wrap: wrap; margin-top:8px; }
41
+ .preview-item { position:relative; }
42
+ .remove { position:absolute; top:4px; right:4px; background: #ef4444; color:white; border:0; border-radius: 6px; padding:2px 6px; cursor:pointer; font-size:12px;}
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div class="app">
47
+ <header>
48
+ <div class="title">Qwen3‑VL Chat</div>
49
+ <input id="apiBase" type="text" placeholder="HF Space API Base, e.g. https://killerking93-transformers-inferenceserver-openapi.hf.space" />
50
+ <button id="saveBase" class="secondary">Save Base</button>
51
+ <span id="health" class="small">Health: checking…</span>
52
+ </header>
53
+
54
+ <main>
55
+ <section class="chat" id="chat"></section>
56
+
57
+ <aside class="aside">
58
+ <div class="card">
59
+ <div class="label">Prompt</div>
60
+ <div class="controls">
61
+ <textarea id="prompt" placeholder="Ask anything… Supports images and videos."></textarea>
62
+ <div class="row">
63
+ <label for="file" class="file-btn button"><button class="secondary">Attach Image/Video</button></label>
64
+ <input id="file" type="file" accept="image/*,video/*" multiple />
65
+ <input id="maxTokens" type="number" min="1" max="8192" value="4096" title="Max tokens" />
66
+ <input id="temperature" type="number" min="0" max="2" step="0.1" value="0.7" title="Temperature" />
67
+ <button id="send">Send (Stream)</button>
68
+ </div>
69
+ <div id="preview" class="preview"></div>
70
+ <div class="row" style="margin-top:8px;">
71
+ <button id="clearHistory" class="secondary">Clear History</button>
72
+ <span class="hint">Session <code id="sessionIdLabel"></code> — history saved locally</span>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="card">
78
+ <div class="label">Hints</div>
79
+ <div class="chips">
80
+ <div class="chip">Images: embedded as base64</div>
81
+ <div class="chip">Videos: base64, frame-sampled by server</div>
82
+ <div class="chip">SSE Streaming</div>
83
+ </div>
84
+ </div>
85
+ </aside>
86
+ </main>
87
+
88
+ <footer>
89
+ <div>Powered by FastAPI + Transformers (Qwen3‑VL). Calls public HF Space API (no internal access).</div>
90
+ <div><a href="./docs" target="_blank">Swagger</a> · <a href="./openapi.yaml" target="_blank">OpenAPI YAML</a></div>
91
+ </footer>
92
+ </div>
93
+
94
+ <script>
95
+ // Config and state
96
+ const DEFAULT_SPACE = "https://killerking93-transformers-inferenceserver-openapi.hf.space";
97
+ const qs = new URLSearchParams(location.search);
98
+ const apiBaseInput = document.getElementById('apiBase');
99
+ const saveBaseBtn = document.getElementById('saveBase');
100
+ const healthEl = document.getElementById('health');
101
+ const chatEl = document.getElementById('chat');
102
+ const promptEl = document.getElementById('prompt');
103
+ const fileEl = document.getElementById('file');
104
+ const previewEl = document.getElementById('preview');
105
+ const sendBtn = document.getElementById('send');
106
+ const clearBtn = document.getElementById('clearHistory');
107
+ const sessionIdLabel = document.getElementById('sessionIdLabel');
108
+ const maxTokensEl = document.getElementById('maxTokens');
109
+ const temperatureEl = document.getElementById('temperature');
110
+
111
+ const store = {
112
+ get apiBase() { return localStorage.getItem('apiBase') || DEFAULT_SPACE; },
113
+ set apiBase(v) { localStorage.setItem('apiBase', v); },
114
+ get sessionId() {
115
+ let sid = localStorage.getItem('sessionId');
116
+ if (!sid) { sid = 'sess-' + Math.random().toString(16).slice(2, 10); localStorage.setItem('sessionId', sid); }
117
+ return sid;
118
+ },
119
+ get messages() {
120
+ const sid = this.sessionId;
121
+ try { return JSON.parse(localStorage.getItem(`chat:${sid}`) || '[]'); } catch { return []; }
122
+ },
123
+ set messages(arr) {
124
+ const sid = this.sessionId;
125
+ localStorage.setItem(`chat:${sid}`, JSON.stringify(arr));
126
+ },
127
+ clear() {
128
+ localStorage.removeItem(`chat:${this.sessionId}`);
129
+ }
130
+ };
131
+
132
+ apiBaseInput.value = qs.get('api') || store.apiBase;
133
+ sessionIdLabel.textContent = store.sessionId;
134
+
135
+ saveBaseBtn.onclick = () => {
136
+ const v = apiBaseInput.value.trim();
137
+ if (!/^https?:\/\//i.test(v)) { alert('Provide a valid API base (https://...)'); return; }
138
+ store.apiBase = v;
139
+ checkHealth();
140
+ };
141
+
142
+ async function checkHealth() {
143
+ healthEl.textContent = 'Health: checking…';
144
+ try {
145
+ const r = await fetch(new URL('/health', store.apiBase), { mode: 'cors' });
146
+ const j = await r.json();
147
+ healthEl.textContent = `Health: ${j.ok ? 'OK' : 'ERR'} · ModelReady=${j.modelReady ? 'yes' : 'no'} · Model=${j.modelId || 'unknown'}`;
148
+ } catch (e) {
149
+ healthEl.textContent = `Health: error (${e && e.message ? e.message : 'network'})`;
150
+ }
151
+ }
152
+
153
+ // UI helpers
154
+ function render() {
155
+ chatEl.innerHTML = '';
156
+ const messages = store.messages;
157
+ // Render messages grouped by role sequence
158
+ for (const msg of messages) {
159
+ const node = document.createElement('div');
160
+ node.className = `msg ${msg.role}`;
161
+ const role = document.createElement('div');
162
+ role.className = 'role';
163
+ role.textContent = msg.role;
164
+ node.appendChild(role);
165
+
166
+ const content = document.createElement('div');
167
+ content.className = 'content';
168
+ if (typeof msg.content === 'string') {
169
+ const pre = document.createElement('pre');
170
+ pre.textContent = msg.content;
171
+ content.appendChild(pre);
172
+ } else if (Array.isArray(msg.content)) {
173
+ const textParts = msg.content.filter(p => p.type === 'text');
174
+ for (const t of textParts) {
175
+ const pre = document.createElement('pre');
176
+ pre.textContent = t.text || '';
177
+ content.appendChild(pre);
178
+ }
179
+ const media = document.createElement('div');
180
+ media.className = 'media';
181
+ for (const p of msg.content) {
182
+ if (p.type === 'input_image' || p.type === 'image_url') {
183
+ const img = document.createElement('img');
184
+ if (p.b64_json) {
185
+ img.src = p.b64_json.startsWith('data:') ? p.b64_json : ('data:image/*;base64,' + p.b64_json);
186
+ } else if (p.image_url && p.image_url.url) {
187
+ img.src = p.image_url.url;
188
+ }
189
+ media.appendChild(img);
190
+ } else if (p.type === 'input_video' || p.type === 'video_url') {
191
+ const video = document.createElement('video');
192
+ video.controls = true;
193
+ if (p.b64_json) {
194
+ video.src = p.b64_json.startsWith('data:') ? p.b64_json : ('data:video/mp4;base64,' + p.b64_json);
195
+ } else if (p.video_url && p.video_url.url) {
196
+ video.src = p.video_url.url;
197
+ }
198
+ media.appendChild(video);
199
+ }
200
+ }
201
+ if (media.childElementCount) content.appendChild(media);
202
+ }
203
+ node.appendChild(content);
204
+ chatEl.appendChild(node);
205
+ }
206
+ chatEl.scrollTop = chatEl.scrollHeight;
207
+ }
208
+
209
+ // File handling
210
+ const fileQueue = [];
211
+ fileEl.addEventListener('change', async (e) => {
212
+ const files = Array.from(e.target.files || []);
213
+ for (const f of files) {
214
+ const b64 = await fileToDataURL(f);
215
+ fileQueue.push({ name: f.name, type: f.type, dataUrl: b64 });
216
+ }
217
+ renderPreview();
218
+ e.target.value = '';
219
+ });
220
+
221
+ function renderPreview() {
222
+ previewEl.innerHTML = '';
223
+ for (let i = 0; i < fileQueue.length; i++) {
224
+ const f = fileQueue[i];
225
+ const wrap = document.createElement('div');
226
+ wrap.className = 'preview-item';
227
+ const btn = document.createElement('button');
228
+ btn.className = 'remove';
229
+ btn.textContent = 'x';
230
+ btn.onclick = () => { fileQueue.splice(i, 1); renderPreview(); };
231
+ wrap.appendChild(btn);
232
+ if (f.type.startsWith('image/')) {
233
+ const img = document.createElement('img');
234
+ img.src = f.dataUrl;
235
+ img.style.maxWidth = '160px';
236
+ img.style.maxHeight = '120px';
237
+ wrap.appendChild(img);
238
+ } else if (f.type.startsWith('video/')) {
239
+ const video = document.createElement('video');
240
+ video.src = f.dataUrl;
241
+ video.controls = true;
242
+ video.style.maxWidth = '160px';
243
+ video.style.maxHeight = '120px';
244
+ wrap.appendChild(video);
245
+ } else {
246
+ const pre = document.createElement('pre');
247
+ pre.textContent = f.name;
248
+ wrap.appendChild(pre);
249
+ }
250
+ previewEl.appendChild(wrap);
251
+ }
252
+ }
253
+
254
+ function fileToDataURL(file) {
255
+ return new Promise((resolve, reject) => {
256
+ const reader = new FileReader();
257
+ reader.onload = () => resolve(reader.result);
258
+ reader.onerror = reject;
259
+ reader.readAsDataURL(file);
260
+ });
261
+ }
262
+
263
+ function dataUrlToBase64(d) {
264
+ return d.includes('base64,') ? d.split('base64,')[1] : d;
265
+ }
266
+
267
+ // Build OpenAI-style messages array from stored history (already in that shape)
268
+ function getMessages() {
269
+ return store.messages;
270
+ }
271
+
272
+ function pushUserMessageFromUI() {
273
+ const msg = { role: 'user', content: [] };
274
+ const text = (promptEl.value || '').trim();
275
+ if (text) msg.content.push({ type: 'text', text });
276
+ for (const f of fileQueue) {
277
+ if (f.type.startsWith('image/')) {
278
+ msg.content.push({ type: 'input_image', b64_json: dataUrlToBase64(f.dataUrl) });
279
+ } else if (f.type.startsWith('video/')) {
280
+ msg.content.push({ type: 'input_video', b64_json: dataUrlToBase64(f.dataUrl) });
281
+ }
282
+ }
283
+ const messages = getMessages();
284
+ messages.push(msg);
285
+ store.messages = messages;
286
+ // clear UI queue
287
+ fileQueue.splice(0, fileQueue.length);
288
+ previewEl.innerHTML = '';
289
+ promptEl.value = '';
290
+ render();
291
+ }
292
+
293
+ async function sendStream() {
294
+ const apiBase = apiBaseInput.value.trim() || DEFAULT_SPACE;
295
+ const body = {
296
+ messages: getMessages(),
297
+ stream: true,
298
+ session_id: store.sessionId,
299
+ max_tokens: Math.max(1, parseInt(maxTokensEl.value || '4096', 10)),
300
+ temperature: parseFloat(temperatureEl.value || '0.7'),
301
+ };
302
+
303
+ const url = new URL('/v1/chat/completions', apiBase);
304
+ const resp = await fetch(url, {
305
+ method: 'POST',
306
+ headers: { 'Content-Type': 'application/json' },
307
+ body: JSON.stringify(body),
308
+ mode: 'cors',
309
+ });
310
+ if (!resp.ok || !resp.body) {
311
+ const text = await resp.text().catch(() => '');
312
+ throw new Error(`HTTP ${resp.status}: ${text}`);
313
+ }
314
+ // Prepare assistant message to accumulate streamed content
315
+ const messages = getMessages();
316
+ const asst = { role: 'assistant', content: '' };
317
+ messages.push(asst);
318
+ store.messages = messages;
319
+ render();
320
+
321
+ const reader = resp.body.getReader();
322
+ const decoder = new TextDecoder();
323
+ let buffer = '';
324
+ while (true) {
325
+ const { done, value } = await reader.read();
326
+ if (done) break;
327
+ buffer += decoder.decode(value, { stream: true });
328
+ // split SSE blocks
329
+ let idx;
330
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
331
+ const block = buffer.slice(0, idx); buffer = buffer.slice(idx + 2);
332
+ const lines = block.split('\n');
333
+ for (const line of lines) {
334
+ if (line.startsWith('data:')) {
335
+ const data = line.slice(5).trim();
336
+ if (data === '[DONE]') continue;
337
+ try {
338
+ const j = JSON.parse(data);
339
+ const delta = (((j || {}).choices || [])[0] || {}).delta || {};
340
+ if (typeof delta.content === 'string' && delta.content.length) {
341
+ // append token
342
+ const msgs = getMessages();
343
+ const last = msgs[msgs.length - 1];
344
+ if (last && last.role === 'assistant') {
345
+ last.content = (last.content || '') + delta.content;
346
+ store.messages = msgs;
347
+ render();
348
+ }
349
+ }
350
+ } catch {}
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ sendBtn.onclick = async () => {
358
+ try {
359
+ pushUserMessageFromUI();
360
+ await sendStream();
361
+ } catch (e) {
362
+ alert('Send failed: ' + (e && e.message ? e.message : e));
363
+ }
364
+ };
365
+
366
+ clearBtn.onclick = () => {
367
+ if (confirm('Clear chat history for this session?')) {
368
+ store.clear(); render();
369
+ }
370
+ };
371
+
372
+ (async function init() {
373
+ render();
374
+ await checkHealth();
375
+ // Auto-save default base on first load if empty
376
+ if (!localStorage.getItem('apiBase')) localStorage.setItem('apiBase', apiBaseInput.value.trim() || DEFAULT_SPACE);
377
+ })();
378
+ </script>
379
+ </body>
380
+ </html>