R-Kentaren commited on
Commit
380204e
Β·
verified Β·
1 Parent(s): 54056c4

Upload folder using huggingface_hub

Browse files
app.py CHANGED
@@ -1,20 +1,22 @@
1
  """Fullstack Code Builder β€” entry point.
2
 
3
- Uses MiniCPM5-1B for local inference (no external APIs).
 
4
  Supports generating fullstack applications in any language.
5
  Can push generated projects to HuggingFace Hub.
6
  Web search via Google scraping (no API keys needed).
7
  Gradio app support for Python.
 
8
 
9
  Project structure:
10
  code/
11
- β”œβ”€β”€ config/constants.py App constants, language options, system prompt
12
- β”œβ”€β”€ model/loader.py Model loading & status
13
- β”œβ”€β”€ model/inference.py Streaming model inference
14
  β”œβ”€β”€ execution/code_extractor.py Code extraction & language normalization
15
  β”œβ”€β”€ execution/python_runner.py Sandboxed Python execution
16
  β”œβ”€β”€ execution/gradio_runner.py Gradio app subprocess runner
17
- β”œβ”€β”€ websearch/google_scraper.py Google search scraping (no API)
18
  β”œβ”€β”€ huggingface/push.py HuggingFace Hub push & ZIP packaging
19
  β”œβ”€β”€ server/chat_helpers.py Chat history & prompt building
20
  └── server/routes.py FastAPI / Gradio server routes
@@ -30,7 +32,7 @@ from code.server.routes import get_app
30
  logging.basicConfig(level=logging.INFO)
31
  logger = logging.getLogger(__name__)
32
 
33
- # Start loading model in background
34
  start_background_load()
35
 
36
  # Launch the server
 
1
  """Fullstack Code Builder β€” entry point.
2
 
3
+ Uses MiniCPM5-1B (text) or MiniCPM-V-4.6 (vision+text) for local inference.
4
+ No external APIs required.
5
  Supports generating fullstack applications in any language.
6
  Can push generated projects to HuggingFace Hub.
7
  Web search via Google scraping (no API keys needed).
8
  Gradio app support for Python.
9
+ Image understanding with MiniCPM-V-4.6.
10
 
11
  Project structure:
12
  code/
13
+ β”œβ”€β”€ config/constants.py App constants, model configs, system prompt
14
+ β”œβ”€β”€ model/loader.py Dual model loading & switching
15
+ β”œβ”€β”€ model/inference.py Streaming inference (text + VLM)
16
  β”œβ”€β”€ execution/code_extractor.py Code extraction & language normalization
17
  β”œβ”€β”€ execution/python_runner.py Sandboxed Python execution
18
  β”œβ”€β”€ execution/gradio_runner.py Gradio app subprocess runner
19
+ β”œβ”€β”€ websearch/google_scraper.py Web search scraping (no API)
20
  β”œβ”€β”€ huggingface/push.py HuggingFace Hub push & ZIP packaging
21
  β”œβ”€β”€ server/chat_helpers.py Chat history & prompt building
22
  └── server/routes.py FastAPI / Gradio server routes
 
32
  logging.basicConfig(level=logging.INFO)
33
  logger = logging.getLogger(__name__)
34
 
35
+ # Start loading default model in background
36
  start_background_load()
37
 
38
  # Launch the server
code/config/constants.py CHANGED
@@ -7,9 +7,36 @@ import re
7
  # ─── App Identity ────────────────────────────────────────────────────────
8
 
9
  APP_TITLE = "Fullstack Code Builder"
10
- MODEL_ID = "openbmb/MiniCPM5-1B"
11
  MODEL_URL = "https://huggingface.co/openbmb/MiniCPM5-1B"
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  # ─── Runtime Defaults ───────────────────────────────────────────────────
14
 
15
  DEFAULT_TEMPERATURE = 0.4
@@ -93,6 +120,8 @@ When generating Gradio apps, create a complete app.py with:
93
  For Python, prefer standard library or common packages. Do not use network calls, subprocesses, shell commands, or long-running loops in demo code (except Gradio apps which are server-based).
94
 
95
  If web search results are provided in the context, use them to inform your code generation. Incorporate relevant information from the search results into the generated code.
 
 
96
  """
97
 
98
  # ─── Example Prompts ────────────────────────────────────────────────────
 
7
  # ─── App Identity ────────────────────────────────────────────────────────
8
 
9
  APP_TITLE = "Fullstack Code Builder"
 
10
  MODEL_URL = "https://huggingface.co/openbmb/MiniCPM5-1B"
11
 
12
+ # ─── Model Configs ───────────────────────────────────────────────────────
13
+
14
+ MODEL_CONFIGS = {
15
+ "minicpm5-1b": {
16
+ "id": "openbmb/MiniCPM5-1B",
17
+ "name": "MiniCPM5-1B",
18
+ "type": "text",
19
+ "description": "Text-only, fast code generation",
20
+ "auto_class": "AutoModelForCausalLM",
21
+ "tokenizer_class": "AutoTokenizer",
22
+ "size_gb": 2.17,
23
+ },
24
+ "minicpm-v-4.6": {
25
+ "id": "openbmb/MiniCPM-V-4.6",
26
+ "name": "MiniCPM-V-4.6",
27
+ "type": "vlm",
28
+ "description": "Vision + Text, image understanding & code",
29
+ "auto_class": "AutoModelForImageTextToText",
30
+ "processor_class": "AutoProcessor",
31
+ "size_gb": 2.8,
32
+ },
33
+ }
34
+
35
+ DEFAULT_MODEL_KEY = "minicpm5-1b"
36
+
37
+ # Keep backward compat aliases
38
+ MODEL_ID = MODEL_CONFIGS[DEFAULT_MODEL_KEY]["id"]
39
+
40
  # ─── Runtime Defaults ───────────────────────────────────────────────────
41
 
42
  DEFAULT_TEMPERATURE = 0.4
 
120
  For Python, prefer standard library or common packages. Do not use network calls, subprocesses, shell commands, or long-running loops in demo code (except Gradio apps which are server-based).
121
 
122
  If web search results are provided in the context, use them to inform your code generation. Incorporate relevant information from the search results into the generated code.
123
+
124
+ If the user provides an image, analyze it and generate code based on what you see in the image. For example: replicate a UI from a screenshot, generate code from a wireframe, or build an app described in a document.
125
  """
126
 
127
  # ─── Example Prompts ────────────────────────────────────────────────────
code/model/inference.py CHANGED
@@ -1,6 +1,8 @@
1
  """Model inference β€” streaming and synchronous generation.
2
 
3
- Uses TextIteratorStreamer for real-time token streaming.
 
 
4
  """
5
 
6
  from __future__ import annotations
@@ -10,8 +12,15 @@ import threading
10
  from collections.abc import Iterator
11
  from typing import Any
12
 
13
- from code.config.constants import DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS
14
- from code.model.loader import get_model, get_tokenizer, get_model_status, is_model_loaded
 
 
 
 
 
 
 
15
 
16
  logger = logging.getLogger(__name__)
17
 
@@ -19,19 +28,32 @@ logger = logging.getLogger(__name__)
19
  def call_model(
20
  messages: list[dict[str, Any]],
21
  max_new_tokens: int = DEFAULT_MAX_TOKENS,
 
22
  ) -> Iterator[str]:
23
- """Stream model text using local MiniCPM5-1B.
24
 
25
- Yields progressively longer strings (full text so far).
26
  """
27
-
28
  if not is_model_loaded():
29
  status = get_model_status()
30
  yield status["message"]
31
  return
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  model = get_model()
34
- tokenizer = get_tokenizer()
35
 
36
  try:
37
  from transformers import TextIteratorStreamer
@@ -82,16 +104,171 @@ def call_model(
82
  thread.join()
83
 
84
  except Exception as exc:
85
- logger.exception("Error during model inference")
86
  yield f"_Error during generation: {exc}_"
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def call_model_sync(
90
  messages: list[dict[str, Any]],
91
  max_new_tokens: int = DEFAULT_MAX_TOKENS,
 
92
  ) -> str:
93
  """Non-streaming model call β€” returns complete response."""
94
  result = ""
95
- for chunk in call_model(messages, max_new_tokens):
96
  result = chunk
97
  return result
 
1
  """Model inference β€” streaming and synchronous generation.
2
 
3
+ Supports two inference paths:
4
+ - Text-only (MiniCPM5-1B): uses TextIteratorStreamer for real-time streaming
5
+ - VLM (MiniCPM-V-4.6): uses processor.apply_chat_template() with image support
6
  """
7
 
8
  from __future__ import annotations
 
12
  from collections.abc import Iterator
13
  from typing import Any
14
 
15
+ from code.config.constants import DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS, MODEL_CONFIGS
16
+ from code.model.loader import (
17
+ get_model,
18
+ get_tokenizer_or_processor,
19
+ get_model_status,
20
+ is_model_loaded,
21
+ get_current_model_key,
22
+ get_current_model_type,
23
+ )
24
 
25
  logger = logging.getLogger(__name__)
26
 
 
28
  def call_model(
29
  messages: list[dict[str, Any]],
30
  max_new_tokens: int = DEFAULT_MAX_TOKENS,
31
+ image_url: str | None = None,
32
  ) -> Iterator[str]:
33
+ """Stream model text. Yields progressively longer strings (full text so far).
34
 
35
+ For VLM models, if image_url is provided, it's included in the last user message.
36
  """
 
37
  if not is_model_loaded():
38
  status = get_model_status()
39
  yield status["message"]
40
  return
41
 
42
+ model_type = get_current_model_type()
43
+
44
+ if model_type == "vlm":
45
+ yield from _call_vlm_model(messages, max_new_tokens, image_url)
46
+ else:
47
+ yield from _call_text_model(messages, max_new_tokens)
48
+
49
+
50
+ def _call_text_model(
51
+ messages: list[dict[str, Any]],
52
+ max_new_tokens: int,
53
+ ) -> Iterator[str]:
54
+ """Stream text from a text-only model using TextIteratorStreamer."""
55
  model = get_model()
56
+ tokenizer = get_tokenizer_or_processor()
57
 
58
  try:
59
  from transformers import TextIteratorStreamer
 
104
  thread.join()
105
 
106
  except Exception as exc:
107
+ logger.exception("Error during text model inference")
108
  yield f"_Error during generation: {exc}_"
109
 
110
 
111
+ def _call_vlm_model(
112
+ messages: list[dict[str, Any]],
113
+ max_new_tokens: int,
114
+ image_url: str | None = None,
115
+ ) -> Iterator[str]:
116
+ """Stream text from a VLM model with optional image input.
117
+
118
+ Uses processor.apply_chat_template() for proper image+text processing,
119
+ then generates with streaming via a thread.
120
+ """
121
+ model = get_model()
122
+ processor = get_tokenizer_or_processor()
123
+
124
+ try:
125
+ import torch
126
+
127
+ # Build VLM-style messages with image support
128
+ vlm_messages = _build_vlm_messages(messages, image_url)
129
+
130
+ # Apply chat template
131
+ try:
132
+ inputs = processor.apply_chat_template(
133
+ vlm_messages,
134
+ tokenize=True,
135
+ add_generation_prompt=True,
136
+ return_dict=True,
137
+ return_tensors="pt",
138
+ downsample_mode="16x",
139
+ max_slice_nums=9,
140
+ )
141
+ except TypeError:
142
+ # Fallback for older transformers without downsample_mode
143
+ inputs = processor.apply_chat_template(
144
+ vlm_messages,
145
+ tokenize=True,
146
+ add_generation_prompt=True,
147
+ return_dict=True,
148
+ return_tensors="pt",
149
+ )
150
+
151
+ if torch.cuda.is_available():
152
+ inputs = inputs.to("cuda")
153
+ else:
154
+ # Move to CPU explicitly
155
+ inputs = inputs.to("cpu")
156
+
157
+ # Generate with streaming
158
+ try:
159
+ from transformers import TextIteratorStreamer
160
+ streamer = TextIteratorStreamer(
161
+ processor.tokenizer if hasattr(processor, 'tokenizer') else processor,
162
+ skip_prompt=True,
163
+ skip_special_tokens=True,
164
+ )
165
+
166
+ gen_kwargs = {
167
+ **inputs,
168
+ "streamer": streamer,
169
+ "max_new_tokens": max_new_tokens,
170
+ "temperature": DEFAULT_TEMPERATURE,
171
+ "do_sample": True,
172
+ "top_p": 0.9,
173
+ "repetition_penalty": 1.1,
174
+ }
175
+ # Add downsample_mode if supported
176
+ try:
177
+ gen_kwargs["downsample_mode"] = "16x"
178
+ except Exception:
179
+ pass
180
+
181
+ # Ensure pad_token_id
182
+ if hasattr(processor, 'tokenizer') and hasattr(processor.tokenizer, 'eos_token_id'):
183
+ gen_kwargs["pad_token_id"] = processor.tokenizer.eos_token_id
184
+ elif hasattr(processor, 'eos_token_id'):
185
+ gen_kwargs["pad_token_id"] = processor.eos_token_id
186
+
187
+ thread = threading.Thread(target=model.generate, kwargs=gen_kwargs)
188
+ thread.start()
189
+
190
+ output = ""
191
+ for new_text in streamer:
192
+ output += new_text
193
+ yield output
194
+
195
+ thread.join()
196
+
197
+ except Exception as stream_err:
198
+ # Fallback: non-streaming generation
199
+ logger.warning("Streaming failed for VLM, falling back to sync: %s", stream_err)
200
+ gen_kwargs = {
201
+ **inputs,
202
+ "max_new_tokens": max_new_tokens,
203
+ "temperature": DEFAULT_TEMPERATURE,
204
+ "do_sample": True,
205
+ "top_p": 0.9,
206
+ }
207
+ try:
208
+ gen_kwargs["downsample_mode"] = "16x"
209
+ except Exception:
210
+ pass
211
+
212
+ generated_ids = model.generate(**gen_kwargs)
213
+ # Trim input tokens from output
214
+ input_len = inputs["input_ids"].shape[1] if hasattr(inputs, "shape") else len(inputs["input_ids"])
215
+ generated_ids_trimmed = [
216
+ out_ids[len(in_ids):]
217
+ for in_ids, out_ids in zip(inputs["input_ids"], generated_ids)
218
+ ]
219
+ tok = processor.tokenizer if hasattr(processor, 'tokenizer') else processor
220
+ output_text = tok.batch_decode(
221
+ generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
222
+ )
223
+ yield output_text[0] if output_text else ""
224
+
225
+ except Exception as exc:
226
+ logger.exception("Error during VLM model inference")
227
+ yield f"_Error during generation: {exc}_"
228
+
229
+
230
+ def _build_vlm_messages(
231
+ messages: list[dict[str, Any]],
232
+ image_url: str | None = None,
233
+ ) -> list[dict[str, Any]]:
234
+ """Build VLM-style messages with image content blocks.
235
+
236
+ If an image_url is provided, it's injected into the last user message
237
+ as a content block with type "image".
238
+ """
239
+ vlm_messages = []
240
+
241
+ for i, msg in enumerate(messages):
242
+ role = msg.get("role", "user")
243
+ content = msg.get("content", "")
244
+
245
+ if role == "system":
246
+ # System messages stay as-is for VLM
247
+ vlm_messages.append({"role": "system", "content": content})
248
+ continue
249
+
250
+ # For the last user message with an image, use structured content
251
+ is_last_user = (i == len(messages) - 1) and role == "user"
252
+
253
+ if is_last_user and image_url:
254
+ # Build content list with image + text
255
+ content_list = [{"type": "image", "url": image_url}]
256
+ if content.strip():
257
+ content_list.append({"type": "text", "text": content})
258
+ vlm_messages.append({"role": "user", "content": content_list})
259
+ else:
260
+ vlm_messages.append({"role": role, "content": content})
261
+
262
+ return vlm_messages
263
+
264
+
265
  def call_model_sync(
266
  messages: list[dict[str, Any]],
267
  max_new_tokens: int = DEFAULT_MAX_TOKENS,
268
+ image_url: str | None = None,
269
  ) -> str:
270
  """Non-streaming model call β€” returns complete response."""
271
  result = ""
272
+ for chunk in call_model(messages, max_new_tokens, image_url):
273
  result = chunk
274
  return result
code/model/loader.py CHANGED
@@ -1,89 +1,232 @@
1
  """Model loading and status management.
2
 
3
- Handles loading MiniCPM5-1B locally using transformers.
 
 
 
 
4
  The model is loaded in a background thread on startup.
5
  """
6
 
7
  from __future__ import annotations
8
 
 
9
  import logging
10
  import threading
11
  from typing import Any
12
 
13
- from code.config.constants import MODEL_ID
14
 
15
  logger = logging.getLogger(__name__)
16
 
17
  # ─── Module-level state ─────────────────────────────────────────────────
18
 
 
19
  _model = None
20
- _tokenizer = None
21
  _model_loaded = False
22
  _model_loading = False
23
  _load_error: str | None = None
24
 
25
 
26
- def load_model() -> None:
27
- """Load MiniCPM5-1B model and tokenizer locally."""
28
- global _model, _tokenizer, _model_loaded, _model_loading, _load_error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- if _model_loaded or _model_loading:
 
 
 
 
 
 
 
 
 
 
 
31
  return
32
 
33
  _model_loading = True
34
  _load_error = None
35
 
 
 
 
 
 
 
 
 
 
36
  try:
37
- from transformers import AutoModelForCausalLM, AutoTokenizer
38
  import torch
39
 
40
- logger.info("Loading MiniCPM5-1B model...")
41
-
42
  dtype = torch.float16 if torch.cuda.is_available() else torch.float32
43
  device_map = "auto" if torch.cuda.is_available() else None
44
 
45
- _tokenizer = AutoTokenizer.from_pretrained(
46
- MODEL_ID,
47
- trust_remote_code=True,
48
- )
49
- _model = AutoModelForCausalLM.from_pretrained(
50
- MODEL_ID,
51
- torch_dtype=dtype,
52
- device_map=device_map,
53
- trust_remote_code=True,
54
- low_cpu_mem_usage=True,
55
- )
56
-
57
- if device_map is None:
58
- _model = _model.to("cpu")
59
-
60
- _model.eval()
61
  _model_loaded = True
62
- logger.info("MiniCPM5-1B model loaded successfully.")
63
 
64
  except Exception as exc:
65
  _load_error = str(exc)
66
- logger.exception("Failed to load model: %s", exc)
67
  finally:
68
  _model_loading = False
69
 
70
 
71
- def start_background_load() -> threading.Thread:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  """Start loading the model in a background daemon thread."""
73
- thread = threading.Thread(target=load_model, daemon=True)
74
  thread.start()
75
  return thread
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def get_model_status() -> dict[str, Any]:
79
  """Return current model loading status."""
 
80
  if _model_loaded:
81
- return {"status": "ready", "message": "Model loaded and ready"}
 
 
 
 
 
 
82
  if _model_loading:
83
- return {"status": "loading", "message": "Model is loading... (this may take a few minutes on first run)"}
 
 
 
 
 
 
84
  if _load_error:
85
- return {"status": "error", "message": f"Model load error: {_load_error}"}
86
- return {"status": "unknown", "message": "Model not initialized"}
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
 
89
  def get_model():
@@ -91,11 +234,21 @@ def get_model():
91
  return _model
92
 
93
 
94
- def get_tokenizer():
95
- """Return the loaded tokenizer instance (or None)."""
96
- return _tokenizer
97
 
98
 
99
  def is_model_loaded() -> bool:
100
  """Return True if the model has been loaded successfully."""
101
  return _model_loaded
 
 
 
 
 
 
 
 
 
 
 
1
  """Model loading and status management.
2
 
3
+ Supports two models:
4
+ - MiniCPM5-1B (text-only, fast)
5
+ - MiniCPM-V-4.6 (vision + text, image understanding)
6
+
7
+ Only one model is loaded at a time to conserve memory.
8
  The model is loaded in a background thread on startup.
9
  """
10
 
11
  from __future__ import annotations
12
 
13
+ import gc
14
  import logging
15
  import threading
16
  from typing import Any
17
 
18
+ from code.config.constants import DEFAULT_MODEL_KEY, MODEL_CONFIGS
19
 
20
  logger = logging.getLogger(__name__)
21
 
22
  # ─── Module-level state ─────────────────────────────────────────────────
23
 
24
+ _current_model_key: str = DEFAULT_MODEL_KEY
25
  _model = None
26
+ _tokenizer_or_processor = None
27
  _model_loaded = False
28
  _model_loading = False
29
  _load_error: str | None = None
30
 
31
 
32
+ def _unload_model() -> None:
33
+ """Unload current model and free memory."""
34
+ global _model, _tokenizer_or_processor, _model_loaded
35
+
36
+ if _model is not None:
37
+ del _model
38
+ _model = None
39
+ if _tokenizer_or_processor is not None:
40
+ del _tokenizer_or_processor
41
+ _tokenizer_or_processor = None
42
+
43
+ _model_loaded = False
44
+ gc.collect()
45
+
46
+ try:
47
+ import torch
48
+ if torch.cuda.is_available():
49
+ torch.cuda.empty_cache()
50
+ except ImportError:
51
+ pass
52
+
53
+
54
+ def load_model(model_key: str | None = None) -> None:
55
+ """Load a model by key. Unloads the previous model first."""
56
+ global _model, _tokenizer_or_processor, _model_loaded, _model_loading
57
+ global _load_error, _current_model_key
58
 
59
+ if model_key is None:
60
+ model_key = _current_model_key
61
+
62
+ if model_key not in MODEL_CONFIGS:
63
+ _load_error = f"Unknown model: {model_key}"
64
+ logger.error(_load_error)
65
+ return
66
+
67
+ # Skip if already loading or already loaded with same key
68
+ if _model_loading:
69
+ return
70
+ if _model_loaded and _current_model_key == model_key:
71
  return
72
 
73
  _model_loading = True
74
  _load_error = None
75
 
76
+ # Unload previous model if switching
77
+ if _model_loaded and _current_model_key != model_key:
78
+ logger.info("Switching model from %s to %s", _current_model_key, model_key)
79
+ _unload_model()
80
+
81
+ _current_model_key = model_key
82
+ config = MODEL_CONFIGS[model_key]
83
+ model_id = config["id"]
84
+
85
  try:
 
86
  import torch
87
 
 
 
88
  dtype = torch.float16 if torch.cuda.is_available() else torch.float32
89
  device_map = "auto" if torch.cuda.is_available() else None
90
 
91
+ if config["type"] == "vlm":
92
+ _load_vlm_model(model_id, dtype, device_map)
93
+ else:
94
+ _load_text_model(model_id, dtype, device_map)
95
+
 
 
 
 
 
 
 
 
 
 
 
96
  _model_loaded = True
97
+ logger.info("%s model loaded successfully.", config["name"])
98
 
99
  except Exception as exc:
100
  _load_error = str(exc)
101
+ logger.exception("Failed to load model %s: %s", model_id, exc)
102
  finally:
103
  _model_loading = False
104
 
105
 
106
+ def _load_text_model(model_id: str, dtype, device_map) -> None:
107
+ """Load a text-only model (AutoModelForCausalLM + AutoTokenizer)."""
108
+ global _model, _tokenizer_or_processor
109
+
110
+ from transformers import AutoModelForCausalLM, AutoTokenizer
111
+
112
+ logger.info("Loading %s (text model)...", model_id)
113
+
114
+ _tokenizer_or_processor = AutoTokenizer.from_pretrained(
115
+ model_id,
116
+ trust_remote_code=True,
117
+ )
118
+ _model = AutoModelForCausalLM.from_pretrained(
119
+ model_id,
120
+ torch_dtype=dtype,
121
+ device_map=device_map,
122
+ trust_remote_code=True,
123
+ low_cpu_mem_usage=True,
124
+ )
125
+
126
+ if device_map is None:
127
+ _model = _model.to("cpu")
128
+
129
+ _model.eval()
130
+
131
+
132
+ def _load_vlm_model(model_id: str, dtype, device_map) -> None:
133
+ """Load a vision-language model (AutoModelForImageTextToText + AutoProcessor)."""
134
+ global _model, _tokenizer_or_processor
135
+
136
+ try:
137
+ from transformers import AutoModelForImageTextToText, AutoProcessor
138
+ except ImportError:
139
+ # Fallback for older transformers
140
+ logger.warning("AutoModelForImageTextToText not found, trying AutoModel...")
141
+ from transformers import AutoModel as AutoModelForImageTextToText
142
+ from transformers import AutoProcessor
143
+
144
+ logger.info("Loading %s (VLM)...", model_id)
145
+
146
+ _tokenizer_or_processor = AutoProcessor.from_pretrained(
147
+ model_id,
148
+ trust_remote_code=True,
149
+ )
150
+ _model = AutoModelForImageTextToText.from_pretrained(
151
+ model_id,
152
+ torch_dtype=dtype,
153
+ device_map=device_map,
154
+ trust_remote_code=True,
155
+ low_cpu_mem_usage=True,
156
+ )
157
+
158
+ if device_map is None:
159
+ _model = _model.to("cpu")
160
+
161
+ _model.eval()
162
+
163
+
164
+ def start_background_load(model_key: str | None = None) -> threading.Thread:
165
  """Start loading the model in a background daemon thread."""
166
+ thread = threading.Thread(target=load_model, args=(model_key,), daemon=True)
167
  thread.start()
168
  return thread
169
 
170
 
171
+ def switch_model(model_key: str) -> dict[str, Any]:
172
+ """Switch to a different model. Returns status immediately, loads in background."""
173
+ global _current_model_key
174
+
175
+ if model_key not in MODEL_CONFIGS:
176
+ return {"success": False, "message": f"Unknown model: {model_key}"}
177
+
178
+ if _current_model_key == model_key and _model_loaded:
179
+ return {"success": True, "message": f"Already using {MODEL_CONFIGS[model_key]['name']}"}
180
+
181
+ _current_model_key = model_key
182
+ _model_loaded = False
183
+
184
+ # Start loading in background
185
+ start_background_load(model_key)
186
+
187
+ config = MODEL_CONFIGS[model_key]
188
+ return {
189
+ "success": True,
190
+ "message": f"Switching to {config['name']}...",
191
+ "model_key": model_key,
192
+ "model_name": config["name"],
193
+ }
194
+
195
+
196
  def get_model_status() -> dict[str, Any]:
197
  """Return current model loading status."""
198
+ config = MODEL_CONFIGS.get(_current_model_key, {})
199
  if _model_loaded:
200
+ return {
201
+ "status": "ready",
202
+ "message": f"{config.get('name', 'Model')} loaded and ready",
203
+ "model_key": _current_model_key,
204
+ "model_name": config.get("name", ""),
205
+ "model_type": config.get("type", "text"),
206
+ }
207
  if _model_loading:
208
+ return {
209
+ "status": "loading",
210
+ "message": f"Loading {config.get('name', 'model')}... (this may take a few minutes)",
211
+ "model_key": _current_model_key,
212
+ "model_name": config.get("name", ""),
213
+ "model_type": config.get("type", "text"),
214
+ }
215
  if _load_error:
216
+ return {
217
+ "status": "error",
218
+ "message": f"Model load error: {_load_error}",
219
+ "model_key": _current_model_key,
220
+ "model_name": config.get("name", ""),
221
+ "model_type": config.get("type", "text"),
222
+ }
223
+ return {
224
+ "status": "unknown",
225
+ "message": "Model not initialized",
226
+ "model_key": _current_model_key,
227
+ "model_name": config.get("name", ""),
228
+ "model_type": config.get("type", "text"),
229
+ }
230
 
231
 
232
  def get_model():
 
234
  return _model
235
 
236
 
237
+ def get_tokenizer_or_processor():
238
+ """Return the loaded tokenizer or processor (or None)."""
239
+ return _tokenizer_or_processor
240
 
241
 
242
  def is_model_loaded() -> bool:
243
  """Return True if the model has been loaded successfully."""
244
  return _model_loaded
245
+
246
+
247
+ def get_current_model_key() -> str:
248
+ """Return the key of the currently selected model."""
249
+ return _current_model_key
250
+
251
+
252
+ def get_current_model_type() -> str:
253
+ """Return 'text' or 'vlm' for the current model."""
254
+ return MODEL_CONFIGS.get(_current_model_key, {}).get("type", "text")
code/server/routes.py CHANGED
@@ -8,10 +8,13 @@ Defines all HTTP and API endpoints:
8
  - API web_search β†’ Google search scraping
9
  - API chat β†’ streaming chat with code execution
10
  - API push_hf β†’ push to HuggingFace Hub
 
 
11
  """
12
 
13
  from __future__ import annotations
14
 
 
15
  import json
16
  import logging
17
  import os
@@ -26,7 +29,7 @@ from code.config.constants import (
26
  APP_TITLE,
27
  EXAMPLE_PROMPTS,
28
  LANGUAGE_OPTIONS,
29
- MODEL_ID,
30
  MODEL_URL,
31
  PY_TIMEOUT_S,
32
  )
@@ -41,7 +44,13 @@ from code.execution.code_extractor import (
41
  from code.execution.gradio_runner import run_gradio_app, stop_gradio_app
42
  from code.execution.python_runner import run_python
43
  from code.huggingface.push import create_project_zip, push_to_huggingface
44
- from code.model.loader import get_model_status, is_model_loaded
 
 
 
 
 
 
45
  from code.model.inference import call_model
46
  from code.server.chat_helpers import chat_history_to_messages, targeted_prompt
47
  from code.websearch.google_scraper import web_search_google, format_search_results
@@ -52,6 +61,10 @@ logger = logging.getLogger(__name__)
52
 
53
  _served_files: dict[str, str] = {}
54
 
 
 
 
 
55
  # ─── Server Instance ────────────────────────────────────────────────────
56
 
57
  app = Server()
@@ -69,13 +82,14 @@ async def homepage():
69
 
70
  config = json.dumps({
71
  "app_title": APP_TITLE,
72
- "model_id": MODEL_ID,
73
  "model_url": MODEL_URL,
74
  "languages": LANGUAGE_OPTIONS,
75
  "examples": [
76
  {"label": label, "prompt": prompt, "language": lang, "framework": fw}
77
  for label, prompt, lang, fw in EXAMPLE_PROMPTS
78
  ],
 
79
  })
80
  content = content.replace("__RUNTIME_CONFIG__", config)
81
  return content
@@ -105,9 +119,77 @@ async def serve_download(filename: str):
105
  return HTMLResponse("Not found", status_code=404)
106
 
107
 
 
 
 
 
 
 
 
 
 
108
  # ─── Gradio API Endpoints ──────────────────────────────────────────────
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  @app.api(name="web_search", concurrency_limit=4)
112
  def handle_web_search(query: str) -> str:
113
  """Search the web using Google scraping. No API key needed."""
@@ -143,6 +225,7 @@ def handle_chat(
143
  history_json: str,
144
  exec_context_json: str,
145
  search_enabled: str = "false",
 
146
  ) -> str:
147
  """Stream chat responses with code execution. Yields JSON strings."""
148
  history = json.loads(history_json) if history_json else []
@@ -225,8 +308,11 @@ def handle_chat(
225
  }
226
  messages = chat_history_to_messages(model_history)
227
 
 
 
 
228
  final_response = ""
229
- for partial in call_model(messages):
230
  final_response = partial
231
  # Strip thinking blocks so chat only shows clean output
232
  clean_partial = strip_thinking_blocks(partial)
@@ -250,8 +336,7 @@ def handle_chat(
250
  })
251
  return
252
 
253
- # Extract code from response (use original with thinking for extraction,
254
- # but chat history already has clean version)
255
  clean_response = strip_thinking_blocks(final_response)
256
  code, fence_lang = extract_code(clean_response)
257
  target = normalize_language(target_language, fence_lang)
@@ -311,11 +396,11 @@ def handle_chat(
311
  status_state = "success"
312
 
313
  # Register image for serving
314
- image_url = None
315
  if image_path:
316
  filename = os.path.basename(image_path)
317
  _served_files[f"img:{filename}"] = image_path
318
- image_url = f"/images/{filename}"
319
 
320
  # Register code for download
321
  download_url = None
@@ -346,7 +431,7 @@ def handle_chat(
346
  "fence_lang": fence_lang or target,
347
  "stdout": stdout,
348
  "stderr": stderr,
349
- "image_url": image_url,
350
  "image_path": image_path,
351
  "status": status_text,
352
  "language": fence_lang or target,
 
8
  - API web_search β†’ Google search scraping
9
  - API chat β†’ streaming chat with code execution
10
  - API push_hf β†’ push to HuggingFace Hub
11
+ - API switch_model β†’ switch between loaded models
12
+ - API upload_image β†’ upload image for VLM inference
13
  """
14
 
15
  from __future__ import annotations
16
 
17
+ import base64
18
  import json
19
  import logging
20
  import os
 
29
  APP_TITLE,
30
  EXAMPLE_PROMPTS,
31
  LANGUAGE_OPTIONS,
32
+ MODEL_CONFIGS,
33
  MODEL_URL,
34
  PY_TIMEOUT_S,
35
  )
 
44
  from code.execution.gradio_runner import run_gradio_app, stop_gradio_app
45
  from code.execution.python_runner import run_python
46
  from code.huggingface.push import create_project_zip, push_to_huggingface
47
+ from code.model.loader import (
48
+ get_model_status,
49
+ is_model_loaded,
50
+ get_current_model_key,
51
+ get_current_model_type,
52
+ switch_model,
53
+ )
54
  from code.model.inference import call_model
55
  from code.server.chat_helpers import chat_history_to_messages, targeted_prompt
56
  from code.websearch.google_scraper import web_search_google, format_search_results
 
61
 
62
  _served_files: dict[str, str] = {}
63
 
64
+ # ─── Uploaded Images Registry ───────────────────────────────────────────
65
+
66
+ _uploaded_images: dict[str, str] = {}
67
+
68
  # ─── Server Instance ────────────────────────────────────────────────────
69
 
70
  app = Server()
 
82
 
83
  config = json.dumps({
84
  "app_title": APP_TITLE,
85
+ "model_id": MODEL_CONFIGS,
86
  "model_url": MODEL_URL,
87
  "languages": LANGUAGE_OPTIONS,
88
  "examples": [
89
  {"label": label, "prompt": prompt, "language": lang, "framework": fw}
90
  for label, prompt, lang, fw in EXAMPLE_PROMPTS
91
  ],
92
+ "default_model": "minicpm5-1b",
93
  })
94
  content = content.replace("__RUNTIME_CONFIG__", config)
95
  return content
 
119
  return HTMLResponse("Not found", status_code=404)
120
 
121
 
122
+ @app.get("/uploaded-images/{image_id}")
123
+ async def serve_uploaded_image(image_id: str):
124
+ """Serve an uploaded image by its ID."""
125
+ path = _uploaded_images.get(image_id)
126
+ if path and os.path.exists(path):
127
+ return FileResponse(path, media_type="image/png")
128
+ return HTMLResponse("Not found", status_code=404)
129
+
130
+
131
  # ─── Gradio API Endpoints ──────────────────────────────────────────────
132
 
133
 
134
+ @app.api(name="switch_model", concurrency_limit=1)
135
+ def handle_switch_model(model_key: str) -> str:
136
+ """Switch to a different model."""
137
+ result = switch_model(model_key)
138
+ yield json.dumps(result)
139
+
140
+
141
+ @app.api(name="upload_image", concurrency_limit=4)
142
+ def handle_upload_image(image_data: str) -> str:
143
+ """Upload a base64-encoded image for VLM inference.
144
+
145
+ Returns an image ID that can be referenced in chat.
146
+ """
147
+ try:
148
+ if not image_data:
149
+ yield json.dumps({"success": False, "message": "No image data provided"})
150
+ return
151
+
152
+ # Handle data URI format: data:image/png;base64,...
153
+ if image_data.startswith("data:"):
154
+ # Extract the base64 part
155
+ parts = image_data.split(",", 1)
156
+ if len(parts) == 2:
157
+ image_data = parts[1]
158
+
159
+ # Decode base64
160
+ image_bytes = base64.b64decode(image_data)
161
+
162
+ # Save to temp file
163
+ img_dir = tempfile.mkdtemp(prefix="uploaded_img_")
164
+ image_id = f"img_{os.getpid()}_{int(os.urandom(4).hex(), 16)}"
165
+ img_path = os.path.join(img_dir, f"{image_id}.png")
166
+ Path(img_path).write_bytes(image_bytes)
167
+
168
+ # Register for serving
169
+ _uploaded_images[image_id] = img_path
170
+
171
+ # Create a URL for the image that the VLM can access
172
+ image_url = f"/uploaded-images/{image_id}"
173
+
174
+ # Also save as a file:// URL for local VLM access
175
+ file_url = f"file://{img_path}"
176
+
177
+ yield json.dumps({
178
+ "success": True,
179
+ "image_id": image_id,
180
+ "image_url": image_url,
181
+ "file_url": file_url,
182
+ "message": "Image uploaded successfully",
183
+ })
184
+
185
+ except Exception as exc:
186
+ logger.exception("Image upload failed")
187
+ yield json.dumps({
188
+ "success": False,
189
+ "message": f"Upload failed: {str(exc)}",
190
+ })
191
+
192
+
193
  @app.api(name="web_search", concurrency_limit=4)
194
  def handle_web_search(query: str) -> str:
195
  """Search the web using Google scraping. No API key needed."""
 
225
  history_json: str,
226
  exec_context_json: str,
227
  search_enabled: str = "false",
228
+ image_url: str = "",
229
  ) -> str:
230
  """Stream chat responses with code execution. Yields JSON strings."""
231
  history = json.loads(history_json) if history_json else []
 
308
  }
309
  messages = chat_history_to_messages(model_history)
310
 
311
+ # Determine image URL for VLM
312
+ vlm_image_url = image_url.strip() if image_url else None
313
+
314
  final_response = ""
315
+ for partial in call_model(messages, image_url=vlm_image_url):
316
  final_response = partial
317
  # Strip thinking blocks so chat only shows clean output
318
  clean_partial = strip_thinking_blocks(partial)
 
336
  })
337
  return
338
 
339
+ # Extract code from response (use cleaned version)
 
340
  clean_response = strip_thinking_blocks(final_response)
341
  code, fence_lang = extract_code(clean_response)
342
  target = normalize_language(target_language, fence_lang)
 
396
  status_state = "success"
397
 
398
  # Register image for serving
399
+ image_url_out = None
400
  if image_path:
401
  filename = os.path.basename(image_path)
402
  _served_files[f"img:{filename}"] = image_path
403
+ image_url_out = f"/images/{filename}"
404
 
405
  # Register code for download
406
  download_url = None
 
431
  "fence_lang": fence_lang or target,
432
  "stdout": stdout,
433
  "stderr": stderr,
434
+ "image_url": image_url_out,
435
  "image_path": image_path,
436
  "status": status_text,
437
  "language": fence_lang or target,
index.html CHANGED
@@ -212,6 +212,30 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-cyan); }
212
  text-shadow: var(--glow-purple);
213
  }
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  /* ═══════════════════════════════════════════════════════
216
  BANNER
217
  ═══════════════════════════════════════════════════════ */
@@ -937,13 +961,17 @@ body.hide-thinking .think-block { display: none; }
937
  <header id="header">
938
  <div class="header-title">
939
  <div class="header-ascii">&#9556;&#9552;&#9552;&#9552; FULLSTACK CODE BUILDER &#9552;&#9552;&#9552;&#9562;</div>
940
- <div class="header-subtitle">Local AI App Generator | MiniCPM5-1B</div>
941
  </div>
942
  <div class="header-actions">
943
  <a class="pill" id="model-pill" href="#" target="_blank" rel="noopener">
944
  <span class="dot loading" id="model-dot"></span>
945
  <span id="model-pill-text">MiniCPM5-1B</span>
946
  </a>
 
 
 
 
947
  <button id="btn-thinking" class="btn-thinking active" onclick="toggleThinking()" title="Show/hide thinking blocks">🧠 Think</button>
948
  <button id="btn-new-chat" onclick="newChat()" title="Start a new chat session">[NEW]</button>
949
  </div>
@@ -969,6 +997,12 @@ body.hide-thinking .think-block { display: none; }
969
  <span class="selector-label">Framework:</span>
970
  <select id="framework-select"></select>
971
  </div>
 
 
 
 
 
 
972
  </div>
973
  <div id="input-row">
974
  <span class="input-prompt-symbol">&#10095;</span>
@@ -1123,6 +1157,10 @@ const state = {
1123
  searchEnabled: false,
1124
  lastSearchResults: [],
1125
  showThinking: true,
 
 
 
 
1126
  };
1127
 
1128
  // ═══════════════════════════════════════════════════════
@@ -1197,6 +1235,23 @@ async function pollModelStatus() {
1197
  statusText.textContent = 'MODEL READY';
1198
  indicator.className = 'status-indicator status-success';
1199
  document.getElementById('btn-push-hf').disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1200
  setTimeout(() => {
1201
  if (!state.isGenerating) {
1202
  indicator.className = 'status-indicator status-idle';
@@ -1793,7 +1848,7 @@ async function sendMessage(prompt) {
1793
  method: 'POST',
1794
  headers: { 'Content-Type': 'application/json' },
1795
  body: JSON.stringify({
1796
- data: [prompt, state.targetLanguage, framework, historyJSON, execContextJSON, state.searchEnabled ? 'true' : 'false']
1797
  })
1798
  });
1799
 
@@ -1951,6 +2006,143 @@ function toggleThinking() {
1951
  }
1952
  }
1953
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1954
  // ═══════════════════════════════════════════════════════
1955
  // WEB SEARCH
1956
  // ═══════════════════════════════════════════════════════
 
212
  text-shadow: var(--glow-purple);
213
  }
214
 
215
+ #model-select {
216
+ background: var(--bg-deep);
217
+ border: 1px solid var(--border);
218
+ color: var(--cyan);
219
+ font-family: var(--font-mono);
220
+ font-size: 11px;
221
+ padding: 5px 8px;
222
+ border-radius: var(--radius);
223
+ outline: none;
224
+ cursor: pointer;
225
+ transition: border-color var(--transition);
226
+ }
227
+ #model-select:focus { border-color: var(--border-focus); }
228
+ #model-select option { background: var(--bg-deep); color: var(--gray-light); }
229
+
230
+ #btn-attach-image {
231
+ background: transparent; border: 1px solid var(--border); color: var(--amber);
232
+ font-family: var(--font-mono); font-size: 14px; padding: 3px 8px;
233
+ border-radius: var(--radius); cursor: pointer; transition: all var(--transition);
234
+ }
235
+ #btn-attach-image:hover {
236
+ border-color: var(--amber); background: rgba(255,179,0,0.1);
237
+ }
238
+
239
  /* ═══════════════════════════════════════════════════════
240
  BANNER
241
  ═══════════════════════════════════════════════════════ */
 
961
  <header id="header">
962
  <div class="header-title">
963
  <div class="header-ascii">&#9556;&#9552;&#9552;&#9552; FULLSTACK CODE BUILDER &#9552;&#9552;&#9552;&#9562;</div>
964
+ <div class="header-subtitle">Local AI App Generator | <span id="header-model-name">MiniCPM5-1B</span></div>
965
  </div>
966
  <div class="header-actions">
967
  <a class="pill" id="model-pill" href="#" target="_blank" rel="noopener">
968
  <span class="dot loading" id="model-dot"></span>
969
  <span id="model-pill-text">MiniCPM5-1B</span>
970
  </a>
971
+ <select id="model-select" onchange="onModelChange()" title="Switch AI model">
972
+ <option value="minicpm5-1b">MiniCPM5-1B (text)</option>
973
+ <option value="minicpm-v-4.6">MiniCPM-V-4.6 (vision)</option>
974
+ </select>
975
  <button id="btn-thinking" class="btn-thinking active" onclick="toggleThinking()" title="Show/hide thinking blocks">🧠 Think</button>
976
  <button id="btn-new-chat" onclick="newChat()" title="Start a new chat session">[NEW]</button>
977
  </div>
 
997
  <span class="selector-label">Framework:</span>
998
  <select id="framework-select"></select>
999
  </div>
1000
+ <div class="selector-group" id="image-attach-group" style="display:none;">
1001
+ <input type="file" id="image-upload" accept="image/*" style="display:none" onchange="onImageUpload(event)">
1002
+ <button id="btn-attach-image" onclick="document.getElementById('image-upload').click()" title="Attach image (VLM only)">πŸ“·</button>
1003
+ <span id="image-attach-name" style="font-size:10px;color:var(--gray-dim);max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
1004
+ <button id="btn-remove-image" onclick="removeImage()" title="Remove image" style="display:none;font-size:10px;color:var(--red);background:none;border:none;cursor:pointer;">βœ•</button>
1005
+ </div>
1006
  </div>
1007
  <div id="input-row">
1008
  <span class="input-prompt-symbol">&#10095;</span>
 
1157
  searchEnabled: false,
1158
  lastSearchResults: [],
1159
  showThinking: true,
1160
+ currentModelKey: 'minicpm5-1b',
1161
+ currentModelType: 'text',
1162
+ uploadedImageFileUrl: '',
1163
+ uploadedImageName: '',
1164
  };
1165
 
1166
  // ═══════════════════════════════════════════════════════
 
1235
  statusText.textContent = 'MODEL READY';
1236
  indicator.className = 'status-indicator status-success';
1237
  document.getElementById('btn-push-hf').disabled = false;
1238
+ // Update model info from server response
1239
+ if (data.model_key) state.currentModelKey = data.model_key;
1240
+ if (data.model_type) state.currentModelType = data.model_type;
1241
+ if (data.model_name) {
1242
+ document.getElementById('model-pill-text').textContent = data.model_name;
1243
+ document.getElementById('header-model-name').textContent = data.model_name;
1244
+ }
1245
+ // Show/hide image upload based on model type
1246
+ const imageGroup = document.getElementById('image-attach-group');
1247
+ if (state.currentModelType === 'vlm') {
1248
+ imageGroup.style.display = 'flex';
1249
+ } else {
1250
+ imageGroup.style.display = 'none';
1251
+ }
1252
+ // Sync model selector
1253
+ const modelSelect = document.getElementById('model-select');
1254
+ if (modelSelect && data.model_key) modelSelect.value = data.model_key;
1255
  setTimeout(() => {
1256
  if (!state.isGenerating) {
1257
  indicator.className = 'status-indicator status-idle';
 
1848
  method: 'POST',
1849
  headers: { 'Content-Type': 'application/json' },
1850
  body: JSON.stringify({
1851
+ data: [prompt, state.targetLanguage, framework, historyJSON, execContextJSON, state.searchEnabled ? 'true' : 'false', state.uploadedImageFileUrl || '']
1852
  })
1853
  });
1854
 
 
2006
  }
2007
  }
2008
 
2009
+ // ═══════════════════════════════════════════════════════
2010
+ // MODEL SWITCHING
2011
+ // ═══════════════════════════════════════════════════════
2012
+ async function onModelChange() {
2013
+ const select = document.getElementById('model-select');
2014
+ const modelKey = select.value;
2015
+ if (modelKey === state.currentModelKey) return;
2016
+
2017
+ const isVLM = modelKey === 'minicpm-v-4.6';
2018
+ const imageGroup = document.getElementById('image-attach-group');
2019
+ if (isVLM) {
2020
+ imageGroup.style.display = 'flex';
2021
+ } else {
2022
+ imageGroup.style.display = 'none';
2023
+ removeImage();
2024
+ }
2025
+
2026
+ addSystemMessage(`Switching model to ${select.options[select.selectedIndex].text}...`);
2027
+ renderStatus('Switching model...', 'working');
2028
+
2029
+ try {
2030
+ const resp = await fetch('/gradio_api/call/switch_model', {
2031
+ method: 'POST',
2032
+ headers: { 'Content-Type': 'application/json' },
2033
+ body: JSON.stringify({ data: [modelKey] })
2034
+ });
2035
+ const { event_id } = await resp.json();
2036
+ const eventSource = new EventSource(`/gradio_api/call/switch_model/${event_id}`);
2037
+
2038
+ eventSource.addEventListener('complete', (e) => {
2039
+ const dataArray = JSON.parse(e.data);
2040
+ const result = JSON.parse(dataArray[0]);
2041
+ if (result.success) {
2042
+ state.currentModelKey = modelKey;
2043
+ state.currentModelType = isVLM ? 'vlm' : 'text';
2044
+ const name = isVLM ? 'MiniCPM-V-4.6' : 'MiniCPM5-1B';
2045
+ document.getElementById('model-pill-text').textContent = name;
2046
+ document.getElementById('header-model-name').textContent = name;
2047
+ addSystemMessage(`Switched to ${name}. Model is loading in background...`);
2048
+ // Poll for model ready
2049
+ pollModelStatus();
2050
+ } else {
2051
+ addSystemMessage(`Failed to switch: ${result.message}`);
2052
+ select.value = state.currentModelKey;
2053
+ }
2054
+ eventSource.close();
2055
+ });
2056
+ eventSource.addEventListener('error', () => {
2057
+ addSystemMessage('Model switch failed');
2058
+ select.value = state.currentModelKey;
2059
+ eventSource.close();
2060
+ });
2061
+ } catch (err) {
2062
+ addSystemMessage(`Switch error: ${err.message}`);
2063
+ select.value = state.currentModelKey;
2064
+ }
2065
+ }
2066
+
2067
+ function pollModelStatus() {
2068
+ const interval = setInterval(async () => {
2069
+ try {
2070
+ const resp = await fetch('/api/model-status');
2071
+ const status = await resp.json();
2072
+ if (status.status === 'ready') {
2073
+ state.modelReady = true;
2074
+ const dot = document.getElementById('model-dot');
2075
+ dot.className = 'dot';
2076
+ dot.style.background = 'var(--success)';
2077
+ dot.style.boxShadow = '0 0 6px var(--success)';
2078
+ renderStatus('Ready', 'success');
2079
+ setTimeout(() => renderStatus('Idle', 'idle'), 2000);
2080
+ clearInterval(interval);
2081
+ } else if (status.status === 'error') {
2082
+ state.modelReady = false;
2083
+ renderStatus('Model error', 'error');
2084
+ clearInterval(interval);
2085
+ }
2086
+ } catch { clearInterval(interval); }
2087
+ }, 2000);
2088
+ }
2089
+
2090
+ // ═══════════════════════════════════════════════════════
2091
+ // IMAGE UPLOAD (VLM)
2092
+ // ═══════════════════════════════════════════════════════
2093
+ async function onImageUpload(event) {
2094
+ const file = event.target.files[0];
2095
+ if (!file) return;
2096
+
2097
+ state.uploadedImageName = file.name;
2098
+ document.getElementById('image-attach-name').textContent = file.name;
2099
+ document.getElementById('btn-remove-image').style.display = 'inline';
2100
+
2101
+ // Convert to base64
2102
+ const reader = new FileReader();
2103
+ reader.onload = async function(e) {
2104
+ const base64Data = e.target.result;
2105
+
2106
+ // Upload to server
2107
+ try {
2108
+ const resp = await fetch('/gradio_api/call/upload_image', {
2109
+ method: 'POST',
2110
+ headers: { 'Content-Type': 'application/json' },
2111
+ body: JSON.stringify({ data: [base64Data] })
2112
+ });
2113
+ const { event_id } = await resp.json();
2114
+ const eventSource = new EventSource(`/gradio_api/call/upload_image/${event_id}`);
2115
+
2116
+ eventSource.addEventListener('complete', (ev) => {
2117
+ const dataArray = JSON.parse(ev.data);
2118
+ const result = JSON.parse(dataArray[0]);
2119
+ if (result.success) {
2120
+ state.uploadedImageFileUrl = result.file_url;
2121
+ document.getElementById('image-attach-name').textContent = 'βœ“ ' + file.name;
2122
+ } else {
2123
+ document.getElementById('image-attach-name').textContent = 'βœ— Upload failed';
2124
+ }
2125
+ eventSource.close();
2126
+ });
2127
+ eventSource.addEventListener('error', () => {
2128
+ document.getElementById('image-attach-name').textContent = 'βœ— Upload error';
2129
+ eventSource.close();
2130
+ });
2131
+ } catch (err) {
2132
+ document.getElementById('image-attach-name').textContent = 'βœ— Error';
2133
+ }
2134
+ };
2135
+ reader.readAsDataURL(file);
2136
+ }
2137
+
2138
+ function removeImage() {
2139
+ state.uploadedImageFileUrl = '';
2140
+ state.uploadedImageName = '';
2141
+ document.getElementById('image-upload').value = '';
2142
+ document.getElementById('image-attach-name').textContent = '';
2143
+ document.getElementById('btn-remove-image').style.display = 'none';
2144
+ }
2145
+
2146
  // ═══════════════════════════════════════════════════════
2147
  // WEB SEARCH
2148
  // ═══════════════════════════════════════════════════════
requirements.txt CHANGED
@@ -7,3 +7,4 @@ matplotlib>=3.8
7
  requests>=2.31.0
8
  beautifulsoup4>=4.12.0
9
  Pillow>=10.0
 
 
7
  requests>=2.31.0
8
  beautifulsoup4>=4.12.0
9
  Pillow>=10.0
10
+ torchvision>=0.16.0