coderouter-cli 2.4.0__py3-none-any.whl → 2.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1187 @@
1
+ """Launcher routes — ``GET /launcher`` + ``/api/launcher/*``.
2
+
3
+ llama.cpp / vllm / mlx プロセス管理 UI。
4
+
5
+ 設計方針:
6
+ - ダッシュボードと同じ "1ファイル完結" スタイル (Tailwind CDN + inline JS)
7
+ - プロセスレジストリは app.state.launcher に持たせる (再起動で消えるが意図通り)
8
+ - option_profiles は providers.yaml の launcher: セクションで管理 → コード変更不要で拡張可
9
+ - 複数プロセスの同時起動に対応 (UUID ベースの ID 管理)
10
+ - llama.cpp / vllm / mlx いずれも同じ key-value args スキーマで統一
11
+
12
+ エンドポイント:
13
+ GET /launcher → HTML UI
14
+ GET /api/launcher/models → model_dirs をスキャンしてリスト返却
15
+ GET /api/launcher/option-profiles → providers.yaml の option_profiles を返却
16
+ GET /api/launcher/processes → 起動中・停止済みプロセス一覧
17
+ POST /api/launcher/start → プロセス起動
18
+ POST /api/launcher/stop/{id} → プロセス停止 (SIGTERM → SIGKILL)
19
+ DELETE /api/launcher/processes/{id} → レジストリから削除 (停止済みのみ)
20
+ GET /api/launcher/logs/{id} → ログ最新 N 行
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import contextlib
27
+ import os
28
+ import platform
29
+ import shlex
30
+ import shutil
31
+ import subprocess
32
+ import uuid
33
+ from collections import deque
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from fastapi import APIRouter, HTTPException, Request
39
+ from fastapi.responses import HTMLResponse
40
+ from pydantic import BaseModel
41
+
42
+ router = APIRouter()
43
+
44
+ # 背景タスクへの強参照を保持する (create_task の戻り値が GC されるのを防ぐ)
45
+ _background_tasks: set[asyncio.Task[Any]] = set()
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Model file extensions to scan
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _MODEL_EXTS = {".gguf", ".ggml", ".safetensors", ".bin", ".pt", ".pth"}
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # ManagedProcess — 起動したプロセスの状態を保持
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ @dataclass
60
+ class ManagedProcess:
61
+ """Running or stopped backend process entry."""
62
+
63
+ id: str
64
+ name: str
65
+ backend: str # "llama.cpp" | "vllm" | "mlx"
66
+ model_path: str
67
+ port: int
68
+ options: dict[str, Any]
69
+ extra_args: str
70
+ status: str = "starting" # "starting" | "running" | "stopped" | "error"
71
+ pid: int | None = None
72
+ returncode: int | None = None
73
+ log_tail: deque = field(default_factory=lambda: deque(maxlen=200))
74
+ # asyncio subprocess handle — not serialised
75
+ _proc: Any = field(default=None, repr=False, compare=False)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # LauncherRegistry — app.state に格納するレジストリ
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class LauncherRegistry:
84
+ """In-process registry for ManagedProcess instances."""
85
+
86
+ def __init__(self) -> None:
87
+ self._procs: dict[str, ManagedProcess] = {}
88
+
89
+ def get(self, proc_id: str) -> ManagedProcess:
90
+ try:
91
+ return self._procs[proc_id]
92
+ except KeyError:
93
+ raise KeyError(proc_id) from None
94
+
95
+ def add(self, proc: ManagedProcess) -> None:
96
+ self._procs[proc.id] = proc
97
+
98
+ def remove(self, proc_id: str) -> None:
99
+ del self._procs[proc_id]
100
+
101
+ def all(self) -> list[ManagedProcess]:
102
+ return list(self._procs.values())
103
+
104
+
105
+ def _registry(request: Request) -> LauncherRegistry:
106
+ """Get or create the LauncherRegistry on app.state."""
107
+ if not hasattr(request.app.state, "launcher"):
108
+ request.app.state.launcher = LauncherRegistry()
109
+ return request.app.state.launcher
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Model scanning
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def _scan_models(model_dirs: list[str]) -> list[dict[str, Any]]:
118
+ """Walk model_dirs and return metadata for each discovered model file."""
119
+ found: list[dict[str, Any]] = []
120
+ for raw in model_dirs:
121
+ base = Path(raw).expanduser().resolve()
122
+ if not base.exists():
123
+ continue
124
+ for p in sorted(base.rglob("*")):
125
+ if not p.is_file():
126
+ continue
127
+ if p.suffix.lower() not in _MODEL_EXTS:
128
+ continue
129
+ try:
130
+ size = p.stat().st_size
131
+ except OSError:
132
+ continue
133
+ found.append(
134
+ {
135
+ "path": str(p),
136
+ "name": p.name,
137
+ "dir": str(p.parent),
138
+ "size_gb": round(size / (1024**3), 2),
139
+ "ext": p.suffix.lower(),
140
+ }
141
+ )
142
+ return found
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Command builder
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ _BACKEND_DEFAULTS: dict[str, str] = {
151
+ "llama.cpp": "llama-server",
152
+ "vllm": "python",
153
+ "mlx": "python", # mlx_lm.server (Apple Silicon 向け)
154
+ }
155
+
156
+
157
+ def _resolve_binary(backend: str, configured: str | None) -> str:
158
+ """Return the executable to use, expanding ~ and env vars."""
159
+ raw = configured or _BACKEND_DEFAULTS.get(backend, backend)
160
+ return str(Path(raw).expanduser())
161
+
162
+
163
+ def _resolve_backends_sync(
164
+ backends_cfg: dict[str, Any] | None,
165
+ ) -> dict[str, dict[str, Any]]:
166
+ """Resolve binary paths and check availability for every backend.
167
+
168
+ Performs blocking filesystem I/O (``is_file`` / ``shutil.which``),
169
+ so async-route callers must invoke it via ``asyncio.to_thread``.
170
+ """
171
+ result: dict[str, dict[str, Any]] = {}
172
+ for backend, default_bin in _BACKEND_DEFAULTS.items():
173
+ configured: str | None = None
174
+ if backends_cfg:
175
+ bc = backends_cfg.get(backend)
176
+ if bc and bc.binary:
177
+ configured = bc.binary
178
+ resolved = _resolve_binary(backend, configured)
179
+ expanded = str(Path(resolved).expanduser())
180
+ found = (
181
+ Path(expanded).is_file() # フルパス指定でファイルが存在
182
+ or shutil.which(expanded) is not None # PATH から解決可能
183
+ )
184
+ result[backend] = {
185
+ "resolved": resolved,
186
+ "configured": configured or "",
187
+ "default": default_bin,
188
+ "is_custom": configured is not None,
189
+ "found": found,
190
+ }
191
+ return result
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Hardware detection + model recommendation (luna-go /models 互換の発想)
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ def _detect_hardware() -> dict[str, Any]:
200
+ """ハードウェアを best-effort で検出する。
201
+
202
+ ブロッキング I/O (sysctl / nvidia-smi) を含むため、async ルートからは
203
+ ``asyncio.to_thread`` 経由で呼ぶこと。
204
+ """
205
+ cpu = os.cpu_count() or 4
206
+ ram_gb = 0.0
207
+ with contextlib.suppress(ValueError, OSError, AttributeError):
208
+ ram_gb = (os.sysconf("SC_PHYS_PAGES")
209
+ * os.sysconf("SC_PAGE_SIZE") / (1024 ** 3))
210
+ if ram_gb <= 0:
211
+ try:
212
+ out = subprocess.run(["sysctl", "-n", "hw.memsize"],
213
+ capture_output=True, text=True, timeout=3)
214
+ ram_gb = int(out.stdout.strip()) / (1024 ** 3)
215
+ except (ValueError, OSError, subprocess.SubprocessError):
216
+ pass
217
+ gpu, vram_gb = "cpu", 0.0
218
+ if platform.system() == "Darwin" and platform.machine() == "arm64":
219
+ gpu, vram_gb = "metal", ram_gb # ユニファイドメモリ
220
+ elif shutil.which("nvidia-smi"):
221
+ try:
222
+ out = subprocess.run(
223
+ ["nvidia-smi", "--query-gpu=memory.total",
224
+ "--format=csv,noheader,nounits"],
225
+ capture_output=True, text=True, timeout=5)
226
+ mb = max((int(x) for x in out.stdout.split() if x.strip().isdigit()),
227
+ default=0)
228
+ if mb > 0:
229
+ gpu, vram_gb = "cuda", mb / 1024
230
+ except (ValueError, OSError, subprocess.SubprocessError):
231
+ pass
232
+ return {"ram_gb": round(ram_gb, 1), "vram_gb": round(vram_gb, 1),
233
+ "gpu": gpu, "cpu_count": cpu}
234
+
235
+
236
+ def _usable_memory_gb(hw: dict[str, Any]) -> float:
237
+ """モデルの重み + KV キャッシュに使えるメモリ量。"""
238
+ if hw.get("gpu") == "cuda":
239
+ return float(hw.get("vram_gb", 0.0))
240
+ return float(hw.get("ram_gb", 0.0)) # metal (ユニファイド) / cpu
241
+
242
+
243
+ def _model_recommendation(size_gb: float, hw: dict[str, Any]) -> dict[str, str]:
244
+ """モデル単位のメモリ適合判定 (luna-go /models 相当)。
245
+
246
+ level: "ok" (推奨) | "warn" (メモリ厳しい) | "unknown"
247
+ """
248
+ usable = _usable_memory_gb(hw)
249
+ if usable <= 0 or size_gb <= 0:
250
+ return {"level": "unknown", "label": "—"}
251
+ if size_gb * 1.2 + 2.0 <= usable:
252
+ return {"level": "ok", "label": "推奨"}
253
+ return {"level": "warn", "label": "メモリ厳しい"}
254
+
255
+
256
+ def _suggest_launch_flags(size_gb: float, hw: dict[str, Any]) -> str:
257
+ """選択モデル + ハードから -ngl / --ctx-size / --threads を提案する。
258
+
259
+ あくまで目安。他プロセスのメモリ使用や量子化方式までは考慮しない。
260
+ """
261
+ threads = max(1, int(hw.get("cpu_count", 4)) - 2)
262
+ usable = _usable_memory_gb(hw)
263
+ weights = size_gb * 1.15 # 重み + オーバーヘッド概算
264
+ if hw.get("gpu") == "cpu":
265
+ ngl = 0
266
+ elif usable >= weights + 1.0:
267
+ ngl = 99 # 全レイヤー GPU に載る
268
+ elif usable > 1.5:
269
+ ngl = max(0, min(99, int(99 * (usable - 0.7) / max(weights, 0.1))))
270
+ else:
271
+ ngl = 0
272
+ headroom = usable - weights - 1.0
273
+ if headroom >= 8:
274
+ ctx = 32768
275
+ elif headroom >= 4:
276
+ ctx = 16384
277
+ elif headroom >= 2:
278
+ ctx = 8192
279
+ else:
280
+ ctx = 4096
281
+ return f"-ngl {ngl} --ctx-size {ctx} --threads {threads}"
282
+
283
+
284
+ def _model_size_gb(path: str) -> float:
285
+ """モデルファイルのサイズ (GB)。失敗時は 0.0 (ブロッキング — to_thread 推奨)。"""
286
+ try:
287
+ return Path(path).expanduser().stat().st_size / (1024 ** 3)
288
+ except OSError:
289
+ return 0.0
290
+
291
+
292
+ def _build_cmd(
293
+ backend: str,
294
+ model_path: str,
295
+ port: int,
296
+ options: dict[str, Any],
297
+ extra_args: str,
298
+ binary: str | None = None,
299
+ ) -> list[str]:
300
+ """Build the CLI command list for the given backend and options.
301
+
302
+ ``binary`` overrides the default executable (``llama-server`` /
303
+ ``python``). When None, the default is used and PATH resolution
304
+ is left to the OS.
305
+ """
306
+ exe = _resolve_binary(backend, binary)
307
+
308
+ if backend == "llama.cpp":
309
+ cmd: list[str] = [exe, "-m", model_path, "--port", str(port)]
310
+ elif backend == "vllm":
311
+ cmd = [
312
+ exe, "-m", "vllm.entrypoints.openai.api_server",
313
+ "--model", model_path,
314
+ "--port", str(port),
315
+ ]
316
+ elif backend == "mlx":
317
+ cmd = [
318
+ exe, "-m", "mlx_lm.server",
319
+ "--model", model_path,
320
+ "--port", str(port),
321
+ ]
322
+ else:
323
+ raise ValueError(
324
+ f"Unknown backend: {backend!r}. "
325
+ "Expected 'llama.cpp', 'vllm' or 'mlx'."
326
+ )
327
+
328
+ for flag, val in options.items():
329
+ if isinstance(val, bool):
330
+ if val:
331
+ cmd.append(flag)
332
+ else:
333
+ cmd.extend([flag, str(val)])
334
+
335
+ if extra_args.strip():
336
+ cmd.extend(shlex.split(extra_args))
337
+
338
+ return cmd
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Log reader background task
343
+ # ---------------------------------------------------------------------------
344
+
345
+
346
+ async def _tail_logs(proc: ManagedProcess) -> None:
347
+ """Read stdout+stderr into proc.log_tail until the process exits."""
348
+ p = proc._proc
349
+ if p is None:
350
+ return
351
+
352
+ async def _drain(stream: asyncio.StreamReader | None) -> None:
353
+ if stream is None:
354
+ return
355
+ while True:
356
+ line = await stream.readline()
357
+ if not line:
358
+ break
359
+ proc.log_tail.append(line.decode(errors="replace").rstrip())
360
+
361
+ await asyncio.gather(_drain(p.stdout), _drain(p.stderr))
362
+ await p.wait()
363
+ proc.returncode = p.returncode
364
+ proc.pid = None
365
+ proc.status = "stopped" if (p.returncode or 0) == 0 else "error"
366
+ proc.log_tail.append(
367
+ f"[launcher] process exited with code {p.returncode}"
368
+ )
369
+
370
+
371
+ async def shutdown_launcher(app: Any) -> None:
372
+ """Terminate all managed child processes on CodeRouter shutdown.
373
+
374
+ Called from the FastAPI lifespan so that llama.cpp / vllm processes
375
+ started via the Launcher are not left as orphans when CodeRouter exits.
376
+ """
377
+ reg = getattr(app.state, "launcher", None)
378
+ if reg is None:
379
+ return
380
+ procs = reg.all()
381
+ for proc in procs:
382
+ p = proc._proc
383
+ if p is not None and p.returncode is None:
384
+ with contextlib.suppress(Exception):
385
+ p.terminate()
386
+ for proc in procs:
387
+ p = proc._proc
388
+ if p is None or p.returncode is not None:
389
+ continue
390
+ try:
391
+ await asyncio.wait_for(p.wait(), timeout=5.0)
392
+ except TimeoutError:
393
+ with contextlib.suppress(Exception):
394
+ p.kill()
395
+ except Exception:
396
+ pass
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Pydantic request models
401
+ # ---------------------------------------------------------------------------
402
+
403
+
404
+ class StartRequest(BaseModel):
405
+ name: str
406
+ backend: str
407
+ model_path: str
408
+ port: int
409
+ options: dict[str, Any] = {}
410
+ extra_args: str = ""
411
+
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # API routes
415
+ # ---------------------------------------------------------------------------
416
+
417
+
418
+ @router.get("/api/launcher/models")
419
+ async def api_models(request: Request) -> dict[str, Any]:
420
+ """Scan model_dirs and return discovered model files."""
421
+ cfg = request.app.state.config
422
+ launcher_cfg = getattr(cfg, "launcher", None)
423
+ model_dirs: list[str] = launcher_cfg.model_dirs if launcher_cfg else []
424
+ # rglob / stat はブロッキング I/O。イベントループ(= プロキシ全体)を
425
+ # 止めないよう別スレッドへ退避する。
426
+ models = await asyncio.to_thread(_scan_models, model_dirs)
427
+ hw = await asyncio.to_thread(_detect_hardware)
428
+ for m in models:
429
+ m["recommendation"] = _model_recommendation(m.get("size_gb", 0.0), hw)
430
+ return {
431
+ "models": models,
432
+ "model_dirs": model_dirs,
433
+ "hardware": hw,
434
+ }
435
+
436
+
437
+ @router.get("/api/launcher/option-profiles")
438
+ async def api_option_profiles(request: Request) -> dict[str, Any]:
439
+ """Return option_profiles from providers.yaml launcher config."""
440
+ cfg = request.app.state.config
441
+ launcher_cfg = getattr(cfg, "launcher", None)
442
+ if not launcher_cfg:
443
+ return {"profiles": {}, "_note": "launcher: block not found in providers.yaml"}
444
+ if not launcher_cfg.option_profiles:
445
+ return {"profiles": {}, "_note": "option_profiles is empty — add option_profiles: under launcher: in providers.yaml"}
446
+ result: dict[str, list[dict]] = {}
447
+ for backend, profiles in launcher_cfg.option_profiles.items():
448
+ result[backend] = [{"name": p.name, "args": p.args} for p in profiles]
449
+ return {"profiles": result}
450
+
451
+
452
+ @router.get("/api/launcher/config-debug")
453
+ async def api_launcher_config_debug(request: Request) -> dict[str, Any]:
454
+ """Return the effective launcher config for troubleshooting."""
455
+ cfg = request.app.state.config
456
+ launcher_cfg = getattr(cfg, "launcher", None)
457
+ if not launcher_cfg:
458
+ return {"launcher": None, "message": "launcher: block not found in providers.yaml"}
459
+ return {
460
+ "launcher": {
461
+ "model_dirs": launcher_cfg.model_dirs,
462
+ "backends": {k: {"binary": v.binary} for k, v in launcher_cfg.backends.items()},
463
+ "option_profiles": {
464
+ k: [p.name for p in v]
465
+ for k, v in launcher_cfg.option_profiles.items()
466
+ },
467
+ },
468
+ }
469
+
470
+
471
+ @router.get("/api/launcher/processes")
472
+ async def api_processes(request: Request) -> dict[str, Any]:
473
+ """List all managed processes."""
474
+ reg = _registry(request)
475
+ return {
476
+ "processes": [
477
+ {
478
+ "id": p.id,
479
+ "name": p.name,
480
+ "backend": p.backend,
481
+ "model_path": p.model_path,
482
+ "port": p.port,
483
+ "status": p.status,
484
+ "pid": p.pid,
485
+ "returncode": p.returncode,
486
+ }
487
+ for p in reg.all()
488
+ ]
489
+ }
490
+
491
+
492
+ @router.get("/api/launcher/backends")
493
+ async def api_backends(request: Request) -> dict[str, Any]:
494
+ """Return resolved binary paths for each backend.
495
+
496
+ Used by the UI to display which executable will be invoked.
497
+ Shows configured path (from providers.yaml) or the PATH default.
498
+ """
499
+ cfg = request.app.state.config
500
+ launcher_cfg = getattr(cfg, "launcher", None)
501
+ backends_cfg = (
502
+ launcher_cfg.backends
503
+ if (launcher_cfg and launcher_cfg.backends)
504
+ else None
505
+ )
506
+ # is_file / shutil.which はブロッキング I/O。別スレッドへ退避する。
507
+ result = await asyncio.to_thread(_resolve_backends_sync, backends_cfg)
508
+ return {"backends": result}
509
+
510
+
511
+ @router.post("/api/launcher/start")
512
+ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
513
+ """Start a new backend process."""
514
+ # Resolve binary path from providers.yaml launcher.backends
515
+ cfg = request.app.state.config
516
+ launcher_cfg = getattr(cfg, "launcher", None)
517
+ configured_binary: str | None = None
518
+ if launcher_cfg and launcher_cfg.backends:
519
+ bc = launcher_cfg.backends.get(req.backend)
520
+ if bc and bc.binary:
521
+ configured_binary = bc.binary
522
+
523
+ try:
524
+ cmd = _build_cmd(
525
+ req.backend, req.model_path, req.port,
526
+ req.options, req.extra_args,
527
+ binary=configured_binary,
528
+ )
529
+ except ValueError as exc:
530
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
531
+
532
+ proc_id = uuid.uuid4().hex[:8]
533
+ proc = ManagedProcess(
534
+ id=proc_id,
535
+ name=req.name,
536
+ backend=req.backend,
537
+ model_path=req.model_path,
538
+ port=req.port,
539
+ options=req.options,
540
+ extra_args=req.extra_args,
541
+ status="starting",
542
+ )
543
+ proc.log_tail.append(f"[launcher] cmd: {' '.join(cmd)}")
544
+
545
+ try:
546
+ p = await asyncio.create_subprocess_exec(
547
+ *cmd,
548
+ stdout=asyncio.subprocess.PIPE,
549
+ stderr=asyncio.subprocess.PIPE,
550
+ )
551
+ except FileNotFoundError:
552
+ raise HTTPException(
553
+ status_code=400,
554
+ detail=f"Executable not found: {cmd[0]!r}. Is {req.backend} installed?",
555
+ ) from None
556
+ except Exception as exc:
557
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
558
+
559
+ proc._proc = p
560
+ proc.pid = p.pid
561
+ proc.status = "running"
562
+ proc.log_tail.append(f"[launcher] started PID {p.pid}")
563
+
564
+ _registry(request).add(proc)
565
+ _task = asyncio.create_task(_tail_logs(proc))
566
+ _background_tasks.add(_task)
567
+ _task.add_done_callback(_background_tasks.discard)
568
+
569
+ return {"id": proc_id, "pid": p.pid, "command": cmd}
570
+
571
+
572
+ @router.post("/api/launcher/stop/{proc_id}")
573
+ async def api_stop(proc_id: str, request: Request) -> dict[str, Any]:
574
+ """Terminate a running process (SIGTERM, then SIGKILL after 5s)."""
575
+ try:
576
+ proc = _registry(request).get(proc_id)
577
+ except KeyError:
578
+ raise HTTPException(
579
+ status_code=404, detail=f"Process {proc_id!r} not found.") from None
580
+
581
+ if proc._proc and proc.status == "running":
582
+ proc._proc.terminate()
583
+ proc.log_tail.append("[launcher] SIGTERM sent")
584
+ try:
585
+ await asyncio.wait_for(proc._proc.wait(), timeout=5.0)
586
+ except TimeoutError:
587
+ proc._proc.kill()
588
+ proc.log_tail.append("[launcher] SIGKILL sent (timeout)")
589
+ proc.status = "stopped"
590
+ proc.pid = None
591
+
592
+ return {"id": proc_id, "status": proc.status}
593
+
594
+
595
+ @router.delete("/api/launcher/processes/{proc_id}")
596
+ async def api_delete(proc_id: str, request: Request) -> dict[str, Any]:
597
+ """Remove a stopped process from the registry."""
598
+ reg = _registry(request)
599
+ try:
600
+ proc = reg.get(proc_id)
601
+ except KeyError:
602
+ raise HTTPException(
603
+ status_code=404, detail=f"Process {proc_id!r} not found.") from None
604
+ if proc.status == "running":
605
+ raise HTTPException(status_code=400, detail="Stop the process before deleting.")
606
+ reg.remove(proc_id)
607
+ return {"deleted": proc_id}
608
+
609
+
610
+ @router.get("/api/launcher/logs/{proc_id}")
611
+ async def api_logs(proc_id: str, request: Request, n: int = 100) -> dict[str, Any]:
612
+ """Return the last N log lines for a process."""
613
+ try:
614
+ proc = _registry(request).get(proc_id)
615
+ except KeyError:
616
+ raise HTTPException(
617
+ status_code=404, detail=f"Process {proc_id!r} not found.") from None
618
+ tail = list(proc.log_tail)
619
+ return {"id": proc_id, "logs": tail[-n:], "total": len(tail)}
620
+
621
+
622
+ @router.get("/api/launcher/suggest")
623
+ async def api_suggest(model_path: str = "") -> dict[str, Any]:
624
+ """Suggest launch flags for the given model based on detected hardware.
625
+
626
+ クライアントの「推奨値」ボタンから呼ばれる。値はあくまで目安。
627
+ """
628
+ hw = await asyncio.to_thread(_detect_hardware)
629
+ size_gb = 0.0
630
+ if model_path:
631
+ size_gb = await asyncio.to_thread(_model_size_gb, model_path)
632
+ return {
633
+ "extra_args": _suggest_launch_flags(size_gb, hw),
634
+ "hardware": hw,
635
+ "size_gb": round(size_gb, 2),
636
+ }
637
+
638
+
639
+ # ---------------------------------------------------------------------------
640
+ # HTML UI
641
+ # ---------------------------------------------------------------------------
642
+
643
+ _LAUNCHER_HTML = r"""<!doctype html>
644
+ <html lang="ja">
645
+ <head>
646
+ <meta charset="utf-8" />
647
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
648
+ <title>CodeRouter Launcher</title>
649
+ <script src="https://cdn.tailwindcss.com"></script>
650
+ <style>
651
+ .dot { width:.5rem;height:.5rem;border-radius:9999px;display:inline-block; }
652
+ .tabnum { font-variant-numeric:tabular-nums; }
653
+ .log-box { font-family:monospace;font-size:.75rem;line-height:1.4;
654
+ overflow-y:auto;max-height:14rem;white-space:pre-wrap;word-break:break-all; }
655
+ .model-row:hover { background:rgba(255,255,255,.04);cursor:pointer; }
656
+ .model-row.selected { background:rgba(99,102,241,.15);border-left:2px solid #6366f1; }
657
+ input, select, textarea {
658
+ background:#1e293b;border:1px solid #334155;color:#f1f5f9;
659
+ border-radius:.375rem;padding:.35rem .6rem;width:100%;font-size:.875rem;
660
+ outline:none;
661
+ }
662
+ input:focus, select:focus, textarea:focus { border-color:#6366f1; }
663
+ .btn-primary {
664
+ background:#6366f1;color:#fff;padding:.4rem 1rem;border-radius:.375rem;
665
+ font-size:.875rem;font-weight:600;cursor:pointer;transition:background .15s;
666
+ }
667
+ .btn-primary:hover { background:#4f46e5; }
668
+ .btn-primary:disabled { background:#475569;cursor:not-allowed; }
669
+ .btn-sm {
670
+ padding:.25rem .6rem;border-radius:.25rem;font-size:.75rem;
671
+ cursor:pointer;font-weight:500;transition:background .15s;
672
+ }
673
+ .btn-red { background:#7f1d1d;color:#fca5a5; }
674
+ .btn-red:hover { background:#991b1b; }
675
+ .btn-slate { background:#334155;color:#94a3b8; }
676
+ .btn-slate:hover { background:#475569; }
677
+ .btn-indigo { background:#312e81;color:#a5b4fc; }
678
+ .btn-indigo:hover { background:#3730a3; }
679
+ </style>
680
+ </head>
681
+ <body class="bg-slate-950 text-slate-100 min-h-screen font-sans">
682
+
683
+ <!-- Header -->
684
+ <header class="border-b border-slate-800 px-6 py-3">
685
+ <div class="max-w-7xl mx-auto flex items-center gap-x-6 text-sm">
686
+ <span class="text-lg font-semibold tracking-tight">CodeRouter</span>
687
+ <a href="/dashboard" class="text-slate-400 hover:text-slate-200 transition-colors">Dashboard</a>
688
+ <span class="text-slate-100 font-medium border-b border-indigo-400 pb-0.5">Launcher</span>
689
+ <span id="status-msg" class="ml-auto text-xs text-slate-500"></span>
690
+ </div>
691
+ </header>
692
+
693
+ <main class="max-w-7xl mx-auto p-4 md:p-6 space-y-4">
694
+
695
+ <!-- Row 1: Models + Launch form -->
696
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
697
+
698
+ <!-- Models panel -->
699
+ <section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 flex flex-col gap-3">
700
+ <div class="flex items-center justify-between">
701
+ <div class="flex items-baseline gap-2">
702
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400">Models</h2>
703
+ <span id="hw-info" class="text-xs text-slate-500"></span>
704
+ </div>
705
+ <button onclick="fetchModels()" class="btn-sm btn-slate">↻ スキャン</button>
706
+ </div>
707
+ <div id="model-dirs" class="text-xs text-slate-500 space-y-0.5"></div>
708
+ <div id="model-list" class="divide-y divide-slate-800 text-sm flex-1 overflow-y-auto max-h-64">
709
+ <div class="py-2 text-slate-500 text-xs">スキャン中…</div>
710
+ </div>
711
+ </section>
712
+
713
+ <!-- Launch form -->
714
+ <section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 flex flex-col gap-3">
715
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400">Launch</h2>
716
+
717
+ <div class="grid grid-cols-2 gap-2">
718
+ <div>
719
+ <label class="block text-xs text-slate-400 mb-1">名前</label>
720
+ <input id="f-name" type="text" placeholder="my-qwen" />
721
+ </div>
722
+ <div>
723
+ <label class="block text-xs text-slate-400 mb-1">ポート</label>
724
+ <input id="f-port" type="number" value="8080" min="1024" max="65535" />
725
+ </div>
726
+ </div>
727
+
728
+ <div>
729
+ <label class="block text-xs text-slate-400 mb-1">バックエンド</label>
730
+ <select id="f-backend" onchange="onBackendChange()">
731
+ <option value="llama.cpp">llama.cpp</option>
732
+ <option value="vllm">vllm</option>
733
+ <option value="mlx">mlx</option>
734
+ </select>
735
+ <div id="binary-hint" class="mt-1 text-xs text-slate-500 min-h-[1.2rem]"></div>
736
+ </div>
737
+
738
+ <div>
739
+ <label class="block text-xs text-slate-400 mb-1">モデルパス</label>
740
+ <input id="f-model" type="text" placeholder="← モデル一覧から選択 or 直接入力" />
741
+ </div>
742
+
743
+ <div>
744
+ <label class="block text-xs text-slate-400 mb-1">オプションプロファイル</label>
745
+ <select id="f-profile" onchange="onProfileChange()">
746
+ <option value="">-- なし --</option>
747
+ </select>
748
+ <div id="profile-args" class="mt-1 text-xs font-mono text-slate-400 bg-slate-800/50 rounded p-2 hidden"></div>
749
+ </div>
750
+
751
+ <div>
752
+ <div class="flex items-center justify-between mb-1">
753
+ <label class="block text-xs text-slate-400">追加オプション(自由入力)</label>
754
+ <button onclick="suggestOptions()" class="btn-sm btn-slate">⚙ 推奨値</button>
755
+ </div>
756
+ <input id="f-extra" type="text" placeholder="-ngl 99 --threads 8" />
757
+ </div>
758
+
759
+ <button id="btn-launch" onclick="launchProcess()" class="btn-primary w-full mt-1">
760
+ ▶ 起動
761
+ </button>
762
+ <div id="launch-err" class="text-xs text-red-400 hidden"></div>
763
+ </section>
764
+ </div>
765
+
766
+ <!-- Row 2: Running processes -->
767
+ <section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
768
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-3">Processes</h2>
769
+ <div class="overflow-x-auto">
770
+ <table class="w-full text-sm tabnum">
771
+ <thead class="text-slate-500 text-left">
772
+ <tr>
773
+ <th class="pb-2 font-medium">NAME</th>
774
+ <th class="pb-2 font-medium">BACKEND</th>
775
+ <th class="pb-2 font-medium">MODEL</th>
776
+ <th class="pb-2 font-medium text-right">PORT</th>
777
+ <th class="pb-2 font-medium text-right">PID</th>
778
+ <th class="pb-2 font-medium">STATUS</th>
779
+ <th class="pb-2 font-medium text-right">ACTIONS</th>
780
+ </tr>
781
+ </thead>
782
+ <tbody id="proc-table" class="divide-y divide-slate-800">
783
+ <tr><td colspan="7" class="py-3 text-slate-500 text-xs">プロセスなし</td></tr>
784
+ </tbody>
785
+ </table>
786
+ </div>
787
+ </section>
788
+
789
+ <!-- Row 3: Log viewer (hidden until a process is selected) -->
790
+ <section id="log-panel" class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 hidden">
791
+ <div class="flex items-center justify-between mb-2">
792
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400">
793
+ Log: <span id="log-title" class="text-slate-200 normal-case">—</span>
794
+ </h2>
795
+ <div class="flex gap-2">
796
+ <button onclick="refreshLogs()" class="btn-sm btn-slate">↻ 更新</button>
797
+ <button onclick="closeLog()" class="btn-sm btn-slate">✕ 閉じる</button>
798
+ </div>
799
+ </div>
800
+ <div id="log-box" class="log-box bg-slate-950 rounded p-3 text-slate-300"></div>
801
+ </section>
802
+
803
+ </main>
804
+
805
+ <script>
806
+ (() => {
807
+ "use strict";
808
+
809
+ const POLL_MS = 3000;
810
+ let allProfiles = {}; // backend → [{name, args}]
811
+ const _modelCache = {}; // index → {path, name, dir, size_gb}
812
+ let selectedLogId = null;
813
+ let logAutoScroll = true;
814
+ let _lastAutoName = ""; // selectModel が自動入力した名前
815
+
816
+ // ── Helpers ──────────────────────────────────────────────────────────────
817
+
818
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c =>
819
+ ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])
820
+ );
821
+
822
+ const statusMsg = (msg, ok = true) => {
823
+ const el = document.getElementById("status-msg");
824
+ el.textContent = msg;
825
+ el.className = "ml-auto text-xs " + (ok ? "text-slate-500" : "text-red-400");
826
+ if (ok) setTimeout(() => { if (el.textContent === msg) el.textContent = ""; }, 3000);
827
+ };
828
+
829
+ const showLaunchErr = (msg) => {
830
+ const el = document.getElementById("launch-err");
831
+ if (msg) { el.textContent = msg; el.classList.remove("hidden"); }
832
+ else { el.textContent = ""; el.classList.add("hidden"); }
833
+ };
834
+
835
+ const statusDot = (status) => {
836
+ const map = {running:"bg-green-500", starting:"bg-yellow-500",
837
+ stopped:"bg-slate-500", error:"bg-red-500"};
838
+ return `<span class="dot ${map[status] || "bg-slate-500"} mr-1.5"></span>${esc(status)}`;
839
+ };
840
+
841
+ // ── Models ───────────────────────────────────────────────────────────────
842
+
843
+ window.fetchModels = async () => {
844
+ statusMsg("モデルスキャン中…");
845
+ try {
846
+ const r = await fetch("/api/launcher/models");
847
+ const d = await r.json();
848
+ renderModelDirs(d.model_dirs || []);
849
+ renderHwInfo(d.hardware);
850
+ renderModels(d.models || []);
851
+ statusMsg(`モデル ${d.models.length} 件`);
852
+ } catch (e) {
853
+ statusMsg("モデルスキャン失敗: " + e.message, false);
854
+ }
855
+ };
856
+
857
+ const renderHwInfo = (hw) => {
858
+ const el = document.getElementById("hw-info");
859
+ if (!el) return;
860
+ if (!hw) { el.textContent = ""; return; }
861
+ const gpu = {metal: "Metal", cuda: "CUDA", cpu: "CPU"}[hw.gpu] || "CPU";
862
+ let s = `${gpu} · RAM ${hw.ram_gb}GB`;
863
+ if (hw.gpu === "cuda" && hw.vram_gb) s += ` · VRAM ${hw.vram_gb}GB`;
864
+ el.textContent = s;
865
+ };
866
+
867
+ const renderModelDirs = (dirs) => {
868
+ const el = document.getElementById("model-dirs");
869
+ el.innerHTML = dirs.length
870
+ ? dirs.map(d => `<div class="truncate">📂 ${esc(d)}</div>`).join("")
871
+ : '<div class="text-slate-600">model_dirs 未設定 (providers.yaml)</div>';
872
+ };
873
+
874
+ const recBadge = (rec) => {
875
+ if (!rec || !rec.label) return "";
876
+ if (rec.level === "ok")
877
+ return `<span class="text-xs shrink-0" style="color:#22c55e">✓ ${esc(rec.label)}</span>`;
878
+ if (rec.level === "warn")
879
+ return `<span class="text-xs shrink-0" style="color:#eab308">⚠ ${esc(rec.label)}</span>`;
880
+ return "";
881
+ };
882
+
883
+ const renderModels = (models) => {
884
+ const el = document.getElementById("model-list");
885
+ if (!models.length) {
886
+ el.innerHTML = '<div class="py-2 text-slate-500 text-xs">モデルが見つかりません</div>';
887
+ return;
888
+ }
889
+ el.innerHTML = models.map((m, i) => {
890
+ _modelCache[i] = m;
891
+ return `
892
+ <div class="model-row px-1 py-2" onclick="selectModel(${i})">
893
+ <div class="flex justify-between items-baseline gap-2">
894
+ <span class="truncate">${esc(m.name)}</span>
895
+ <span class="flex items-baseline gap-2 shrink-0">
896
+ ${recBadge(m.recommendation)}
897
+ <span class="text-slate-400 tabnum">${m.size_gb} GB</span>
898
+ </span>
899
+ </div>
900
+ <div class="text-slate-500 text-xs truncate">${esc(m.dir)}</div>
901
+ </div>`;
902
+ }).join("");
903
+ };
904
+
905
+ window.suggestOptions = async () => {
906
+ const model = document.getElementById("f-model").value.trim();
907
+ if (!model) { showLaunchErr("先にモデルを選択してください"); return; }
908
+ try {
909
+ const r = await fetch("/api/launcher/suggest?model_path="
910
+ + encodeURIComponent(model));
911
+ const d = await r.json();
912
+ if (!r.ok) { showLaunchErr(d.detail || "推奨値の取得に失敗"); return; }
913
+ document.getElementById("f-extra").value = d.extra_args;
914
+ showLaunchErr("");
915
+ statusMsg("推奨値を設定(目安): " + d.extra_args);
916
+ } catch (e) {
917
+ showLaunchErr(e.message);
918
+ }
919
+ };
920
+
921
+ window.selectModel = (idx) => {
922
+ const m = _modelCache[idx];
923
+ if (!m) return;
924
+ document.getElementById("f-model").value = m.path;
925
+ // 名前が空 or 前回自動入力した値のまま → 選択モデル名で更新(手入力は保護)
926
+ const nameEl = document.getElementById("f-name");
927
+ if (!nameEl.value || nameEl.value === _lastAutoName) {
928
+ _lastAutoName = m.name.replace(/\.[^.]+$/, "").slice(0, 30);
929
+ nameEl.value = _lastAutoName;
930
+ }
931
+ document.querySelectorAll(".model-row").forEach((r, i) => {
932
+ r.classList.toggle("selected", i === idx);
933
+ });
934
+ };
935
+
936
+ // ── Backends (binary paths) ───────────────────────────────────────────────
937
+
938
+ let allBackends = {};
939
+
940
+ const fetchBackends = async () => {
941
+ try {
942
+ const r = await fetch("/api/launcher/backends");
943
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
944
+ const d = await r.json();
945
+ allBackends = d.backends || {};
946
+ } catch (e) {
947
+ console.error("[Launcher] fetchBackends failed:", e);
948
+ }
949
+ renderBinaryHint(); // always call outside try-catch so errors surface
950
+ };
951
+
952
+ const renderBinaryHint = () => {
953
+ const backend = document.getElementById("f-backend").value;
954
+ const hint = document.getElementById("binary-hint");
955
+ const btn = document.getElementById("btn-launch");
956
+ const info = allBackends[backend];
957
+ if (!info) {
958
+ hint.innerHTML = '<span class="text-slate-600 text-xs">バイナリ確認中…</span>';
959
+ return;
960
+ }
961
+ const dotColor = info.found ? "#22c55e" : "#ef4444"; // green-500 / red-500
962
+ const dot = `<svg style="display:inline;vertical-align:middle;margin-right:5px;flex-shrink:0" width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="${dotColor}"/></svg>`;
963
+ const label = info.is_custom ? "カスタム設定" : "PATH";
964
+ const statusText = info.found ? "利用可" : "見つかりません";
965
+ const pathColor = info.found
966
+ ? (info.is_custom ? "#818cf8" : "#4ade80") // indigo-400 / green-400
967
+ : "#f87171"; // red-400
968
+ hint.innerHTML = dot
969
+ + `<span style="font-family:monospace;color:${pathColor};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(info.resolved)}</span>`
970
+ + `<span style="color:#64748b;margin-left:6px;white-space:nowrap;flex-shrink:0">(${label} — ${statusText})</span>`;
971
+ hint.style.cssText = "display:flex;align-items:center;gap:0;overflow:hidden";
972
+ // Enable/disable launch button based on binary availability
973
+ if (!info.found) {
974
+ btn.disabled = true;
975
+ showLaunchErr(`⚠ "${esc(info.resolved)}" が見つかりません。選択中のバックエンド (${esc(backend)}) をインストールするか、providers.yaml の launcher.backends.${esc(backend)}.binary にフルパスを設定してください。`);
976
+ } else {
977
+ btn.disabled = false;
978
+ // Clear error only if it was a binary-not-found error
979
+ const errEl = document.getElementById("launch-err");
980
+ if (errEl.textContent.startsWith("⚠")) showLaunchErr("");
981
+ }
982
+ };
983
+
984
+ // ── Option profiles ──────────────────────────────────────────────────────
985
+
986
+ const fetchProfiles = async () => {
987
+ try {
988
+ const r = await fetch("/api/launcher/option-profiles");
989
+ const d = await r.json();
990
+ allProfiles = d.profiles || {};
991
+ populateProfileSelect();
992
+ // Show hint if profiles are empty (misconfigured YAML)
993
+ if (d._note) console.warn("[Launcher] option-profiles:", d._note);
994
+ } catch (e) {
995
+ console.error("[Launcher] fetchProfiles error:", e);
996
+ }
997
+ };
998
+
999
+ const populateProfileSelect = () => {
1000
+ const backend = document.getElementById("f-backend").value;
1001
+ const sel = document.getElementById("f-profile");
1002
+ const profiles = allProfiles[backend] || [];
1003
+ const hint = profiles.length === 0
1004
+ ? '<option value="" disabled style="color:#64748b">providers.yaml に option_profiles を追加すると選べます</option>'
1005
+ : '';
1006
+ sel.innerHTML = '<option value="">-- なし --</option>' + hint +
1007
+ profiles.map((p, i) => `<option value="${i}">${esc(p.name)}</option>`).join("");
1008
+ renderProfileArgs();
1009
+ };
1010
+
1011
+ window.onBackendChange = () => {
1012
+ populateProfileSelect();
1013
+ renderBinaryHint();
1014
+ };
1015
+
1016
+ // renderProfileArgs は下で const 宣言されるため、宣言前参照(TDZ)を避けて
1017
+ // 呼び出し時に解決されるラッパーにする。
1018
+ window.onProfileChange = () => renderProfileArgs();
1019
+
1020
+ const renderProfileArgs = () => {
1021
+ const backend = document.getElementById("f-backend").value;
1022
+ const idx = document.getElementById("f-profile").value;
1023
+ const box = document.getElementById("profile-args");
1024
+ if (idx === "") { box.classList.add("hidden"); box.textContent = ""; return; }
1025
+ const profiles = allProfiles[backend] || [];
1026
+ const p = profiles[parseInt(idx)];
1027
+ if (!p) { box.classList.add("hidden"); return; }
1028
+ const lines = Object.entries(p.args).map(([k, v]) =>
1029
+ typeof v === "boolean" ? (v ? k : `# ${k} (disabled)`) : `${k} ${v}`
1030
+ );
1031
+ box.textContent = lines.join(" ");
1032
+ box.classList.remove("hidden");
1033
+ };
1034
+
1035
+ const selectedProfileArgs = () => {
1036
+ const backend = document.getElementById("f-backend").value;
1037
+ const idx = document.getElementById("f-profile").value;
1038
+ if (idx === "") return {};
1039
+ const profiles = allProfiles[backend] || [];
1040
+ const p = profiles[parseInt(idx)];
1041
+ return p ? p.args : {};
1042
+ };
1043
+
1044
+ // ── Launch ───────────────────────────────────────────────────────────────
1045
+
1046
+ window.launchProcess = async () => {
1047
+ showLaunchErr("");
1048
+ const name = document.getElementById("f-name").value.trim();
1049
+ const port = parseInt(document.getElementById("f-port").value);
1050
+ const backend = document.getElementById("f-backend").value;
1051
+ const model = document.getElementById("f-model").value.trim();
1052
+ const extra = document.getElementById("f-extra").value.trim();
1053
+
1054
+ if (!name) { showLaunchErr("名前を入力してください"); return; }
1055
+ if (!model) { showLaunchErr("モデルパスを入力してください"); return; }
1056
+ if (!port || port < 1024 || port > 65535) { showLaunchErr("ポートは 1024-65535"); return; }
1057
+
1058
+ const btn = document.getElementById("btn-launch");
1059
+ btn.disabled = true;
1060
+ btn.textContent = "起動中…";
1061
+
1062
+ try {
1063
+ const res = await fetch("/api/launcher/start", {
1064
+ method: "POST",
1065
+ headers: {"Content-Type": "application/json"},
1066
+ body: JSON.stringify({name, backend, model_path: model, port,
1067
+ options: selectedProfileArgs(), extra_args: extra}),
1068
+ });
1069
+ const d = await res.json();
1070
+ if (!res.ok) { showLaunchErr(d.detail || "起動失敗"); return; }
1071
+ statusMsg(`起動: ${name} (PID ${d.pid})`);
1072
+ // reset form name/port only
1073
+ document.getElementById("f-name").value = "";
1074
+ document.getElementById("f-port").value = String(port + 1);
1075
+ } catch (e) {
1076
+ showLaunchErr(e.message);
1077
+ } finally {
1078
+ btn.disabled = false;
1079
+ btn.textContent = "▶ 起動";
1080
+ }
1081
+ };
1082
+
1083
+ // ── Processes ────────────────────────────────────────────────────────────
1084
+
1085
+ const fetchProcesses = async () => {
1086
+ try {
1087
+ const r = await fetch("/api/launcher/processes");
1088
+ const d = await r.json();
1089
+ renderProcesses(d.processes || []);
1090
+ } catch (_) {}
1091
+ };
1092
+
1093
+ const renderProcesses = (procs) => {
1094
+ const tbody = document.getElementById("proc-table");
1095
+ if (!procs.length) {
1096
+ tbody.innerHTML = '<tr><td colspan="7" class="py-3 text-slate-500 text-xs">プロセスなし</td></tr>';
1097
+ return;
1098
+ }
1099
+ tbody.innerHTML = procs.map(p => {
1100
+ const modelName = p.model_path.split("/").pop();
1101
+ const stopBtn = p.status === "running"
1102
+ ? `<button onclick="stopProc('${p.id}')" class="btn-sm btn-red">■ 停止</button>`
1103
+ : "";
1104
+ const delBtn = p.status !== "running"
1105
+ ? `<button onclick="deleteProc('${p.id}')" class="btn-sm btn-slate ml-1">✕</button>`
1106
+ : "";
1107
+ const logBtn = `<button onclick="openLog('${p.id}','${esc(p.name)}')" class="btn-sm btn-indigo ml-1">📋 ログ</button>`;
1108
+ return `<tr>
1109
+ <td class="py-2 pr-3 font-medium">${esc(p.name)}</td>
1110
+ <td class="py-2 pr-3 text-slate-400">${esc(p.backend)}</td>
1111
+ <td class="py-2 pr-3 text-slate-400 truncate max-w-[10rem]" title="${esc(p.model_path)}">${esc(modelName)}</td>
1112
+ <td class="py-2 pr-3 text-right">${p.port}</td>
1113
+ <td class="py-2 pr-3 text-right text-slate-400">${p.pid ?? "—"}</td>
1114
+ <td class="py-2 pr-3">${statusDot(p.status)}</td>
1115
+ <td class="py-2 text-right whitespace-nowrap">${stopBtn}${logBtn}${delBtn}</td>
1116
+ </tr>`;
1117
+ }).join("");
1118
+ };
1119
+
1120
+ window.stopProc = async (id) => {
1121
+ if (!confirm("プロセスを停止しますか?")) return;
1122
+ const r = await fetch(`/api/launcher/stop/${id}`, {method:"POST"});
1123
+ const d = await r.json();
1124
+ statusMsg(`停止: ${d.status}`);
1125
+ await fetchProcesses();
1126
+ if (selectedLogId === id) await refreshLogs();
1127
+ };
1128
+
1129
+ window.deleteProc = async (id) => {
1130
+ if (!confirm("レジストリから削除しますか?")) return;
1131
+ await fetch(`/api/launcher/processes/${id}`, {method:"DELETE"});
1132
+ if (selectedLogId === id) closeLog();
1133
+ await fetchProcesses();
1134
+ };
1135
+
1136
+ // ── Log viewer ───────────────────────────────────────────────────────────
1137
+
1138
+ window.openLog = async (id, name) => {
1139
+ selectedLogId = id;
1140
+ document.getElementById("log-title").textContent = name;
1141
+ document.getElementById("log-panel").classList.remove("hidden");
1142
+ await refreshLogs();
1143
+ };
1144
+
1145
+ window.refreshLogs = async () => {
1146
+ if (!selectedLogId) return;
1147
+ try {
1148
+ const r = await fetch(`/api/launcher/logs/${selectedLogId}?n=200`);
1149
+ const d = await r.json();
1150
+ const box = document.getElementById("log-box");
1151
+ box.textContent = d.logs.join("\n") || "(ログなし)";
1152
+ if (logAutoScroll) box.scrollTop = box.scrollHeight;
1153
+ } catch (e) {
1154
+ document.getElementById("log-box").textContent = "ログ取得失敗: " + e.message;
1155
+ }
1156
+ };
1157
+
1158
+ window.closeLog = () => {
1159
+ selectedLogId = null;
1160
+ document.getElementById("log-panel").classList.add("hidden");
1161
+ };
1162
+
1163
+ // ── Init + polling ───────────────────────────────────────────────────────
1164
+
1165
+ const init = async () => {
1166
+ await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses()]);
1167
+ };
1168
+
1169
+ const poll = async () => {
1170
+ await fetchProcesses();
1171
+ if (selectedLogId) await refreshLogs();
1172
+ };
1173
+
1174
+ init();
1175
+ setInterval(poll, POLL_MS);
1176
+ })();
1177
+ </script>
1178
+
1179
+ </body>
1180
+ </html>
1181
+ """
1182
+
1183
+
1184
+ @router.get("/launcher", response_class=HTMLResponse)
1185
+ async def launcher_page() -> HTMLResponse:
1186
+ """Serve the launcher single-page UI."""
1187
+ return HTMLResponse(content=_LAUNCHER_HTML)