| """ |
| Qwen3.6-27B MTP TQ3_4S — Custom HTML chat server for Hugging Face Spaces. |
| Free CPU tier (2 vCPUs, 16 GB RAM). |
| |
| Architecture: |
| - FastAPI server on port 7860 |
| - GET / → HTML chat interface |
| - POST /api/chat → SSE stream of reasoning + response tokens + live metrics |
| - No Gradio — pure HTML/CSS/JS frontend |
| |
| Reasoning (Qwen3 thinking mode): |
| The model's embedded chat template defaults to thinking ENABLED. We don't |
| pass chat_template_kwargs (not supported in llama-cpp-python 0.3.32). |
| The model emits reasoning tokens in delta.reasoning_content automatically. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import json |
| import os |
| import re |
| import time |
| import traceback |
| import uuid |
| from threading import Lock |
| from typing import Iterator, List |
|
|
| import uvicorn |
| from fastapi import FastAPI, Request |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import HTMLResponse, JSONResponse |
| from huggingface_hub import hf_hub_download |
| from llama_cpp import Llama |
| from sse_starlette.sse import EventSourceResponse |
|
|
| |
| |
| |
| REPO_ID = "openbmb/MiniCPM5-1B-GGUF" |
| FILENAME = "MiniCPM5-1B-Q8_0.gguf" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| N_THREADS = 2 |
| N_CTX = 102400 |
| N_BATCH = 512 |
| KV_CACHE_DTYPE = "q8_0" |
| N_GPU_LAYERS = 0 |
|
|
| |
| |
| |
| MAX_TOKENS_DEFAULT = 4096 |
| TEMPERATURE_DEFAULT = 0.7 |
| TOP_P_DEFAULT = 0.9 |
| REPEAT_PENALTY_DEFAULT = 1.05 |
|
|
| PORT = int(os.environ.get("PORT", 7860)) |
|
|
| SYSTEM_PROMPT_DEFAULT = ( |
| "You are MiniCPM5, a helpful and concise assistant. " |
| "Answer in clear, well-structured prose." |
| ) |
|
|
| |
| |
| CHAT_TEMPLATE = ( |
| "{% for message in messages %}" |
| "{% if message['role'] == 'system' %}" |
| "<|im_start|>system\n{{ message['content'] }}<|im_end|>\n" |
| "{% elif message['role'] == 'user' %}" |
| "<|im_start|>user\n{{ message['content'] }}<|im_end|>\n" |
| "{% elif message['role'] == 'assistant' %}" |
| "<|im_start|>assistant\n{{ message['content'] }}<|im_end|>\n" |
| "{% endif %}" |
| "{% endfor %}" |
| "{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" |
| ) |
|
|
| |
| |
| |
| def _parse_quant(filename: str) -> str: |
| m = re.search(r"(Q[0-9]+_[A-Z]+|IQ[0-9]+_[A-Z]+|TQ[0-9]+_[A-Z0-9]+|F16|F32)", filename, re.IGNORECASE) |
| return m.group(1).upper() if m else "unknown" |
|
|
|
|
| |
| |
| |
| print(f"[boot] Downloading {FILENAME} from {REPO_ID} ...", flush=True) |
| model_path = hf_hub_download( |
| repo_id=REPO_ID, |
| filename=FILENAME, |
| repo_type="model", |
| ) |
| print(f"[boot] Model cached at: {model_path}", flush=True) |
|
|
| model_size_mb = os.path.getsize(model_path) / (1024 * 1024) |
| quant_label = _parse_quant(FILENAME) |
| print(f"[boot] Model file size: {model_size_mb:.1f} MB ({quant_label})", flush=True) |
| if model_size_mb < 100: |
| raise RuntimeError( |
| f"Model file is suspiciously small ({model_size_mb:.1f} MB). " |
| f"Try clearing the HF cache and restarting." |
| ) |
|
|
| import llama_cpp as _llama_cpp_mod |
| print(f"[boot] llama-cpp-python version: {_llama_cpp_mod.__version__}", flush=True) |
|
|
| print("[boot] Loading Llama model (this can take 1-2 minutes) ...", flush=True) |
|
|
| LOAD_ATTEMPTS = [ |
| |
| |
| |
| ("q8_0 KV cache, model's embedded template", dict( |
| kv_cache_dtype=KV_CACHE_DTYPE, |
| )), |
| ("fp16 KV cache, model's embedded template", dict( |
| kv_cache_dtype="fp16", |
| )), |
| ("bare minimum (all defaults)", dict()), |
| ] |
|
|
| llm = None |
| last_error = None |
| for label, extra_kwargs in LOAD_ATTEMPTS: |
| print(f"[boot] Attempt: {label}", flush=True) |
| try: |
| llm = Llama( |
| model_path=model_path, |
| n_threads=N_THREADS, |
| n_ctx=N_CTX, |
| n_batch=N_BATCH, |
| n_gpu_layers=N_GPU_LAYERS, |
| use_mlock=False, |
| use_mmap=False, |
| |
| |
| verbose=False, |
| |
| **extra_kwargs, |
| ) |
| print(f"[boot] SUCCESS with: {label}", flush=True) |
| break |
| except Exception as exc: |
| last_error = exc |
| print(f"[boot] FAILED with: {label}", flush=True) |
| print(f"[boot] error: {exc}", flush=True) |
| print("-" * 70, flush=True) |
| llm = None |
| continue |
|
|
| if llm is None: |
| raise RuntimeError(f"All model load attempts failed. Last error: {last_error}") |
|
|
| print("[boot] Model ready. Starting FastAPI server ...", flush=True) |
|
|
| LLM_LOCK = Lock() |
|
|
|
|
| |
| |
| |
| app = FastAPI(title="Qwen3.6-27B TQ3_4S Chat") |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| MODEL_ID = FILENAME.replace(".gguf", "") |
|
|
|
|
| def _build_messages(history: List[dict], system_prompt: str) -> List[dict]: |
| """ |
| Build the messages list for create_chat_completion. |
| |
| The HTML frontend sends the full conversation history (including the |
| latest user message) via the `history` field. We prepend a system |
| message if one isn't already present in the history. |
| |
| The Qwen3.6 chat template requires at least one user message — |
| otherwise it raises "No user query found in messages." |
| """ |
| messages: List[dict] = [] |
|
|
| |
| has_system = ( |
| len(history) > 0 |
| and isinstance(history[0], dict) |
| and history[0].get("role") == "system" |
| ) |
|
|
| if not has_system: |
| messages.append({ |
| "role": "system", |
| "content": system_prompt or SYSTEM_PROMPT_DEFAULT, |
| }) |
|
|
| for msg in history: |
| if isinstance(msg, dict) and msg.get("role") and msg.get("content") is not None: |
| messages.append({ |
| "role": msg["role"], |
| "content": msg["content"], |
| }) |
|
|
| |
| |
| |
| has_user = any(m.get("role") == "user" for m in messages) |
| if not has_user: |
| print(f"[warn] _build_messages: no user message in history " |
| f"(roles: {[m['role'] for m in messages]})", flush=True) |
|
|
| return messages |
|
|
|
|
| def _run_llm_stream( |
| messages: List[dict], |
| temperature: float, |
| top_p: float, |
| max_tokens: int, |
| repeat_penalty: float, |
| queue: asyncio.Queue, |
| loop: asyncio.AbstractEventLoop, |
| ) -> None: |
| """ |
| Synchronous worker that runs llama-cpp-python's blocking create_chat_completion |
| in a background thread. Pushes events to the asyncio queue so the async SSE |
| generator can yield them immediately without blocking the event loop. |
| |
| This is CRITICAL for streaming — if we ran create_chat_completion directly |
| in the async path, each token would block the event loop and the SSE |
| response wouldn't flush until the entire generation finished. |
| """ |
| def _put(event): |
| |
| loop.call_soon_threadsafe(queue.put_nowait, event) |
|
|
| try: |
| _put({"type": "status", "stage": "processing_prompt"}) |
|
|
| state = { |
| "prompt_start": time.perf_counter(), |
| "first_token": None, |
| "first_reasoning_token": None, |
| "first_response_token": None, |
| "last_reasoning_token": None, |
| "last_response_token": None, |
| "last_token": None, |
| "reasoning_tokens": 0, |
| "response_tokens": 0, |
| } |
|
|
| def _tps(first, last, count): |
| if first is None or last is None or count == 0: |
| return 0.0 |
| elapsed = last - first |
| return count / elapsed if elapsed > 0 else 0.0 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| think_state = "buffering" |
| content_buffer = "" |
|
|
| with LLM_LOCK: |
| stream = llm.create_chat_completion( |
| messages=messages, |
| max_tokens=int(max_tokens), |
| temperature=float(temperature), |
| top_p=float(top_p), |
| repeat_penalty=float(repeat_penalty), |
| stream=True, |
| ) |
| for chunk in stream: |
| delta = (chunk.get("choices") or [{}])[0].get("delta", {}) or {} |
| now = time.perf_counter() |
|
|
| |
| reasoning_token = delta.get("reasoning_content") |
| if reasoning_token: |
| if state["first_token"] is None: |
| state["first_token"] = now |
| if state["first_reasoning_token"] is None: |
| state["first_reasoning_token"] = now |
| state["last_reasoning_token"] = now |
| state["last_token"] = now |
| state["reasoning_tokens"] += 1 |
| _put({ |
| "type": "reasoning", |
| "token": reasoning_token, |
| "count": state["reasoning_tokens"], |
| "tps": _tps( |
| state["first_reasoning_token"], |
| state["last_reasoning_token"], |
| state["reasoning_tokens"], |
| ), |
| }) |
| continue |
|
|
| |
| token = delta.get("content") |
| if not token: |
| continue |
|
|
| |
| content_buffer += token |
|
|
| while content_buffer: |
| if think_state == "buffering": |
| |
| if "<think>" in content_buffer: |
| |
| idx = content_buffer.index("<think>") |
| if idx > 0: |
| pre = content_buffer[:idx] |
| if state["first_token"] is None: |
| state["first_token"] = now |
| if state["first_response_token"] is None: |
| state["first_response_token"] = now |
| state["last_response_token"] = now |
| state["last_token"] = now |
| state["response_tokens"] += 1 |
| _put({ |
| "type": "response", |
| "token": pre, |
| "count": state["response_tokens"], |
| "tps": _tps( |
| state["first_response_token"], |
| state["last_response_token"], |
| state["response_tokens"], |
| ), |
| }) |
| content_buffer = content_buffer[idx + 7:] |
| think_state = "thinking" |
| if state["first_token"] is None: |
| state["first_token"] = now |
| if state["first_reasoning_token"] is None: |
| state["first_reasoning_token"] = now |
| state["last_reasoning_token"] = now |
| state["last_token"] = now |
| state["reasoning_tokens"] += 1 |
| _put({ |
| "type": "reasoning", |
| "token": "", |
| "count": state["reasoning_tokens"], |
| "tps": _tps( |
| state["first_reasoning_token"], |
| state["last_reasoning_token"], |
| state["reasoning_tokens"], |
| ), |
| }) |
| elif len(content_buffer) > 7: |
| |
| |
| safe = content_buffer[:-7] |
| content_buffer = content_buffer[-7:] |
| if safe and state["first_token"] is None: |
| state["first_token"] = now |
| if safe and state["first_response_token"] is None: |
| state["first_response_token"] = now |
| if safe: |
| state["last_response_token"] = now |
| state["last_token"] = now |
| state["response_tokens"] += 1 |
| _put({ |
| "type": "response", |
| "token": safe, |
| "count": state["response_tokens"], |
| "tps": _tps( |
| state["first_response_token"], |
| state["last_response_token"], |
| state["response_tokens"], |
| ), |
| }) |
| break |
| else: |
| break |
|
|
| elif think_state == "thinking": |
| |
| if "</think>" in content_buffer: |
| idx = content_buffer.index("</think>") |
| reasoning_text = content_buffer[:idx] |
| if reasoning_text: |
| if state["first_token"] is None: |
| state["first_token"] = now |
| state["last_reasoning_token"] = now |
| state["last_token"] = now |
| state["reasoning_tokens"] += 1 |
| _put({ |
| "type": "reasoning", |
| "token": reasoning_text, |
| "count": state["reasoning_tokens"], |
| "tps": _tps( |
| state["first_reasoning_token"], |
| state["last_reasoning_token"], |
| state["reasoning_tokens"], |
| ), |
| }) |
| content_buffer = content_buffer[idx + 8:] |
| think_state = "responding" |
| elif len(content_buffer) > 8: |
| |
| safe = content_buffer[:-8] |
| content_buffer = content_buffer[-8:] |
| if safe: |
| state["last_reasoning_token"] = now |
| state["last_token"] = now |
| state["reasoning_tokens"] += 1 |
| _put({ |
| "type": "reasoning", |
| "token": safe, |
| "count": state["reasoning_tokens"], |
| "tps": _tps( |
| state["first_reasoning_token"], |
| state["last_reasoning_token"], |
| state["reasoning_tokens"], |
| ), |
| }) |
| break |
| else: |
| break |
|
|
| elif think_state == "responding": |
| |
| if state["first_response_token"] is None: |
| state["first_response_token"] = now |
| state["last_response_token"] = now |
| state["last_token"] = now |
| state["response_tokens"] += 1 |
| _put({ |
| "type": "response", |
| "token": content_buffer, |
| "count": state["response_tokens"], |
| "tps": _tps( |
| state["first_response_token"], |
| state["last_response_token"], |
| state["response_tokens"], |
| ), |
| }) |
| content_buffer = "" |
| break |
|
|
| |
| total_tokens = state["reasoning_tokens"] + state["response_tokens"] |
| ttft_ms = (state["first_token"] - state["prompt_start"]) * 1000 if state["first_token"] else 0 |
| gen_time = (state["last_token"] - state["first_token"]) if state["first_token"] and state["last_token"] else 0 |
| prompt_time = (state["first_token"] - state["prompt_start"]) if state["first_token"] else 0 |
| total_time = prompt_time + gen_time |
| total_tps = total_tokens / gen_time if gen_time > 0 else 0 |
| reasoning_tps = _tps(state["first_reasoning_token"], state["last_reasoning_token"], state["reasoning_tokens"]) |
| response_tps = _tps(state["first_response_token"], state["last_response_token"], state["response_tokens"]) |
|
|
| _put({ |
| "type": "metrics", |
| "ttft_ms": round(ttft_ms, 0), |
| "prompt_time_s": round(prompt_time, 2), |
| "gen_time_s": round(gen_time, 2), |
| "total_time_s": round(total_time, 2), |
| "reasoning_tokens": state["reasoning_tokens"], |
| "response_tokens": state["response_tokens"], |
| "total_tokens": total_tokens, |
| "reasoning_tps": round(reasoning_tps, 2), |
| "response_tps": round(response_tps, 2), |
| "total_tps": round(total_tps, 2), |
| }) |
| _put({"type": "done"}) |
|
|
| except Exception as exc: |
| traceback.print_exc() |
| _put({"type": "error", "message": str(exc)}) |
|
|
|
|
| async def _stream_chat_async( |
| messages: List[dict], |
| temperature: float, |
| top_p: float, |
| max_tokens: int, |
| repeat_penalty: float, |
| ) -> asyncio.Queue: |
| """ |
| Launch the blocking LLM stream in a background thread and return an |
| asyncio.Queue that yields events as they arrive. Each token is available |
| immediately — the event loop stays responsive for SSE flushing. |
| """ |
| queue: asyncio.Queue = asyncio.Queue() |
| loop = asyncio.get_event_loop() |
| |
| |
| asyncio.get_event_loop().run_in_executor( |
| None, |
| _run_llm_stream, |
| messages, temperature, top_p, max_tokens, repeat_penalty, |
| queue, loop, |
| ) |
| return queue |
|
|
|
|
| @app.get("/") |
| async def index() -> HTMLResponse: |
| return HTMLResponse(content=HTML_PAGE) |
|
|
|
|
| @app.post("/api/chat") |
| async def chat(request: Request): |
| body = await request.json() |
|
|
| messages = _build_messages( |
| body.get("history", []), |
| body.get("system_prompt", SYSTEM_PROMPT_DEFAULT), |
| ) |
|
|
| queue = await _stream_chat_async( |
| messages=messages, |
| temperature=body.get("temperature", TEMPERATURE_DEFAULT), |
| top_p=body.get("top_p", TOP_P_DEFAULT), |
| max_tokens=body.get("max_tokens", MAX_TOKENS_DEFAULT), |
| repeat_penalty=body.get("repeat_penalty", REPEAT_PENALTY_DEFAULT), |
| ) |
|
|
| async def _event_generator(): |
| while True: |
| event = await queue.get() |
| yield {"data": json.dumps(event)} |
| if event.get("type") in ("done", "error"): |
| break |
|
|
| return EventSourceResponse(_event_generator()) |
|
|
|
|
| @app.get("/api/health") |
| async def health(): |
| return JSONResponse({ |
| "status": "ok", |
| "model": FILENAME, |
| "repo": REPO_ID, |
| "quant": quant_label, |
| "size_mb": round(model_size_mb, 1), |
| "llama_cpp_version": _llama_cpp_mod.__version__, |
| "n_threads": N_THREADS, |
| "n_ctx": N_CTX, |
| "kv_cache_dtype": KV_CACHE_DTYPE, |
| }) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| @app.get("/v1/models") |
| async def list_models(): |
| return { |
| "object": "list", |
| "data": [{ |
| "id": MODEL_ID, |
| "object": "model", |
| "created": int(time.time()), |
| "owned_by": REPO_ID.split("/")[0], |
| }], |
| } |
|
|
|
|
| @app.post("/v1/chat/completions") |
| async def chat_completions(request: Request): |
| body = await request.json() |
|
|
| messages = body.get("messages", []) |
| stream = body.get("stream", False) |
| temperature = body.get("temperature", TEMPERATURE_DEFAULT) |
| top_p = body.get("top_p", TOP_P_DEFAULT) |
| |
| max_tokens = body.get("max_completion_tokens") or body.get("max_tokens") or MAX_TOKENS_DEFAULT |
| repeat_penalty = body.get("repeat_penalty", REPEAT_PENALTY_DEFAULT) |
| stop = body.get("stop") |
| |
| if isinstance(stop, str): |
| stop = [stop] |
|
|
| completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" |
| created = int(time.time()) |
| model_name = body.get("model", MODEL_ID) |
|
|
| if stream: |
| queue = await _stream_chat_async(messages, temperature, top_p, max_tokens, repeat_penalty) |
|
|
| async def _openai_sse(): |
| |
| yield {"data": json.dumps({ |
| "id": completion_id, |
| "object": "chat.completion.chunk", |
| "created": created, |
| "model": model_name, |
| "choices": [{ |
| "index": 0, |
| "delta": {"role": "assistant"}, |
| "finish_reason": None, |
| }], |
| })} |
|
|
| while True: |
| event = await queue.get() |
| etype = event.get("type") |
|
|
| if etype == "reasoning": |
| yield {"data": json.dumps({ |
| "id": completion_id, |
| "object": "chat.completion.chunk", |
| "created": created, |
| "model": model_name, |
| "choices": [{ |
| "index": 0, |
| "delta": {"reasoning_content": event["token"]}, |
| "finish_reason": None, |
| }], |
| })} |
| elif etype == "response": |
| yield {"data": json.dumps({ |
| "id": completion_id, |
| "object": "chat.completion.chunk", |
| "created": created, |
| "model": model_name, |
| "choices": [{ |
| "index": 0, |
| "delta": {"content": event["token"]}, |
| "finish_reason": None, |
| }], |
| })} |
| elif etype == "done": |
| yield {"data": json.dumps({ |
| "id": completion_id, |
| "object": "chat.completion.chunk", |
| "created": created, |
| "model": model_name, |
| "choices": [{ |
| "index": 0, |
| "delta": {}, |
| "finish_reason": "stop", |
| }], |
| })} |
| yield {"data": "[DONE]"} |
| break |
| elif etype == "error": |
| yield {"data": json.dumps({ |
| "error": {"message": event.get("message", "unknown error")}, |
| })} |
| break |
|
|
| return EventSourceResponse(_openai_sse()) |
|
|
| else: |
| |
| queue = await _stream_chat_async(messages, temperature, top_p, max_tokens, repeat_penalty) |
| reasoning_content = "" |
| content = "" |
| final_metrics = None |
|
|
| while True: |
| event = await queue.get() |
| etype = event.get("type") |
| if etype == "reasoning": |
| reasoning_content += event["token"] |
| elif etype == "response": |
| content += event["token"] |
| elif etype == "metrics": |
| final_metrics = event |
| elif etype == "error": |
| return JSONResponse( |
| status_code=500, |
| content={"error": {"message": event.get("message", "unknown error")}}, |
| ) |
| elif etype == "done": |
| break |
|
|
| prompt_tokens = 0 |
| try: |
| |
| full_prompt = " ".join(m.get("content", "") for m in messages) |
| prompt_tokens = len(llm.tokenize(full_prompt.encode("utf-8"))) |
| except Exception: |
| pass |
|
|
| completion_tokens = (final_metrics or {}).get("total_tokens", 0) |
|
|
| message_obj = { |
| "role": "assistant", |
| "content": content, |
| } |
| if reasoning_content: |
| message_obj["reasoning_content"] = reasoning_content |
|
|
| return { |
| "id": completion_id, |
| "object": "chat.completion", |
| "created": created, |
| "model": model_name, |
| "choices": [{ |
| "index": 0, |
| "message": message_obj, |
| "finish_reason": "stop", |
| }], |
| "usage": { |
| "prompt_tokens": prompt_tokens, |
| "completion_tokens": completion_tokens, |
| "total_tokens": prompt_tokens + completion_tokens, |
| }, |
| } |
|
|
|
|
| |
| |
| |
| HTML_PAGE = r"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>MiniCPM5-1B · CPU Chat</title> |
| <style> |
| :root { |
| --bg: #0f0f0f; |
| --surface: #1a1a1a; |
| --surface-hover: #242424; |
| --border: #2a2a2a; |
| --text: #e0e0e0; |
| --text-dim: #888; |
| --accent: #6b8afd; |
| --user-bubble: #2563eb; |
| --asst-bubble: #1e1e1e; |
| --reasoning-bg: #161616; |
| --reasoning-border: #333; |
| --success: #22c55e; |
| --error: #ef4444; |
| } |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| background: var(--bg); color: var(--text); |
| display: flex; flex-direction: column; height: 100vh; overflow: hidden; |
| } |
| header { |
| padding: 10px 20px; background: var(--surface); |
| border-bottom: 1px solid var(--border); |
| display: flex; align-items: center; justify-content: space-between; |
| flex-shrink: 0; |
| } |
| header h1 { font-size: 16px; font-weight: 600; } |
| header .meta { font-size: 12px; color: var(--text-dim); } |
| #main { display: flex; flex: 1; overflow: hidden; } |
| #chat-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; } |
| #messages { |
| flex: 1; overflow-y: auto; padding: 20px; |
| display: flex; flex-direction: column; gap: 16px; |
| } |
| .msg { max-width: 75%; word-wrap: break-word; } |
| .msg.user { |
| align-self: flex-end; |
| background: var(--user-bubble); color: white; |
| padding: 10px 14px; border-radius: 16px 16px 4px 16px; |
| } |
| .msg.asst { |
| align-self: flex-start; |
| background: var(--asst-bubble); border: 1px solid var(--border); |
| padding: 12px 16px; border-radius: 16px 16px 16px 4px; |
| } |
| .msg.asst .content { line-height: 1.6; } |
| .msg.asst .content p { margin-bottom: 8px; } |
| .msg.asst .content code { |
| background: #0a0a0a; padding: 2px 5px; border-radius: 3px; |
| font-family: "SF Mono", Monaco, monospace; font-size: 13px; |
| } |
| .msg.asst .content pre { |
| background: #0a0a0a; padding: 10px; border-radius: 6px; |
| overflow-x: auto; margin: 8px 0; |
| } |
| .msg.asst .content pre code { background: none; padding: 0; } |
| .reasoning { |
| background: var(--reasoning-bg); border: 1px solid var(--reasoning-border); |
| border-radius: 8px; margin-bottom: 10px; overflow: hidden; |
| } |
| .reasoning summary { |
| padding: 8px 12px; cursor: pointer; font-size: 13px; |
| color: var(--text-dim); user-select: none; |
| } |
| .reasoning summary b { color: var(--accent); } |
| .reasoning[open] summary { border-bottom: 1px solid var(--reasoning-border); } |
| .reasoning .body { |
| padding: 10px 12px; font-size: 13px; color: var(--text-dim); |
| line-height: 1.5; white-space: pre-wrap; max-height: 400px; overflow-y: auto; |
| } |
| .metrics-bar { |
| margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); |
| font-size: 11px; color: var(--text-dim); font-family: monospace; |
| } |
| #input-area { |
| padding: 12px 20px; background: var(--surface); |
| border-top: 1px solid var(--border); display: flex; gap: 10px; |
| flex-shrink: 0; |
| } |
| #input { |
| flex: 1; background: var(--bg); border: 1px solid var(--border); |
| color: var(--text); padding: 10px 14px; border-radius: 8px; |
| font-size: 14px; font-family: inherit; resize: none; |
| min-height: 44px; max-height: 120px; |
| } |
| #input:focus { outline: none; border-color: var(--accent); } |
| #send { |
| background: var(--accent); color: white; border: none; |
| padding: 0 20px; border-radius: 8px; font-size: 14px; font-weight: 600; |
| cursor: pointer; white-space: nowrap; |
| } |
| #send:disabled { opacity: 0.5; cursor: not-allowed; } |
| #send.stop { background: var(--error); } |
| #sidebar { |
| width: 280px; background: var(--surface); border-left: 1px solid var(--border); |
| padding: 16px; overflow-y: auto; flex-shrink: 0; |
| } |
| #sidebar h3 { font-size: 13px; text-transform: uppercase; color: var(--text-dim); margin-bottom: 10px; } |
| #sidebar .setting { margin-bottom: 14px; } |
| #sidebar label { display: block; font-size: 12px; margin-bottom: 4px; color: var(--text-dim); } |
| #sidebar input[type=text], #sidebar input[type=number] { |
| width: 100%; background: var(--bg); border: 1px solid var(--border); |
| color: var(--text); padding: 6px 8px; border-radius: 4px; font-size: 13px; |
| } |
| #sidebar input[type=range] { width: 100%; } |
| #sidebar .val { float: right; font-family: monospace; font-size: 12px; color: var(--accent); } |
| #live-metrics { |
| background: var(--bg); border: 1px solid var(--border); |
| border-radius: 6px; padding: 10px; margin-top: 12px; |
| font-family: monospace; font-size: 12px; |
| } |
| #live-metrics .row { display: flex; justify-content: space-between; padding: 2px 0; } |
| #live-metrics .label { color: var(--text-dim); } |
| #live-metrics .value { color: var(--accent); } |
| .examples { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } |
| .example { |
| background: var(--surface); border: 1px solid var(--border); |
| padding: 4px 10px; border-radius: 12px; font-size: 12px; |
| cursor: pointer; color: var(--text-dim); |
| } |
| .example:hover { background: var(--surface-hover); color: var(--text); } |
| @media (max-width: 768px) { |
| #sidebar { display: none; } |
| .msg { max-width: 90%; } |
| } |
| /* Markdown content styling */ |
| .msg.asst .content h1, .msg.asst .content h2, .msg.asst .content h3 { |
| margin: 12px 0 6px; color: #fff; font-weight: 600; |
| } |
| .msg.asst .content h1 { font-size: 1.4em; } |
| .msg.asst .content h2 { font-size: 1.2em; } |
| .msg.asst .content h3 { font-size: 1.1em; } |
| .msg.asst .content ul, .msg.asst .content ol { |
| margin: 6px 0; padding-left: 24px; |
| } |
| .msg.asst .content li { margin: 3px 0; } |
| .msg.asst .content blockquote { |
| border-left: 3px solid var(--accent); padding-left: 12px; |
| margin: 8px 0; color: var(--text-dim); font-style: italic; |
| } |
| .msg.asst .content table { |
| border-collapse: collapse; margin: 8px 0; width: 100%; |
| } |
| .msg.asst .content th, .msg.asst .content td { |
| border: 1px solid var(--border); padding: 6px 10px; text-align: left; |
| } |
| .msg.asst .content th { background: var(--surface); } |
| .msg.asst .content a { color: var(--accent); } |
| .msg.asst .content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; } |
| .msg.asst .content p:last-child { margin-bottom: 0; } |
| </style> |
| <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script> |
| </head> |
| <body> |
| |
| <header> |
| <h1>🤖 MiniCPM5-1B (Q8_0) · CPU</h1> |
| <div class="meta">100k context · Max output 30k · Hybrid Reasoning ON · OpenAI API at <code>/v1/chat/completions</code></div> |
| </header> |
| |
| <div id="main"> |
| <div id="chat-area"> |
| <div id="messages"></div> |
| <div class="examples" id="examples"> |
| <div class="example" onclick="useExample(this)">Explain transformer attention in 3 sentences</div> |
| <div class="example" onclick="useExample(this)">Write a Python dedup function</div> |
| <div class="example" onclick="useExample(this)">Three habits for senior DevOps</div> |
| <div class="example" onclick="useExample(this)">Draft a bug-fix release note</div> |
| </div> |
| <div id="input-area"> |
| <textarea id="input" placeholder="Message MiniCPM5-1B... (Enter to send, Shift+Enter for newline)" rows="1"></textarea> |
| <button id="send" onclick="sendOrStop()">Send</button> |
| </div> |
| </div> |
| |
| <div id="sidebar"> |
| <h3>⚙️ Settings</h3> |
| <div class="setting"> |
| <label>System prompt</label> |
| <input type="text" id="system-prompt" value="You are Qwen3.6, a helpful and concise assistant. Answer in clear, well-structured prose."> |
| </div> |
| <div class="setting"> |
| <label>Temperature <span class="val" id="temp-val">0.70</span></label> |
| <input type="range" id="temperature" min="0" max="2" step="0.05" value="0.7" oninput="document.getElementById('temp-val').textContent=parseFloat(this.value).toFixed(2)"> |
| </div> |
| <div class="setting"> |
| <label>Top-p <span class="val" id="topp-val">0.90</span></label> |
| <input type="range" id="top-p" min="0" max="1" step="0.01" value="0.9" oninput="document.getElementById('topp-val').textContent=parseFloat(this.value).toFixed(2)"> |
| </div> |
| <div class="setting"> |
| <label>Max tokens <span class="val" id="maxt-val">4096</span></label> |
| <input type="range" id="max-tokens" min="256" max="30000" step="256" value="4096" oninput="document.getElementById('maxt-val').textContent=this.value"> |
| </div> |
| <div class="setting"> |
| <label>Repeat penalty <span class="val" id="rp-val">1.05</span></label> |
| <input type="range" id="repeat-penalty" min="0.8" max="2" step="0.01" value="1.05" oninput="document.getElementById('rp-val').textContent=parseFloat(this.value).toFixed(2)"> |
| </div> |
| |
| <h3>📊 Live Metrics</h3> |
| <div id="live-metrics"> |
| <div class="row"><span class="label">Status</span><span class="value" id="m-status">idle</span></div> |
| <div class="row"><span class="label">TTFT</span><span class="value" id="m-ttft">—</span></div> |
| <div class="row"><span class="label">Reasoning tok/s</span><span class="value" id="m-rtps">—</span></div> |
| <div class="row"><span class="label">Response tok/s</span><span class="value" id="m-vtps">—</span></div> |
| <div class="row"><span class="label">Total tok/s</span><span class="value" id="m-ttps">—</span></div> |
| <div class="row"><span class="label">Reasoning count</span><span class="value" id="m-rcount">0</span></div> |
| <div class="row"><span class="label">Response count</span><span class="value" id="m-vcount">0</span></div> |
| <div class="row"><span class="label">Total tokens</span><span class="value" id="m-total">0</span></div> |
| <div class="row"><span class="label">Elapsed</span><span class="value" id="m-elapsed">—</span></div> |
| <div class="row"><span class="label">Gen time</span><span class="value" id="m-gen">—</span></div> |
| <div class="row"><span class="label">Total time</span><span class="value" id="m-total-time">—</span></div> |
| </div> |
| </div> |
| </div> |
| |
| <script> |
| let history = []; |
| let isGenerating = false; |
| let currentController = null; |
| let reasoningText = ''; |
| let responseText = ''; |
| let promptStart = 0; |
| let elapsedTimer = null; |
| |
| const messagesEl = document.getElementById('messages'); |
| const inputEl = document.getElementById('input'); |
| const sendBtn = document.getElementById('send'); |
| |
| inputEl.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendOrStop(); } |
| }); |
| |
| function useExample(el) { |
| inputEl.value = el.textContent; |
| inputEl.focus(); |
| } |
| |
| function setMetrics(id, val) { |
| const el = document.getElementById(id); |
| if (el) el.textContent = val; |
| } |
| |
| function startElapsedTimer() { |
| stopElapsedTimer(); |
| promptStart = Date.now(); |
| elapsedTimer = setInterval(() => { |
| const elapsed = ((Date.now() - promptStart) / 1000).toFixed(1); |
| setMetrics('m-elapsed', elapsed + 's'); |
| }, 100); |
| } |
| |
| function stopElapsedTimer() { |
| if (elapsedTimer) { |
| clearInterval(elapsedTimer); |
| elapsedTimer = null; |
| } |
| } |
| |
| function resetLiveMetrics() { |
| setMetrics('m-status', 'starting...'); |
| setMetrics('m-ttft', '—'); |
| setMetrics('m-rtps', '—'); |
| setMetrics('m-vtps', '—'); |
| setMetrics('m-ttps', '—'); |
| setMetrics('m-rcount', '0'); |
| setMetrics('m-vcount', '0'); |
| setMetrics('m-total', '0'); |
| setMetrics('m-elapsed', '0.0s'); |
| setMetrics('m-gen', '—'); |
| setMetrics('m-total-time', '—'); |
| startElapsedTimer(); |
| } |
| |
| function escapeHtml(s) { |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| } |
| |
| // Full markdown rendering via marked.js (loaded from CDN in <head>). |
| // Falls back to escaped text if marked isn't available. |
| function renderMarkdown(text) { |
| if (typeof marked !== 'undefined') { |
| try { |
| return marked.parse(text, { breaks: true, gfm: true }); |
| } catch (e) { |
| console.error('marked error:', e); |
| } |
| } |
| return escapeHtml(text).replace(/\n/g, '<br>'); |
| } |
| |
| function addMessage(role, content) { |
| const div = document.createElement('div'); |
| div.className = 'msg ' + role; |
| if (role === 'user') { |
| div.innerHTML = escapeHtml(content); |
| } else { |
| div.innerHTML = `<div class="content">${renderMarkdown(content)}</div>`; |
| } |
| messagesEl.appendChild(div); |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| return div; |
| } |
| |
| function updateAsstBubble(reasoning, response, isThinking) { |
| let bubble = document.getElementById('current-asst'); |
| if (!bubble) { |
| bubble = document.createElement('div'); |
| bubble.className = 'msg asst'; |
| bubble.id = 'current-asst'; |
| messagesEl.appendChild(bubble); |
| } |
| |
| // Check if we need to (re)create the structure. We avoid replacing |
| // innerHTML on every token because that resets scroll positions. |
| // Instead, we create the structure once and only update text content. |
| let reasoningEl = bubble.querySelector('details.reasoning'); |
| let reasoningBody = reasoningEl ? reasoningEl.querySelector('.body') : null; |
| let contentEl = bubble.querySelector('.content'); |
| |
| // (Re)create reasoning block if presence changed |
| const needReasoning = !!reasoning; |
| const hasReasoning = !!reasoningEl; |
| if (needReasoning && !hasReasoning) { |
| reasoningEl = document.createElement('details'); |
| reasoningEl.className = 'reasoning'; |
| reasoningEl.open = true; |
| const summary = document.createElement('summary'); |
| summary.innerHTML = '<b>💭 Thinking...</b>'; |
| reasoningBody = document.createElement('div'); |
| reasoningBody.className = 'body'; |
| reasoningEl.appendChild(summary); |
| reasoningEl.appendChild(reasoningBody); |
| bubble.insertBefore(reasoningEl, bubble.firstChild); |
| } |
| if (!needReasoning && hasReasoning) { |
| reasoningEl.remove(); |
| reasoningEl = null; |
| reasoningBody = null; |
| } |
| |
| // Update reasoning summary label (Thinking... vs Reasoning) |
| if (reasoningEl) { |
| const summary = reasoningEl.querySelector('summary'); |
| if (summary) { |
| const label = isThinking ? '💭 Thinking...' : '💭 Reasoning'; |
| summary.innerHTML = `<b>${label}</b>`; |
| } |
| } |
| |
| // Update reasoning body text WITHOUT resetting scroll |
| if (reasoningBody) { |
| reasoningBody.textContent = reasoning; |
| // Auto-scroll reasoning to bottom while thinking (follow new tokens) |
| if (isThinking) { |
| reasoningBody.scrollTop = reasoningBody.scrollHeight; |
| } |
| } |
| |
| // (Re)create content block if presence changed |
| const needContent = !!response || !!reasoning; // show placeholder during thinking |
| const hasContent = !!contentEl; |
| if (needContent && !hasContent) { |
| contentEl = document.createElement('div'); |
| contentEl.className = 'content'; |
| bubble.appendChild(contentEl); |
| } |
| |
| // Update content |
| if (contentEl) { |
| if (response) { |
| contentEl.innerHTML = renderMarkdown(response); |
| contentEl.style.color = ''; |
| contentEl.style.fontStyle = ''; |
| } else if (reasoning) { |
| contentEl.innerHTML = 'generating response...'; |
| contentEl.style.color = '#888'; |
| contentEl.style.fontStyle = 'italic'; |
| } |
| } |
| |
| // Auto-scroll the main messages container to follow the response, |
| // but only when we're past the reasoning phase (response is streaming). |
| // During reasoning, the reasoning box handles its own scroll. |
| if (response) { |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| } |
| } |
| |
| function finalizeAsstBubble(metrics) { |
| let bubble = document.getElementById('current-asst'); |
| if (!bubble) return; |
| bubble.removeAttribute('id'); |
| |
| // Collapse the reasoning block now that we're done |
| const details = bubble.querySelector('details.reasoning'); |
| if (details) details.removeAttribute('open'); |
| |
| // Append metrics bar with TPS + total time prominently |
| if (metrics) { |
| const m = document.createElement('div'); |
| m.className = 'metrics-bar'; |
| m.innerHTML = ` |
| <div style="display:flex;gap:16px;flex-wrap:wrap;"> |
| <span>📊 <b>${metrics.total_tokens}</b> tokens</span> |
| <span>🧠 ${metrics.reasoning_tokens} thinking</span> |
| <span>💬 ${metrics.response_tokens} response</span> |
| </div> |
| <div style="display:flex;gap:16px;flex-wrap:wrap;margin-top:4px;"> |
| <span>⚡ TTFT <b>${metrics.ttft_ms}ms</b></span> |
| <span>🧠 <b>${metrics.reasoning_tps}</b> tok/s (reasoning)</span> |
| <span>💬 <b>${metrics.response_tps}</b> tok/s (response)</span> |
| <span>⏱️ <b>${metrics.total_time_s}s</b> total</span> |
| </div> |
| `; |
| bubble.appendChild(m); |
| } |
| } |
| |
| async function sendOrStop() { |
| if (isGenerating) { |
| if (currentController) currentController.abort(); |
| return; |
| } |
| const msg = inputEl.value.trim(); |
| if (!msg) return; |
| |
| inputEl.value = ''; |
| inputEl.style.height = 'auto'; |
| addMessage('user', msg); |
| history.push({ role: 'user', content: msg }); |
| |
| reasoningText = ''; |
| responseText = ''; |
| promptStart = Date.now(); |
| resetLiveMetrics(); |
| updateAsstBubble('', '', true); |
| |
| isGenerating = true; |
| sendBtn.textContent = 'Stop'; |
| sendBtn.classList.add('stop'); |
| |
| currentController = new AbortController(); |
| |
| try { |
| const resp = await fetch('/api/chat', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| history: history, |
| system_prompt: document.getElementById('system-prompt').value, |
| temperature: parseFloat(document.getElementById('temperature').value), |
| top_p: parseFloat(document.getElementById('top-p').value), |
| max_tokens: parseInt(document.getElementById('max-tokens').value), |
| repeat_penalty: parseFloat(document.getElementById('repeat-penalty').value), |
| }), |
| signal: currentController.signal, |
| }); |
| |
| const reader = resp.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
| let finalMetrics = null; |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| buffer += decoder.decode(value, { stream: true }); |
| |
| const lines = buffer.split('\n'); |
| buffer = lines.pop(); |
| |
| for (const line of lines) { |
| if (!line.startsWith('data: ')) continue; |
| try { |
| const ev = JSON.parse(line.slice(6)); |
| handleEvent(ev); |
| if (ev.type === 'metrics') finalMetrics = ev; |
| if (ev.type === 'done' || ev.type === 'error') { |
| finalizeAsstBubble(finalMetrics); |
| if (ev.type === 'error') { |
| setMetrics('m-status', 'error'); |
| } |
| } |
| } catch (e) {} |
| } |
| } |
| |
| // Add the full response to history |
| history.push({ role: 'assistant', content: responseText }); |
| |
| } catch (err) { |
| stopElapsedTimer(); |
| if (err.name === 'AbortError') { |
| setMetrics('m-status', 'stopped'); |
| history.push({ role: 'assistant', content: responseText + ' [stopped]' }); |
| } else { |
| setMetrics('m-status', 'error: ' + err.message); |
| } |
| finalizeAsstBubble(null); |
| } finally { |
| stopElapsedTimer(); |
| isGenerating = false; |
| sendBtn.textContent = 'Send'; |
| sendBtn.classList.remove('stop'); |
| currentController = null; |
| } |
| } |
| |
| function handleEvent(ev) { |
| switch (ev.type) { |
| case 'status': |
| setMetrics('m-status', ev.stage === 'processing_prompt' ? 'processing prompt...' : ev.stage); |
| break; |
| case 'reasoning': |
| reasoningText += ev.token; |
| setMetrics('m-rcount', ev.count); |
| setMetrics('m-rtps', ev.tps.toFixed(2)); |
| const rTotal = ev.count + parseInt(document.getElementById('m-vcount').textContent); |
| setMetrics('m-total', rTotal); |
| updateAsstBubble(reasoningText, responseText, true); |
| break; |
| case 'response': |
| responseText += ev.token; |
| setMetrics('m-vcount', ev.count); |
| setMetrics('m-vtps', ev.tps.toFixed(2)); |
| const vTotal = parseInt(document.getElementById('m-rcount').textContent) + ev.count; |
| setMetrics('m-total', vTotal); |
| if (reasoningText) { |
| updateAsstBubble(reasoningText, responseText, false); |
| } else { |
| updateAsstBubble('', responseText, false); |
| } |
| setMetrics('m-status', 'streaming response'); |
| break; |
| case 'metrics': |
| stopElapsedTimer(); |
| setMetrics('m-status', 'done'); |
| setMetrics('m-ttft', ev.ttft_ms + 'ms'); |
| setMetrics('m-gen', ev.gen_time_s + 's'); |
| setMetrics('m-total-time', ev.total_time_s + 's'); |
| setMetrics('m-rtps', ev.reasoning_tps.toFixed(2)); |
| setMetrics('m-vtps', ev.response_tps.toFixed(2)); |
| setMetrics('m-ttps', ev.total_tps.toFixed(2)); |
| setMetrics('m-elapsed', ev.total_time_s + 's'); |
| break; |
| case 'error': |
| stopElapsedTimer(); |
| setMetrics('m-status', 'error'); |
| responseText += '\n\n[error] ' + ev.message; |
| updateAsstBubble(reasoningText, responseText, false); |
| break; |
| case 'done': |
| stopElapsedTimer(); |
| break; |
| } |
| } |
| |
| // Auto-resize textarea |
| inputEl.addEventListener('input', () => { |
| inputEl.style.height = 'auto'; |
| inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px'; |
| }); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| |
| |
| |
| if __name__ == "__main__": |
| uvicorn.run( |
| app, |
| host="0.0.0.0", |
| port=PORT, |
| log_level="info", |
| ) |
|
|