File size: 5,730 Bytes
1d3370f
678b831
5a6288c
 
 
 
 
 
 
 
 
 
 
 
 
 
1d3370f
d8f021b
 
1d3370f
 
5965cc0
5a6288c
d8f021b
705b411
5965cc0
 
7c990b2
 
a40ad2a
5965cc0
 
 
522b36f
678b831
522b36f
 
 
 
 
 
 
 
678b831
d8f021b
 
4dbf459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a6288c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dbf459
 
 
 
 
 
5a6288c
 
4dbf459
 
 
d8f021b
678b831
1d3370f
678b831
 
 
 
d8f021b
 
5965cc0
d8f021b
678b831
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dbf459
678b831
d8f021b
5965cc0
 
 
 
d8f021b
 
 
5965cc0
d8f021b
5965cc0
678b831
 
5965cc0
d8f021b
 
678b831
 
 
4d868dd
678b831
 
e0e14c2
678b831
 
 
 
 
d8f021b
 
 
678b831
d8f021b
 
 
678b831
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
import base64
import io
import warnings

# Gradio still references Starlette's deprecated HTTP_422_UNPROCESSABLE_ENTITY.
warnings.filterwarnings(
    "ignore",
    message="'HTTP_422_UNPROCESSABLE_ENTITY' is deprecated",
    category=DeprecationWarning,
)
# Stale installs or old Space builds may still import duckduckgo_search; we use Brave only.
warnings.filterwarnings(
    "ignore",
    message=r".*`duckduckgo_search`.*renamed.*`ddgs`.*",
    category=RuntimeWarning,
)

import gradio as gr
from huggingface_hub import InferenceClient
from PIL import Image

from agent import run_agent
from agent.orchestrator import strip_qwen_thinking

MODEL = "Qwen/Qwen3-VL-30B-A3B-Thinking"

DEFAULT_SYSTEM = (
    "You are a helpful, multimodal AI assistant. When an image is sent, simply transcribe it without analysis, unless the user asks for analysis." \
    "When you need up-to-date information from the internet, use the search_web tool. Once you have a complete answer, call final_output with the full answer." \
    "If a task is impossible or unsafe, call abort with a brief reason."
)


def _image_to_data_url(image_path: str, max_side: int = 1120, quality: int = 85) -> str:
    """Resize and base64-encode a local image so the HF payload stays under the limit."""
    with Image.open(image_path) as img:
        img = img.convert("RGB")
        w, h = img.size
        if max(w, h) > max_side:
            scale = max_side / max(w, h)
            img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
        buf = io.BytesIO()
        img.save(buf, format="JPEG", quality=quality)
    return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()


def _normalize_content_for_api(content):
    """
    Gradio multimodal history uses {'type': 'file', 'file': FileData}; the HF
    chat API expects {'type': 'image_url', 'image_url': {'url': ...}}.
    """
    if isinstance(content, str):
        return content
    if not isinstance(content, list):
        return content
    out: list = []
    for part in content:
        if isinstance(part, str):
            out.append({"type": "text", "text": part})
            continue
        if not isinstance(part, dict):
            continue
        ptype = part.get("type")
        if ptype == "text":
            out.append({"type": "text", "text": part.get("text", "")})
        elif ptype == "file":
            fd = part.get("file")
            if isinstance(fd, dict) and fd.get("path"):
                out.append(
                    {
                        "type": "image_url",
                        "image_url": {"url": _image_to_data_url(fd["path"])},
                    }
                )
        elif ptype == "image_url":
            out.append(part)
    if not out:
        return ""
    if len(out) == 1 and out[0].get("type") == "text":
        return out[0].get("text", "")
    return out


def _strip_thinking_from_normalized(content):
    """Remove Qwen thinking tags from text Gradio stored on assistant turns."""
    if isinstance(content, str):
        return strip_qwen_thinking(content)
    if isinstance(content, list):
        out = []
        for part in content:
            if isinstance(part, dict) and part.get("type") == "text":
                t = part.get("text", "")
                out.append({**part, "text": strip_qwen_thinking(t)})
            else:
                out.append(part)
        return out
    return content


def _normalize_history_message(msg: dict) -> dict:
    role = msg.get("role")
    if role not in ("user", "assistant", "system"):
        return msg
    content = msg.get("content")
    normalized = _normalize_content_for_api(content)
    if role == "assistant":
        normalized = _strip_thinking_from_normalized(normalized)
    return {**msg, "content": normalized}


def respond(
    message,
    history: list[dict],
    system_message,
    max_tokens,
    temperature,
    top_p,
    hf_token: gr.OAuthToken,
):
    client = InferenceClient(token=hf_token.token, model=MODEL)

    # multimodal=True sends {"text": str, "files": [path, ...]}
    # Guard against plain strings in case of edge-case history replay
    if isinstance(message, dict):
        text = message.get("text", "")
        files = message.get("files", [])
    else:
        text = message or ""
        files = []

    if files:
        content = []
        if text:
            content.append({"type": "text", "text": text})
        for f in files:
            content.append({"type": "image_url", "image_url": {"url": _image_to_data_url(f)}})
    else:
        content = text

    messages = [{"role": "system", "content": system_message}]
    messages.extend(_normalize_history_message(m) for m in history)
    messages.append({"role": "user", "content": content})

    answer = run_agent(
        messages=messages,
        client=client,
        model=MODEL,
        max_tokens=max_tokens,
        temperature=temperature,
        top_p=top_p,
    )

    partial = ""
    for i in range(0, len(answer), 8):
        partial += answer[i : i + 8]
        yield partial


chatbot = gr.ChatInterface(
    respond,
    multimodal=True,
    chatbot=gr.Chatbot(height=700),
    additional_inputs=[
        gr.Textbox(value=DEFAULT_SYSTEM, label="System message"),
        gr.Slider(minimum=1, maximum=16384, value=16384, step=1, label="Max new tokens"),
        gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
        gr.Slider(minimum=0.1, maximum=1.0, value=0.95, step=0.05, label="Top-p (nucleus sampling)"),
    ],
)

with gr.Blocks() as demo:
    with gr.Sidebar():
        gr.LoginButton()
    chatbot.render()


if __name__ == "__main__":
    demo.launch(ssr_mode=False)