File size: 7,821 Bytes
96540b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
222
223
224
225
226
227
228
"""Streaming protocol for the optional WebSocket backend.

The Gradio Space itself streams through generator functions; this module defines
the wire format and a modality-agnostic dispatcher so the same playground can be
re-deployed as a Docker Space (FastAPI + uvicorn) without touching the loaders.

Wire format (JSON frames)
-------------------------
client -> server
    {"type": "request", "id": "<uuid>", "modality": "text|image|audio|video",
     "repo_id": "...", "task": "auto|<task>", "params": {...}}
    {"type": "cancel",  "id": "<uuid>"}

server -> client
    {"type": "ack",   "id": "..."}
    {"type": "chunk", "id": "...", "seq": 0, "data": "...", "encoding": "text|base64"}
    {"type": "done",  "id": "...", "meta": {...}}
    {"type": "error", "id": "...", "message": "..."}

Endpoints: /ws/text, /ws/audio, /ws/video (one dispatcher, three routes).

Run standalone:  uvicorn ws_protocol:app --host 0.0.0.0 --port 7861
"""

from __future__ import annotations

import base64
import json
import os
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, Iterator, Optional

import model_registry
from loaders.common import PlaygroundError

WS_ROUTES = ("/ws/text", "/ws/audio", "/ws/video")

FRAME_TYPES = ("request", "cancel", "ack", "chunk", "done", "error")


# --------------------------------------------------------------------------- #
# Frames
# --------------------------------------------------------------------------- #


@dataclass
class Frame:
    type: str
    id: str
    seq: Optional[int] = None
    data: Optional[str] = None
    encoding: Optional[str] = None
    message: Optional[str] = None
    meta: Dict[str, Any] = field(default_factory=dict)

    def to_json(self) -> str:
        payload = {k: v for k, v in asdict(self).items() if v not in (None, {}, [])}
        payload["type"] = self.type
        payload["id"] = self.id
        return json.dumps(payload, ensure_ascii=False)


def ack(request_id: str) -> Frame:
    return Frame(type="ack", id=request_id)


def chunk(request_id: str, seq: int, data: str, encoding: str = "text") -> Frame:
    return Frame(type="chunk", id=request_id, seq=seq, data=data, encoding=encoding)


def done(request_id: str, **meta: Any) -> Frame:
    return Frame(type="done", id=request_id, meta=meta)


def error(request_id: str, message: str) -> Frame:
    return Frame(type="error", id=request_id, message=message)


def parse_request(raw: str) -> Dict[str, Any]:
    """Validate an inbound frame and return it as a dict."""
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise PlaygroundError(f"Malformed frame: {exc}") from exc
    if not isinstance(payload, dict):
        raise PlaygroundError("Frame must be a JSON object.")
    frame_type = payload.get("type")
    if frame_type not in FRAME_TYPES:
        raise PlaygroundError(f"Unknown frame type {frame_type!r}.")
    if frame_type == "request":
        for required in ("id", "repo_id"):
            if not payload.get(required):
                raise PlaygroundError(f"Frame is missing '{required}'.")
    return payload


# --------------------------------------------------------------------------- #
# Dispatcher
# --------------------------------------------------------------------------- #


def handle_request(payload: Dict[str, Any]) -> Iterator[Frame]:
    """Turn one `request` frame into a stream of response frames.

    Synchronous generator on purpose: the transport (FastAPI, Gradio, a queue
    worker) decides how to pump it, and the loaders stay transport-agnostic.
    """
    request_id = payload.get("id", "0")
    repo_id = payload.get("repo_id", "")
    task = payload.get("task", "auto")
    params: Dict[str, Any] = payload.get("params", {}) or {}

    yield ack(request_id)

    try:
        spec = model_registry.resolve(repo_id, task)
        loader = model_registry.get_loader(spec.modality)
        handle = loader.load_model(
            spec.repo_id,
            spec.task,
            dtype=params.get("dtype", "auto"),
            uncensored=bool(params.get("uncensored", False)),
        )

        if spec.modality == "text":
            prompt = params.pop("prompt", "")
            seq = 0
            for partial in loader.stream_inference(handle, prompt, **params):
                yield chunk(request_id, seq, partial)
                seq += 1
            yield done(request_id, task=spec.task, chunks=seq)
            return

        if spec.modality == "image":
            images, info = loader.run_inference(handle, **params)
            for seq, image in enumerate(images):
                yield chunk(request_id, seq, _encode_image(image), encoding="base64")
            yield done(request_id, task=spec.task, info=info)
            return

        if spec.modality == "audio":
            audio, text = loader.run_inference(handle, **params)
            if text:
                yield chunk(request_id, 0, text)
            if audio is not None:
                rate, array = audio
                yield chunk(request_id, 1, _encode_array(array), encoding="base64")
                yield done(request_id, task=spec.task, sampling_rate=rate)
                return
            yield done(request_id, task=spec.task)
            return

        path, info = loader.run_inference(handle, **params)
        if path:
            yield chunk(request_id, 0, _encode_file(path), encoding="base64")
        yield done(request_id, task=spec.task, info=info, filename=os.path.basename(path or ""))

    except PlaygroundError as exc:
        yield error(request_id, str(exc))
    except Exception as exc:  # never let the socket die on a model-specific bug
        yield error(request_id, f"{type(exc).__name__}: {exc}")


def _encode_file(path: str) -> str:
    with open(path, "rb") as handle:
        return base64.b64encode(handle.read()).decode("ascii")


def _encode_image(image: Any) -> str:
    import io

    buffer = io.BytesIO()
    image.save(buffer, format="PNG")
    return base64.b64encode(buffer.getvalue()).decode("ascii")


def _encode_array(array: Any) -> str:
    try:
        import numpy as np

        return base64.b64encode(np.asarray(array, dtype="float32").tobytes()).decode("ascii")
    except ImportError:
        return base64.b64encode(bytes(array)).decode("ascii")


# --------------------------------------------------------------------------- #
# Optional FastAPI app (only built when fastapi is installed)
# --------------------------------------------------------------------------- #


def build_app():
    """Return a FastAPI app exposing WS_ROUTES. Raises if fastapi is missing."""
    from fastapi import FastAPI, WebSocket, WebSocketDisconnect

    api = FastAPI(title="Model Playground WS")

    async def endpoint(websocket: WebSocket) -> None:
        await websocket.accept()
        try:
            while True:
                raw = await websocket.receive_text()
                try:
                    payload = parse_request(raw)
                except PlaygroundError as exc:
                    await websocket.send_text(error("0", str(exc)).to_json())
                    continue
                if payload["type"] != "request":
                    continue
                for frame in handle_request(payload):
                    await websocket.send_text(frame.to_json())
        except WebSocketDisconnect:
            return

    for route in WS_ROUTES:
        api.add_api_websocket_route(route, endpoint)

    @api.get("/health")
    async def health() -> Dict[str, Any]:
        return {"status": "ok", "routes": list(WS_ROUTES)}

    return api


try:  # convenience for `uvicorn ws_protocol:app`
    app = build_app()
except Exception:  # fastapi absent in a plain Gradio Space — expected
    app = None