Spaces:
Sleeping
Sleeping
| import os | |
| import gradio as gr | |
| from llama_cpp import Llama | |
| from huggingface_hub import list_bucket_tree, download_bucket_files | |
| MODEL_DIR = "./model_files" | |
| from huggingface_hub import snapshot_download | |
| def download_phi3_vision_model(): | |
| repo_id = "microsoft/Phi-3.5-vision-instruct-onnx" | |
| target_subdir = "cpu_and_mobile/cpu-int4-rtn-block-32-acc-level-4" | |
| local_dir = os.path.join(MODEL_DIR, target_subdir) | |
| print(f"[startup] Checking model files in repo {repo_id}...", flush=True) | |
| try: | |
| snapshot_download( | |
| repo_id=repo_id, | |
| allow_patterns=[f"{target_subdir}/*"], | |
| local_dir=MODEL_DIR, | |
| ) | |
| print("[startup] Download/cache check complete.", flush=True) | |
| except Exception as e: | |
| print(f"[startup] Error checking or downloading model: {e}", flush=True) | |
| raise e | |
| return local_dir | |
| _phi_model = None | |
| _phi_processor = None | |
| _phi_tokenizer = None | |
| def _load_phi_vision(): | |
| global _phi_model, _phi_processor, _phi_tokenizer | |
| if _phi_model is None: | |
| local_dir = download_phi3_vision_model() | |
| print(f"[startup] Loading Phi-3-Vision ONNX model from {local_dir}...", flush=True) | |
| import onnxruntime_genai as og | |
| _phi_model = og.Model(local_dir) | |
| _phi_processor = _phi_model.create_multimodal_processor() | |
| _phi_tokenizer = og.Tokenizer(_phi_model) | |
| print("[startup] Phi-3-Vision ONNX model loaded.", flush=True) | |
| return _phi_model, _phi_processor, _phi_tokenizer | |
| def _clean_history_for_qwen(history): | |
| clean = [] | |
| for turn in history: | |
| role = turn["role"] | |
| content = turn["content"] | |
| if isinstance(content, dict): | |
| text = content.get("text", "") | |
| clean.append({"role": role, "content": text}) | |
| else: | |
| clean.append({"role": role, "content": str(content)}) | |
| return clean | |
| # --------------------------------------------------------------------------- | |
| # Local reasoning models (Qwen3.5, quantized, via llama-cpp-python) | |
| # --------------------------------------------------------------------------- | |
| # "fast" is the default for everyday chat; "deep" trades latency for | |
| # noticeably stronger reasoning (e.g. UPSC GS-style analysis) on the same | |
| # 2 vCPU / no-GPU hardware β pick it per-message via the Response Mode radio. | |
| LLM_VARIANTS = { | |
| "fast": {"repo_id": "unsloth/Qwen3.5-4B-GGUF", "filename": "Qwen3.5-4B-Q4_K_M.gguf"}, | |
| "deep": {"repo_id": "unsloth/Qwen3.5-9B-GGUF", "filename": "Qwen3.5-9B-Q4_K_M.gguf"}, | |
| } | |
| DEFAULT_SYSTEM_PROMPT = "You are a helpful, knowledgeable assistant." | |
| _llms: dict[str, Llama] = {} | |
| def _load_llm(variant: str) -> Llama: | |
| """Download (cached by huggingface_hub) and load a variant, once each.""" | |
| if variant not in _llms: | |
| cfg = LLM_VARIANTS[variant] | |
| print(f"[startup] Loading {variant} model: {cfg['repo_id']}/{cfg['filename']} ...", flush=True) | |
| _llms[variant] = Llama.from_pretrained( | |
| repo_id=cfg["repo_id"], | |
| filename=cfg["filename"], | |
| n_ctx=8192, | |
| n_threads=os.cpu_count() or 2, | |
| verbose=False, | |
| ) | |
| print(f"[startup] {variant} model loaded.", flush=True) | |
| return _llms[variant] | |
| def _build_chatml_prompt(messages: list[dict]) -> str: | |
| """Manually render ChatML with an empty, pre-closed <think> block on the | |
| assistant turn, so the model never starts its own reasoning trace. | |
| Workaround for a currently-open llama.cpp bug where `enable_thinking: | |
| false` is silently ignored for Qwen3.5, causing every reply to pay for a | |
| full (often long) hidden reasoning trace even for trivial messages. | |
| See: https://github.com/ggml-org/llama.cpp/issues/20182 | |
| """ | |
| parts = [f"<|im_start|>{m['role']}\n{m['content']}<|im_end|>\n" for m in messages] | |
| parts.append("<|im_start|>assistant\n<think>\n\n</think>\n\n") | |
| return "".join(parts) | |
| def respond(message, history, system_prompt, max_tokens, temperature, disable_thinking, mode): | |
| """Streaming chat callback for gr.ChatInterface (type='messages').""" | |
| if mode == "vision": | |
| # Load Phi-3-Vision | |
| model, processor, tokenizer = _load_phi_vision() | |
| import onnxruntime_genai as og | |
| user_text = message.get("text", "") if isinstance(message, dict) else str(message) | |
| user_files = message.get("files", []) if isinstance(message, dict) else [] | |
| # Format prompt and collect images | |
| prompt_parts = [] | |
| if system_prompt.strip(): | |
| prompt_parts.append(f"<|system|>\n{system_prompt.strip()}<|end|>\n") | |
| image_counter = 1 | |
| image_paths = [] | |
| for turn in history: | |
| role = turn["role"] | |
| content = turn["content"] | |
| if role == "user": | |
| if isinstance(content, dict): | |
| text = content.get("text", "") | |
| files = content.get("files", []) | |
| else: | |
| text = str(content) | |
| files = [] | |
| img_tags = "" | |
| for f in files: | |
| if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp")): | |
| img_tags += f"<|image_{image_counter}|>\n" | |
| image_paths.append(f) | |
| image_counter += 1 | |
| prompt_parts.append(f"<|user|>\n{img_tags}{text}<|end|>\n") | |
| elif role == "assistant": | |
| prompt_parts.append(f"<|assistant|>\n{content}<|end|>\n") | |
| # Current message | |
| img_tags = "" | |
| for f in user_files: | |
| if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp")): | |
| img_tags += f"<|image_{image_counter}|>\n" | |
| image_paths.append(f) | |
| image_counter += 1 | |
| prompt_parts.append(f"<|user|>\n{img_tags}{user_text}<|end|>\n<|assistant|>\n") | |
| prompt = "".join(prompt_parts) | |
| try: | |
| # Prepare inputs | |
| if image_paths: | |
| print(f"[inference] Processing prompt with {len(image_paths)} images...", flush=True) | |
| images = og.Images.open(*image_paths) | |
| inputs = processor(prompt, images=images) | |
| else: | |
| print("[inference] Processing text-only prompt for Phi-3...", flush=True) | |
| try: | |
| inputs = processor(prompt) | |
| except Exception: | |
| inputs = tokenizer.encode(prompt) | |
| # Set up generator parameters | |
| params = og.GeneratorParams(model) | |
| params.set_search_options( | |
| max_length=8192, | |
| temperature=temperature | |
| ) | |
| # Stream output | |
| generator = og.Generator(model, params) | |
| # Pass inputs | |
| if type(inputs).__name__ == "NamedTensors": | |
| generator.set_inputs(inputs) | |
| else: | |
| import numpy as np | |
| generator.append_tokens(np.array(inputs, dtype=np.int32)) | |
| partial = "" | |
| max_gen_tokens = int(max_tokens) | |
| tokens_generated = 0 | |
| while not generator.is_done() and tokens_generated < max_gen_tokens: | |
| generator.generate_next_token() | |
| new_token = generator.get_next_tokens()[0] | |
| decoded = tokenizer.decode([new_token]) | |
| partial += decoded | |
| yield partial | |
| tokens_generated += 1 | |
| except Exception as e: | |
| print(f"[inference] Error during Phi-3 generation: {e}", flush=True) | |
| yield f"β οΈ **Error during model inference**: {str(e)}\n\n*This was caught gracefully to prevent the Space from crashing. Please try again or rephrase.*" | |
| else: | |
| # Qwen modes | |
| llm = _load_llm(mode) | |
| messages = [{"role": "system", "content": system_prompt.strip() or DEFAULT_SYSTEM_PROMPT}] | |
| messages.extend(_clean_history_for_qwen(history)) | |
| user_text = message.get("text", "") if isinstance(message, dict) else str(message) | |
| messages.append({"role": "user", "content": user_text}) | |
| partial = "" | |
| if disable_thinking: | |
| # Raw completion on a hand-built prompt (empty-think prefill trick), | |
| # since create_chat_completion's enable_thinking kwarg is a no-op here. | |
| stream = llm.create_completion( | |
| prompt=_build_chatml_prompt(messages), | |
| max_tokens=int(max_tokens), | |
| temperature=temperature, | |
| stop=["<|im_end|>", "<|im_start|>"], | |
| stream=True, | |
| ) | |
| for chunk in stream: | |
| delta = chunk["choices"][0]["text"] | |
| if delta: | |
| partial += delta | |
| yield partial | |
| else: | |
| for chunk in llm.create_chat_completion( | |
| messages=messages, | |
| max_tokens=int(max_tokens), | |
| temperature=temperature, | |
| stream=True, | |
| ): | |
| delta = chunk["choices"][0]["delta"].get("content", "") | |
| if delta: | |
| partial += delta | |
| yield partial | |
| # --------------------------------------------------------------------------- | |
| # UI | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(theme=gr.themes.Soft()) as demo: | |
| gr.Markdown("# π¬ Local Reasoning Chat") | |
| gr.Markdown( | |
| "General-purpose chat backed by quantized **Qwen3.5** models and **Phi-3-Vision**. " | |
| "Served locally via `llama-cpp-python` and `onnxruntime-genai` (CPU-only).\n\n" | |
| "**Image Uploading (Text Extraction)**: To ask the model to extract text or analyze an image, " | |
| "make sure you select the **Vision** mode below, then click the **+ (attachment icon)** " | |
| "inside the chat input box to upload your image." | |
| ) | |
| with gr.Accordion("βοΈ Settings", open=True): | |
| mode_box = gr.Radio( | |
| choices=[ | |
| ("β‘ Fast β Qwen3.5-4B (everyday chat)", "fast"), | |
| ("π§ Deep reasoning β Qwen3.5-9B (slower, for UPSC-depth analysis)", "deep"), | |
| ("ποΈ Vision β Phi-3-Vision (ONNX, supports text + images)", "vision"), | |
| ], | |
| value="fast", | |
| label="Response Mode", | |
| ) | |
| system_prompt_box = gr.Textbox( | |
| label="System Prompt", | |
| value=DEFAULT_SYSTEM_PROMPT, | |
| lines=4, | |
| ) | |
| max_tokens_box = gr.Slider( | |
| label="Max response tokens", | |
| minimum=128, maximum=4096, value=1024, step=128, | |
| ) | |
| temperature_box = gr.Slider( | |
| label="Temperature", | |
| minimum=0.0, maximum=1.5, value=0.7, step=0.1, | |
| ) | |
| disable_thinking_box = gr.Checkbox( | |
| label="Disable thinking (faster replies β works around a known llama.cpp/Qwen3.5 bug)", | |
| value=True, | |
| ) | |
| gr.ChatInterface( | |
| fn=respond, | |
| additional_inputs=[ | |
| system_prompt_box, max_tokens_box, temperature_box, disable_thinking_box, mode_box, | |
| ], | |
| type="messages", | |
| multimodal=True, | |
| examples=[ | |
| [{"text": "Extract all text from this image exactly as written.", "files": []}, "You are a helpful, knowledgeable assistant.", 1024, 0.7, True, "vision"], | |
| [{"text": "Describe the contents of this image in detail.", "files": []}, "You are a helpful, knowledgeable assistant.", 1024, 0.7, True, "vision"] | |
| ] | |
| ) | |
| if __name__ == "__main__": | |
| # Pre-load the default ("fast") model now, so the cold download/load cost | |
| # is paid once during Space startup (visible in logs) instead of hanging | |
| # a user's first chat message with no feedback. "deep" stays lazy-loaded | |
| # on first use since picking it is an explicit opt-in to wait longer. | |
| _load_llm("fast") | |
| # Also pre-download Phi-3-Vision model files so they are cached during startup/build phase | |
| try: | |
| download_phi3_vision_model() | |
| except Exception as e: | |
| print(f"[startup] Failed to pre-download Phi-3-Vision model files: {e}", flush=True) | |
| demo.launch() | |