#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LLM API 并发流式可视化界面 (tkinter, 无额外依赖)

同时向 OpenAI 兼容接口发 N 个流式请求, 并排显示 N 个文本窗口实时输出,
用来肉眼确认: 是不是同时出首 token、哪个流在卡、卡在 prefill 还是 decode。

每路的 prompt 可以自己在下方编辑框填写, 也可以点“随机”按 ctx 大小自动生成
(每路随机内容不同, 避免服务端 prefix cache 干扰)。

  python bench_gui.py --url http://localhost:1234/v1 --model mymodel
"""

import argparse
import json
import queue
import random
import threading
import time
import tkinter as tk
import uuid
from tkinter import ttk

import requests

FILLER_WORDS = ("the quick brown fox jumps over lazy dogs while sun sets behind quiet "
                "mountains and gentle wind moves through old trees near a small river "
                "village where farmers harvest wheat and children play outside today "
                "because tomorrow brings rain across the green fields of autumn").split()
GAP_WARN = 0.5  # 超过该秒数没有新 token 就标记卡顿
# 估算校准(已用实测数据标定): 字符/prompt_tk = 2.0, 每SSE块≈实际输出 2.86 tk
DEFAULT_CAL = {"cpt": 2.0, "chunk_tk": 2.86}


def random_prompt(target_tokens: int) -> str:
    """生成目标 token 数的随机 prompt。实测该 tokenizer 约 2 字符/tk(单词+随机编号平均≈10字符≈5tk), 按字符预算拼词, 使真实 prompt 命中面板选的 ctx。"""
    salt = uuid.uuid4().hex[:8]
    budget = int(target_tokens * DEFAULT_CAL["cpt"])  # 目标字符数 = 目标tk × 字符/tk
    rng = random.Random(salt)
    words = []
    made = 0
    while made < budget:
        w = f"{rng.choice(FILLER_WORDS)}{rng.randrange(10000)}"
        words.append(w)
        made += len(w) + 1
    return (f"Benchmark prompt {salt}. Read the passage and answer long 512 words. "
            + " ".join(words))[:budget + 60]


class StreamPanel(ttk.Frame):
    def __init__(self, master, wid, on_reprompt):
        super().__init__(master, relief="solid", borderwidth=1, padding=2)
        self.wid = wid
        # 顶部: 编号 + 实时状态
        self.header = ttk.Label(self, text=f"#{wid} 空闲", anchor="w", font=("Consolas", 9))
        self.header.pack(fill="x")
        # 中部: token 流输出
        self.text = tk.Text(self, wrap="word", state="disabled", font=("Consolas", 10),
                            background="#1e1e1e", foreground="#d4d4d4", insertwidth=0)
        sb = ttk.Scrollbar(self, command=self.text.yview)
        self.text.configure(yscrollcommand=sb.set)
        sb.pack(side="right", fill="y")
        self.text.pack(side="top", fill="both", expand=True)
        self.text.tag_configure("sys", foreground="#6a9955")
        self.text.tag_configure("warn", foreground="#ff6b6b", background="#3a1d1d")
        # 底部: 本路 prompt 编辑框
        prow = ttk.Frame(self)
        prow.pack(fill="x")
        ttk.Button(prow, text="随机", width=5,
                   command=lambda: on_reprompt(self)).pack(side="right", padx=2, pady=1)
        self.prompt_box = tk.Text(prow, height=3, wrap="word", font=("Consolas", 8))
        self.prompt_box.pack(side="left", fill="x", expand=True)

    def set_header(self, s):
        self.header.configure(text=s)

    def append(self, s, tag=None):
        self.text.configure(state="normal")
        self.text.insert("end", s, tag)
        self.text.see("end")
        self.text.configure(state="disabled")

    def get_prompt(self):
        return self.prompt_box.get("1.0", "end").strip()

    def set_prompt(self, s):
        self.prompt_box.delete("1.0", "end")
        # 只放前 3000 字符防止 Tk 卡, 实际发送用完整文本
        self.prompt_box.insert("1.0", s if len(s) <= 3000 else s[:3000] + " …(截断显示)")
        self._full_prompt = s

    def effective_prompt(self):
        shown = self.get_prompt()
        # 若编辑框未被用户改动过(仍是截断显示), 发送完整随机文本
        if shown.endswith("…(截断显示)") and getattr(self, "_full_prompt", None):
            return self._full_prompt
        return shown


class App:
    def __init__(self, root, args):
        self.root = root
        self.args = args
        self.evq = queue.Queue()
        self.panels = {}
        self.live = {}      # wid -> (输出 tk, 是否精确)
        self.dec = {}       # wid -> (decode 速度, 是否精确)
        self.pf = {}        # wid -> (prefill 速度, 是否精确)
        self.finished = set()  # 已上报终态的流 id
        self.cal = dict(DEFAULT_CAL)  # 每次拿到服务端精确 usage 后自动校准
        self.srv_sum = {"pt": 0, "ct": 0}  # 服务端 usage 累加
        self.batch_t0 = None
        self.stop_evt = threading.Event()
        self.stop_t = None
        self.active = {}    # wid -> 当前活动的 requests.Response, 用于停止时强制断开
        self.running = False
        self._build_ui(args)
        self._layout(args.concurrency)
        self.root.after(30, self._drain)

    # ---------- UI ----------
    CTX_MAP = {"4k": 4096, "8k": 8192, "16k": 16384, "32k": 32768,
               "64k": 65536, "128k": 131072, "200k": 204800}

    def get_ctx(self):
        return self.CTX_MAP.get(self.c_ctx.get(), 4096)

    def _on_size_change(self, event=None):
        """换 ctx 挡位或并发数时, 自动重新随机填入全部面板"""
        n = int(self.c_conc.get())
        if n != len(self.panels) and not self.running:
            self._layout(n)
        self.reprompt_all()

    def _build_ui(self, args):
        # 第一行: 连接与规模
        top = ttk.Frame(self.root, padding=4)
        top.pack(fill="x")
        ttk.Label(top, text="URL").pack(side="left")
        self.e_url = ttk.Entry(top, width=38)
        self.e_url.insert(0, args.url)
        self.e_url.pack(side="left", padx=2)
        ttk.Label(top, text="model").pack(side="left")
        self.e_model = ttk.Entry(top, width=14)
        self.e_model.insert(0, args.model)
        self.e_model.pack(side="left", padx=2)
        ttk.Label(top, text="api-key").pack(side="left")
        self.e_key = ttk.Entry(top, width=18, show="*")
        if args.api_key:
            self.e_key.insert(0, args.api_key)
        self.e_key.pack(side="left", padx=2)
        ttk.Label(top, text="随机ctx").pack(side="left")
        self.c_ctx = ttk.Combobox(top, values=list(self.CTX_MAP), width=7)
        inv = {v: k for k, v in self.CTX_MAP.items()}
        self.c_ctx.set(inv.get(args.ctx, "4k"))
        self.c_ctx.pack(side="left", padx=2)
        self.c_ctx.bind("<<ComboboxSelected>>", self._on_size_change)
        ttk.Label(top, text="maxtok").pack(side="left")
        self.e_maxtok = ttk.Entry(top, width=6)
        self.e_maxtok.insert(0, str(args.max_tokens))
        self.e_maxtok.pack(side="left", padx=2)
        ttk.Label(top, text="并发").pack(side="left")
        self.c_conc = ttk.Combobox(top, values=["1", "2", "3", "4", "6", "8"], width=4)
        self.c_conc.set(str(args.concurrency))
        self.c_conc.pack(side="left", padx=2)
        self.c_conc.bind("<<ComboboxSelected>>", self._on_size_change)
        ttk.Button(top, text="随机填入全部", width=11,
                   command=self.reprompt_all).pack(side="left", padx=2)

        # 第二行: 发射控制
        top2 = ttk.Frame(self.root, padding=(4, 0))
        top2.pack(fill="x")
        ttk.Label(top2, text="发射模式").pack(side="left")
        self.c_mode = ttk.Combobox(top2, values=["同时", "随机0~10s", "自定义延时"], width=10)
        self.c_mode.set("同时")
        self.c_mode.pack(side="left", padx=2)
        ttk.Label(top2, text="各路延时s(用,分隔: 1路,2路,3路..)").pack(side="left")
        self.e_delays = ttk.Entry(top2, width=16)
        self.e_delays.insert(0, "0,1,2,3")
        self.e_delays.pack(side="left", padx=2)
        self.b_start = ttk.Button(top2, text="开始", command=self.start, width=8)
        self.b_start.pack(side="left", padx=2)
        self.b_stop = ttk.Button(top2, text="停止", command=self.stop, width=8, state="disabled")
        self.b_stop.pack(side="left", padx=2)
        self.lbl_status = ttk.Label(top2, text="", foreground="#666")
        self.lbl_status.pack(side="left", padx=8)

        # 第三行: 全部统计
        top3 = ttk.Frame(self.root, padding=(4, 0))
        top3.pack(fill="x")
        self.lbl2 = ttk.Label(top3, text="聚合 decode: —", font=("Consolas", 10, "bold"))
        self.lbl2.pack(side="left")
        self.lbl = ttk.Label(top3, text="", foreground="#444", font=("Consolas", 9))
        self.lbl.pack(side="left", padx=12)

        self.grid = ttk.Frame(self.root)
        self.grid.pack(fill="both", expand=True)

    def _layout(self, n):
        for w in self.grid.winfo_children():
            w.destroy()
        self.panels = {}
        if n <= 4:
            pos = {1: [(0, 0)], 2: [(0, 0), (0, 1)],
                   3: [(0, 0), (0, 1), (1, 0)],
                   4: [(0, 0), (0, 1), (1, 0), (1, 1)]}[n]
        else:
            pos = [(i // 2, i % 2) for i in range(n)]
        for i in range(n):
            p = StreamPanel(self.grid, i + 1, self.reprompt_one)
            r, c = pos[i]
            p.grid(row=r, column=c, sticky="nsew")
            p.set_prompt(random_prompt(self.get_ctx()))
            self.panels[i + 1] = p
        rows = max(r for r, _ in pos) + 1
        for r in range(rows):
            self.grid.rowconfigure(r, weight=1)
        for c in range(2 if n > 1 else 1):
            self.grid.columnconfigure(c, weight=1)

    def reprompt_one(self, panel):
        panel.set_prompt(random_prompt(self.get_ctx()))

    def reprompt_all(self):
        for p in self.panels.values():
            p.set_prompt(random_prompt(self.get_ctx()))

    # ---------- 控制 ----------
    def start(self):
        if self.running:
            return
        self.stop_evt.clear()
        self.stop_t = None
        conc = int(self.c_conc.get())
        maxtok = int(self.e_maxtok.get())
        url = self.e_url.get().rstrip("/")
        if not url.endswith("/chat/completions"):
            url += "/chat/completions"
        model = self.e_model.get()
        headers = {"Content-Type": "application/json"}
        key = self.e_key.get().strip()
        if key:
            headers["Authorization"] = f"Bearer {key}"
        prompts = []
        for i in range(1, conc + 1):
            p = self.panels.get(i)
            pr = p.effective_prompt() if p else ""
            prompts.append(pr or random_prompt(self.get_ctx()))
        # 先重排布局(可能改并发数), 再把 prompt 填回新面板
        self._layout(conc)
        for i, pr in enumerate(prompts, start=1):
            self.panels[i].set_prompt(pr)
        self.running = True
        self.b_start.configure(state="disabled")
        self.b_stop.configure(state="normal")
        lens = ", ".join(f"#{i}:{len(p)}ch" for i, p in enumerate(prompts, 1))
        self.lbl_status.configure(text=f"发送中: {conc} 路 ({lens})")
        self.live = {}
        self.dec = {}
        self.pf = {}
        self.finished = set()
        self.srv_sum = {"pt": 0, "ct": 0}
        cal = dict(self.cal)  # 本批次快照
        self.batch_t0 = time.perf_counter()
        t0 = time.perf_counter()
        # 发射模式 -> 每路延时
        mode = self.c_mode.get()
        if mode == "同时":
            delays = [0.0] * conc
        elif mode == "随机0~10s":
            delays = [random.uniform(0, 10) for _ in range(conc)]
        else:  # 自定义: 按输入顺序对应 1路,2路,3路..., 不足补 0
            try:
                ds = [float(x) for x in self.e_delays.get().replace("，", ",").split(",") if x.strip() != ""]
            except ValueError:
                ds = []
            delays = [ds[i] if i < len(ds) else 0.0 for i in range(conc)]
        dstr = ", ".join(f"#{i + 1}:{d:.2f}s" for i, d in enumerate(delays))
        self.evq.put(("all-sys",
                      f"=== 批次发射 | {conc} 路 | mode={mode} [{dstr}] | max_tokens={maxtok} ==="))
        for i in range(conc):
            threading.Thread(target=self._worker,
                             args=(i + 1, url, headers, model, prompts[i], maxtok, t0,
                                   delays[i], cal),
                             daemon=True).start()

    def stop(self):
        self.stop_evt.set()
        self.stop_t = time.perf_counter()
        self.lbl_status.configure(text="已请求停止, 强制断开所有活动流…")
        for r in list(self.active.values()):
            try:
                r.close()  # 逼停阻塞在 iter_lines 的读(否则 prefill 期间收不到块永远检不到停止)
            except Exception:
                pass

    def _upd_total(self):
        if not self.live or self.batch_t0 is None:
            return
        exact = sum(c for c, e in self.live.values() if e)
        est = sum(c for c, e in self.live.values() if not e)
        tot = exact + est
        el = max(time.perf_counter() - self.batch_t0, 1e-6)
        per = " ".join(f"#{w}:{c}{'精确' if e else '~估'}"
                       for w, (c, e) in sorted(self.live.items()))
        tag = f"精确{exact}+估算{est}" if est else f"全精确{exact}"
        extra = (f" | 服务端累计 prompt {self.srv_sum['pt']} / 输出 {self.srv_sum['ct']} tk"
                 if self.srv_sum["pt"] or self.srv_sum["ct"] else "")
        self.lbl.configure(
            text=f"总输出 {tot} tk [{tag}] | 批次墙钟 {el:.1f}s | {per}{extra}")
        # 第二行: 聚合 decode / prefill 速度 = 各路相加
        dsum = sum(v for v, _ in self.dec.values())
        dper = " ".join(f"#{w}:{v:.1f}{'✓' if e else '~'}"
                        for w, (v, e) in sorted(self.dec.items()))
        n_done = sum(1 for _, e in self.dec.values() if e)
        psum = sum(v for v, _ in self.pf.values())
        pper = " ".join(f"#{w}:{v:.0f}{'✓' if e else '~'}"
                        for w, (v, e) in sorted(self.pf.items()))
        self.lbl2.configure(
            text=f"聚合 decode: {dsum:.1f} tk/s ({len(self.dec)}路, 精确{n_done}) | {dper} || "
                 f"聚合 prefill: {psum:.0f} tk/s | {pper}")

    # ---------- worker 线程 ----------
    def _worker(self, wid, url, headers, model, prompt, maxtok, t0, delay, cal):
        q = self.evq
        if self.stop_evt.wait(delay):  # 可被停止打断的延时
            q.put(("hdr", wid, f"#{wid} 已停止 (未发出)"))
            return
        q.put(("sys", wid,
               f"[t={time.perf_counter() - t0:6.3f}] 请求已发出 (延时 {delay:.2f}s)\n"))
        payload = {"model": model,
                   "messages": [{"role": "user", "content": prompt}],
                   "max_tokens": maxtok, "temperature": 0,
                   "stream": True, "stream_options": {"include_usage": True}}
        rs = time.perf_counter()
        ttft = last = usage = None
        est_pt = max(1, int(len(prompt) / cal["cpt"]))  # 校准后的 prompt tk 估算
        chunk_tk = cal["chunk_tk"]                      # 每块≈多少 tk
        maxgap, gap_at, n = 0.0, 0.0, 0
        line_count = 0
        samples = []   # 原始块样本, 解析不出内容时打出来诊断
        body_buf = []  # 非 SSE 行的完整拼接(上限 200KB), 兼容非流式返回
        try:
            with requests.post(url, headers=headers, json=payload, stream=True, timeout=600) as r:
                self.active[wid] = r
                if r.status_code != 200:
                    q.put(("warn", wid, f"[错误] HTTP {r.status_code}: {r.text[:150]}\n"))
                    q.put(("hdr", wid, f"#{wid} 失败"))
                    return
                for raw in r.iter_lines(decode_unicode=True):
                    if self.stop_evt.is_set():
                        q.put(("warn", wid, " [已手动停止]\n"))
                        q.put(("hdr", wid, f"#{wid} 已停止 | 收到 {n} 块"))
                        return
                    if not raw:
                        continue
                    line_count += 1
                    if not raw.startswith("data:"):
                        if len(samples) < 200:
                            samples.append(raw[:300])
                        if sum(map(len, body_buf)) < 200000:
                            body_buf.append(raw)
                        continue
                    data = raw[5:].strip()
                    if data == "[DONE]":
                        break
                    if len(samples) < 200:
                        samples.append(data[:300])
                    try:
                        obj = json.loads(data)
                    except json.JSONDecodeError:
                        continue
                    if obj.get("error"):  # SSE 流内携带错误
                        msg = obj["error"]
                        msg = msg.get("message", str(msg)) if isinstance(msg, dict) else str(msg)
                        q.put(("warn", wid, f"[流内错误] {msg}\n"))
                        continue
                    if obj.get("usage"):  # 服务端真实 token 数 (include_usage)
                        usage = obj["usage"]
                    for ch in obj.get("choices", []):
                        d = ch.get("delta") or {}
                        piece = (d.get("content") or d.get("reasoning")
                                 or d.get("reasoning_content"))
                        if not piece:
                            piece = (ch.get("message") or {}).get("content")  # 非流式风格块
                        if not piece:
                            continue
                        now = time.perf_counter()
                        rel = now - t0
                        if ttft is None:
                            ttft = now - rs
                            q.put(("sys", wid,
                                   f"\n[t={rel:7.3f}] ── 首 token, TTFT={ttft:.3f}s, "
                                   f"prefill≈{est_pt / ttft:.0f} tk/s (估算prompt {est_pt} tk) ──\n"))
                        else:
                            gap = now - last
                            if gap > maxgap:
                                maxgap, gap_at = gap, rel
                            if gap >= GAP_WARN:
                                q.put(("warn", wid, f" ⚠{gap:.2f}s无输出@t={rel:.2f} "))
                        last = now
                        n += 1
                        q.put(("tok", wid, piece))
                        if n <= 5 or n % 5 == 0:
                            el = now - rs
                            dsec = max(el - ttft, 1e-6)
                            est_tk = n * chunk_tk
                            dspd = est_tk / dsec
                            q.put(("hdr", wid,
                                   f"#{wid} TTFT={ttft:.2f}s | 估算 {est_tk:.0f} tk | decode用时 {dsec:.2f}s | "
                                   f"估算 {dspd:5.1f} tk/s | 总用时={el:.2f}s | "
                                   f"最大间隔={maxgap:.2f}s@{gap_at:.1f}s"))
                            q.put(("cnt", wid, (int(est_tk), dspd)))
            total = time.perf_counter() - rs
            if n == 0:
                # 兼容服务端忽略 stream 标志直接返回普通 JSON 的情况
                got = None
                try:
                    o = json.loads("".join(body_buf))
                    c = o.get("choices") or [{}]
                    got = (c[0].get("message") or c[0].get("delta") or {}).get("content")
                except Exception:
                    pass
                if got:
                    q.put(("tok", wid, f"[服务端未按 SSE 流式返回, 一次性收到 {len(got)} 字符]\n" + got))
                    q.put(("hdr", wid, f"#{wid} 完成(非流式) | 总{total:.2f}s"))
                    return
                show = samples[:3] + (["…(尾部)", samples[-1]] if len(samples) > 4 else [])
                q.put(("warn", wid,
                       f"\n[诊断] 收到 {line_count} 行但未解析出任何 token。原始块样本:\n"
                       + "\n".join(show) + "\n"))
                q.put(("hdr", wid, f"#{wid} 完成但无内容 (0 tok, {total:.2f}s)"))
                return
            dt = max(total - (ttft or total), 1e-6)
            pt = usage.get("prompt_tokens") if usage else None
            ct = usage.get("completion_tokens") if usage else None
            if pt is not None:
                ct = ct or n
                dspd = max(ct, 1) / dt
                pspd = pt / ttft
                q.put(("sys", wid,
                       f"\n[t={time.perf_counter() - t0:7.3f}] 完成(服务端精确): prompt {pt} tk, "
                       f"输出 {ct} tk, TTFT={ttft:.2f}s, 精确prefill {pspd:.0f} tk/s, "
                       f"decode用时={dt:.2f}s, 精确 {dspd:.1f} tk/s, 总用时={total:.2f}s, "
                       f"最大卡顿={maxgap:.2f}s\n"))
                q.put(("hdr", wid,
                       f"#{wid} 精确 {ct} tk | TTFT {ttft:.2f}s | 精确prefill {pspd:.0f} tk/s | "
                       f"decode用时 {dt:.2f}s | 精确 {dspd:.1f} tk/s | 总 {total:.2f}s | "
                       f"最大间隔 {maxgap:.2f}s"))
            else:
                ct = int(n * chunk_tk + 0.5)
                dspd = max(ct, 1) / dt
                pspd = est_pt / ttft
                q.put(("sys", wid,
                       f"\n[t={time.perf_counter() - t0:7.3f}] 完成(无 usage, 仅估算): {n} 块×{chunk_tk:.2f}≈{ct} tk, "
                       f"prompt≈{est_pt} tk, TTFT={ttft:.2f}s, 估算prefill {pspd:.0f} tk/s, "
                       f"decode用时={dt:.2f}s, 估算 {dspd:.1f} tk/s, 总用时={total:.2f}s, "
                       f"最大卡顿={maxgap:.2f}s\n"))
                q.put(("hdr", wid,
                       f"#{wid} 估算 {ct} tk | TTFT {ttft:.2f}s | 估算prefill {pspd:.0f} tk/s | "
                       f"decode用时 {dt:.2f}s | 估算 {dspd:.1f} tk/s | 总 {total:.2f}s | "
                       f"最大间隔 {maxgap:.2f}s"))
            q.put(("sum", wid, {"pt": pt, "ct": ct, "exact": pt is not None,
                                "dec_tps": dspd, "pre_tps": pspd, "secs": total,
                                "plen": len(prompt), "chunks": n}))
        except Exception as e:
            if self.stop_evt.is_set():
                q.put(("warn", wid, " [已手动停止]\n"))
                q.put(("hdr", wid, f"#{wid} 已停止 | 收到 {n} 块"))
            else:
                q.put(("warn", wid, f"\n[异常] {e!r}\n"))
                q.put(("hdr", wid, f"#{wid} 异常"))
        finally:
            self.active.pop(wid, None)

    # ---------- Tk 线程消费事件 ----------
    def _drain(self):
        try:
            while True:
                ev = self.evq.get_nowait()
                kind = ev[0]
                if kind == "all-sys":
                    for p in self.panels.values():
                        p.append(ev[1] + "\n", "sys")
                    continue
                _, wid, val = ev
                if kind == "cnt":
                    self.live[wid] = (val[0], False)
                    self.dec[wid] = (val[1], False)
                    self._upd_total()
                    continue
                if kind == "sum":
                    self.finished.add(wid)
                    self.live[wid] = (val["ct"] or 0, val["exact"])
                    self.dec[wid] = (val.get("dec_tps") or 0.0, val["exact"])
                    self.pf[wid] = (val.get("pre_tps") or 0.0, val["exact"])
                    if val["exact"]:
                        self.srv_sum["pt"] += val["pt"] or 0
                        self.srv_sum["ct"] += val["ct"] or 0
                        # 用服务端真值自动校准估算系数(滑动平均, 新值权重 1/2)
                        if val["pt"] and val.get("plen"):
                            self.cal["cpt"] = (self.cal["cpt"] + val["plen"] / val["pt"]) / 2
                        if val["ct"] and val.get("chunks"):
                            self.cal["chunk_tk"] = (self.cal["chunk_tk"] + val["ct"] / val["chunks"]) / 2
                    self._upd_total()
                    continue
                p = self.panels.get(wid)
                if p is None:
                    continue
                if kind == "hdr":
                    p.set_header(val)
                    if any(w in val for w in ("失败", "异常", "已停止")):
                        self.finished.add(wid)
                elif kind == "tok":
                    p.append(val)
                elif kind == "sys":
                    p.append(val, "sys")
                elif kind == "warn":
                    p.append(val, "warn")
        except queue.Empty:
            pass
        if self.running and self.stop_evt.is_set() and self.stop_t \
                and time.perf_counter() - self.stop_t > 2.0:
            # 看门狗: 停止后 2s 兼并未正常上报的流, 无条件恢复按钮
            for w, p in self.panels.items():
                if not any(k in p.header.cget("text")
                           for k in ("完成", "失败", "异常", "已停止")):
                    p.set_header(f"#{w} 已停止")
            self.running = False
            self.active.clear()
            self.b_start.configure(state="normal")
            self.b_stop.configure(state="disabled")
            self.lbl_status.configure(text="已停止(看门狗兑底)")
        if self.running and self.evq.empty() and self.panels:
            if len(self.finished) >= len(self.panels):
                self.running = False
                self.b_start.configure(state="normal")
                self.b_stop.configure(state="disabled")
                self.lbl_status.configure(text="批次结束")
        self.root.after(30, self._drain)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--url", default="http://localhost:1234/v1")
    ap.add_argument("--model", default="local-model")
    ap.add_argument("--api-key", default="", dest="api_key")
    ap.add_argument("--ctx", type=int, default=4096)
    ap.add_argument("--max-tokens", type=int, default=256, dest="max_tokens")
    ap.add_argument("--concurrency", type=int, default=2)
    args = ap.parse_args()
    root = tk.Tk()
    root.title("LLM 并发流式观察窗")
    root.geometry("1280x820")
    App(root, args)
    root.mainloop()


if __name__ == "__main__":
    main()
