Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| # ============================================================================= | |
| # Configuration | |
| # ============================================================================= | |
| REPO_ID = "openbmb/MiniCPM5-1B-GGUF" | |
| FILENAME = "MiniCPM5-1B-Q8_0.gguf" | |
| # --- CPU / memory tuning (free tier = 2 vCPUs, 16 GB RAM) ------------------ | |
| # MiniCPM5-1B is only 1.07 GB (Q8_0) — tons of headroom on 16 GB RAM. | |
| # | |
| # 100k context window — MiniCPM5 supports up to 262k (n_ctx_train). | |
| # KV cache at q8_0 for 100k context ≈ 2.3 GB (fits easily in 16 GB). | |
| # | |
| # N_CTX = 102400 : 100k token context (model supports up to 262k) | |
| # N_BATCH = 512 : prompt-eval batch size | |
| # KV_CACHE = q8_0 : optimal for CPU (memory bandwidth bound) | |
| N_THREADS = 2 | |
| N_CTX = 102400 | |
| N_BATCH = 512 | |
| KV_CACHE_DTYPE = "q8_0" | |
| N_GPU_LAYERS = 0 | |
| # --- Generation defaults --------------------------------------------------- | |
| # Max output = 30k tokens. At ~12 tok/s, 30k tokens ≈ 42 minutes. | |
| # Default is 4096 (reasonable for chat); users can bump to 30k via slider. | |
| 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." | |
| ) | |
| # ChatML template — the model's embedded template already handles thinking, | |
| # but we set this explicitly as a fallback for models that omit it. | |
| 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 %}" | |
| ) | |
| # ============================================================================= | |
| # Quant label parser | |
| # ============================================================================= | |
| 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" | |
| # ============================================================================= | |
| # Model download + load | |
| # ============================================================================= | |
| 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 = [ | |
| # MiniCPM5 has its own embedded chat template — do NOT override it. | |
| # q8_0 KV cache is optimal for CPU (tested: fp16 was 2x slower due to | |
| # memory bandwidth bottleneck). | |
| ("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, # eager-load 1GB model into RAM (avoids 73s | |
| # mmap cold-start on first request over HF's | |
| # network-attached storage) | |
| verbose=False, # suppress per-request debug output (was True | |
| # for debugging load issues — no longer needed) | |
| **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() | |
| # ============================================================================= | |
| # FastAPI app | |
| # ============================================================================= | |
| app = FastAPI(title="Qwen3.6-27B TQ3_4S Chat") | |
| # CORS — allow browser-based apps and tools to call the API directly. | |
| 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] = [] | |
| # Check if the history already starts with a system message. | |
| 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"], | |
| }) | |
| # Safety check: the Qwen3.6 template raises "No user query found" | |
| # if there's no user message. Log a warning if we're about to send | |
| # a messages list with no user role. | |
| 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): | |
| # Thread-safe put into the asyncio queue. | |
| 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 | |
| # State machine for parsing <think> tags from content. | |
| # MiniCPM5 (and some other models) emit reasoning as <think>...</think> | |
| # inside the regular content field, NOT as a separate reasoning_content | |
| # field. We parse these tags in real-time and split into reasoning vs | |
| # response events. | |
| # | |
| # States: | |
| # "thinking" — inside <think> block, emit as reasoning | |
| # "responding" — after </think>, emit as response | |
| # "buffering" — haven't seen <think> yet, buffering to check | |
| 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() | |
| # Check for reasoning_content field first (Qwen3 / GPT-oss style) | |
| 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 | |
| # Content tokens — may contain <think> tags (MiniCPM5 style) | |
| token = delta.get("content") | |
| if not token: | |
| continue | |
| # Parse <think> tags from the token stream | |
| content_buffer += token | |
| while content_buffer: | |
| if think_state == "buffering": | |
| # Check if we have <think> in the buffer | |
| if "<think>" in content_buffer: | |
| # Emit anything before <think> as response (rare) | |
| 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:] # skip <think> | |
| 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: | |
| # Not enough to contain <think> — emit as response | |
| # but keep last 7 chars in case "<think>" spans chunks | |
| 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 # buffer too short, wait for more | |
| elif think_state == "thinking": | |
| # Check for </think> in the buffer | |
| 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:] # skip </think> | |
| think_state = "responding" | |
| elif len(content_buffer) > 8: | |
| # Emit as reasoning but keep last 8 chars | |
| 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 # buffer too short | |
| elif think_state == "responding": | |
| # Everything goes to response | |
| 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 | |
| # Final metrics | |
| 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() | |
| # Run the sync worker in a thread pool. The worker pushes events to the | |
| # queue via call_soon_threadsafe, so the async side can await them. | |
| asyncio.get_event_loop().run_in_executor( | |
| None, | |
| _run_llm_stream, | |
| messages, temperature, top_p, max_tokens, repeat_penalty, | |
| queue, loop, | |
| ) | |
| return queue | |
| async def index() -> HTMLResponse: | |
| return HTMLResponse(content=HTML_PAGE) | |
| 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()) | |
| 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, | |
| }) | |
| # ============================================================================= | |
| # OpenAI-compatible API (/v1/*) | |
| # | |
| # These endpoints implement the OpenAI Chat Completions API spec so any | |
| # client that speaks OpenAI (Python `openai` library, LangChain, curl, etc.) | |
| # can connect directly: | |
| # | |
| # from openai import OpenAI | |
| # client = OpenAI(base_url="https://YOUR_SPACE.hf.space/v1", api_key="x") | |
| # resp = client.chat.completions.create( | |
| # model="Qwen3.6-27B-MTP-TQ3_4S", | |
| # messages=[{"role": "user", "content": "Hello"}], | |
| # stream=True, | |
| # ) | |
| # | |
| # Streaming format follows OpenAI SSE conventions: | |
| # data: {"choices":[{"delta":{"content":"..."}}]} | |
| # data: {"choices":[{"delta":{"reasoning_content":"..."}}]} ← Qwen3 thinking | |
| # data: [DONE] | |
| # | |
| # Non-streaming returns a standard chat.completion object. | |
| # ============================================================================= | |
| async def list_models(): | |
| return { | |
| "object": "list", | |
| "data": [{ | |
| "id": MODEL_ID, | |
| "object": "model", | |
| "created": int(time.time()), | |
| "owned_by": REPO_ID.split("/")[0], | |
| }], | |
| } | |
| 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) | |
| # OpenAI uses max_tokens (legacy) or max_completion_tokens (newer) | |
| 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") | |
| # Normalize stop to a list (OpenAI accepts string or list) | |
| 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(): | |
| # Initial role delta (OpenAI convention) | |
| 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: | |
| # Non-streaming: collect all tokens from the queue, return a single JSON. | |
| 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: | |
| # Count prompt tokens using the model's tokenizer | |
| 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 / CSS / JS — custom chat interface | |
| # ============================================================================= | |
| 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> | |
| """ | |
| # ============================================================================= | |
| # Launch | |
| # ============================================================================= | |
| if __name__ == "__main__": | |
| uvicorn.run( | |
| app, | |
| host="0.0.0.0", | |
| port=PORT, | |
| log_level="info", | |
| ) | |