diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c98fb3..39d46bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,12 @@ on: jobs: test: - name: Python ${{ matrix.python-version }} - runs-on: ubuntu-latest + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [ubuntu-latest, windows-latest] python-version: ["3.10", "3.11", "3.12"] steps: @@ -25,7 +26,7 @@ jobs: cache: pip - name: Install package - run: python -m pip install -e '.[dev]' + run: python -m pip install -e ".[dev]" - name: Lint run: ruff check . diff --git a/pyproject.toml b/pyproject.toml index e7e9ef3..d412fbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ classifiers = [ "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3 :: Only", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] diff --git a/src/arc_llama/agent/tools.py b/src/arc_llama/agent/tools.py index 2c13732..a346d7d 100644 --- a/src/arc_llama/agent/tools.py +++ b/src/arc_llama/agent/tools.py @@ -14,6 +14,7 @@ import json import os import subprocess +import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -391,6 +392,9 @@ def run_command(command: str, root: Path, timeout: float = 60.0) -> ToolResult: f"Error: git history-mutating command '{bad_prefix}' is not allowed.", error=True, ) + env = os.environ.copy() + if sys.platform != "win32": + env.update({"PS1": "", "TERM": "dumb"}) try: result = subprocess.run( command, @@ -399,7 +403,7 @@ def run_command(command: str, root: Path, timeout: float = 60.0) -> ToolResult: capture_output=True, text=True, timeout=timeout, - env={**os.environ, "PS1": "", "TERM": "dumb"}, + env=env, ) except subprocess.TimeoutExpired: return ToolResult( @@ -454,7 +458,7 @@ def search_files(pattern: str, root: Path, path_glob: str = "*") -> ToolResult: with p.open("r", encoding="utf-8", errors="ignore") as f: for i, line in enumerate(f, start=1): if pattern in line: - rel = p.relative_to(root) + rel = p.relative_to(root).as_posix() matches.append(f"{rel}:{i}: {line.rstrip()}") except OSError: continue diff --git a/src/arc_llama/benchmark.py b/src/arc_llama/benchmark.py index fae87e8..6c72806 100644 --- a/src/arc_llama/benchmark.py +++ b/src/arc_llama/benchmark.py @@ -8,18 +8,15 @@ """ from __future__ import annotations -import json import logging -import statistics import time -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass from pathlib import Path from typing import Any import httpx -from arc_llama.config import Config, ModelConfig, load_config -from arc_llama.recipes import KVCacheType, default_recipe, suggest_ctx +from arc_llama.config import Config, load_config log = logging.getLogger("arc_llama.benchmark") diff --git a/src/arc_llama/chat_store.py b/src/arc_llama/chat_store.py index 4642033..c360d28 100644 --- a/src/arc_llama/chat_store.py +++ b/src/arc_llama/chat_store.py @@ -27,7 +27,7 @@ def to_dict(self) -> dict[str, Any]: return {"role": self.role, "content": self.content, "timestamp": self.timestamp} @classmethod - def from_dict(cls, data: dict[str, Any]) -> "ChatMessage": + def from_dict(cls, data: dict[str, Any]) -> ChatMessage: return cls( role=data.get("role", ""), content=data.get("content", ""), @@ -55,7 +55,7 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, data: dict[str, Any]) -> "Chat": + def from_dict(cls, data: dict[str, Any]) -> Chat: return cls( id=data.get("id", ""), title=data.get("title", "Untitled chat"), @@ -170,3 +170,46 @@ def wipe(self) -> None: if self.directory.exists(): shutil.rmtree(self.directory) self.directory.mkdir(parents=True, exist_ok=True) + + def export_all(self) -> list[dict[str, Any]]: + """Return every stored chat as a list of plain dicts.""" + return [chat.to_dict() for chat in self.list_chats()] + + def import_chats( + self, + data: list[dict[str, Any]], + *, + overwrite: bool = False, + ) -> dict[str, int | list[str]]: + """Import a list of chat dicts. + + Args: + data: list of chat dicts in the format produced by ``Chat.to_dict``. + overwrite: if True, replace an existing chat with the same id. + + Returns: + A summary dict with ``imported``, ``skipped``, and ``errors`` counts. + """ + imported = 0 + skipped = 0 + errors: list[str] = [] + for item in data: + if not isinstance(item, dict): + errors.append("skipped non-dict entry") + continue + chat_id = item.get("id") + if not chat_id: + errors.append("skipped chat with missing id") + continue + path = self._chat_path(chat_id) + if path.exists() and not overwrite: + skipped += 1 + continue + try: + chat = Chat.from_dict(item) + except (TypeError, ValueError) as e: + errors.append(f"{chat_id}: invalid chat data ({e})") + continue + self._save(chat) + imported += 1 + return {"imported": imported, "skipped": skipped, "errors": len(errors), "error_details": errors} diff --git a/src/arc_llama/cli.py b/src/arc_llama/cli.py index b24267c..6ed2429 100644 --- a/src/arc_llama/cli.py +++ b/src/arc_llama/cli.py @@ -17,8 +17,10 @@ """ from __future__ import annotations +import json import logging import os +import platform import shutil import subprocess import sys @@ -46,13 +48,38 @@ console = Console() +_IS_WINDOWS = sys.platform == "win32" + + +class _JsonFormatter(logging.Formatter): + """Emit log records as single-line JSON objects.""" + + def format(self, record: logging.LogRecord) -> str: + obj = { + "timestamp": self.formatTime(record), + "level": record.levelname, + "name": record.name, + "message": record.getMessage(), + } + if record.exc_info: + obj["exception"] = self.formatException(record.exc_info) + return json.dumps(obj, default=str) + def _setup_logging(verbose: bool) -> None: level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - ) + if os.environ.get("ARC_LLAMA_LOG_JSON"): + handler = logging.StreamHandler() + handler.setFormatter(_JsonFormatter()) + root = logging.getLogger() + root.setLevel(level) + root.handlers.clear() + root.addHandler(handler) + else: + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) def _save_or_die(cfg: Config, path: Path) -> None: @@ -137,8 +164,14 @@ def init( sys.exit(1) gpus = detect_gpus() if not gpus: - console.print("[red]No Intel GPUs detected.[/red]") - console.print("Run [bold]arc-llama doctor[/bold] for a diagnosis.") + if _IS_WINDOWS: + console.print( + "[yellow]No Intel GPUs detected — Windows auto-detection is not " + "supported yet. Create a config manually or run this on WSL.[/yellow]" + ) + else: + console.print("[red]No Intel GPUs detected.[/red]") + console.print("Run [bold]arc-llama doctor[/bold] for a diagnosis.") sys.exit(2) server_path = _resolve_llama_server(llama_server) cfg = init_config_from_detection(gpus, llama_server_path=server_path) @@ -178,12 +211,19 @@ def doctor(ctx: click.Context) -> None: config_path: Path = ctx.obj["config_path"] console.print("[bold]arc-llama doctor[/bold]\n") - # Kernel + driver - console.print(f" kernel: {os.uname().release}") - has_xe = Path("/sys/module/xe").exists() - has_i915 = Path("/sys/module/i915").exists() - console.print(f" xe driver: {'loaded' if has_xe else 'not loaded'}") - console.print(f" i915 driver: {'loaded' if has_i915 else 'not loaded'}") + # Kernel + driver (Linux-only diagnostics) + if _IS_WINDOWS: + console.print(f" platform: Windows {platform.release()}") + console.print( + " [dim]Kernel/driver checks are not available on Windows.[/dim]" + ) + else: + uname = platform.uname() + console.print(f" kernel: {uname.release}") + has_xe = Path("/sys/module/xe").exists() + has_i915 = Path("/sys/module/i915").exists() + console.print(f" xe driver: {'loaded' if has_xe else 'not loaded'}") + console.print(f" i915 driver: {'loaded' if has_i915 else 'not loaded'}") # GPU detection (enrich=True so clinfo populates VRAM where xe doesn't via sysfs) gpus = detect_gpus(enrich=True) @@ -214,33 +254,49 @@ def doctor(ctx: click.Context) -> None: path = shutil.which(tool) console.print(f" {tool:<14} {path or '— missing —'}") - # Permissions - console.print("\n user groups:") - try: - out = subprocess.run(["id", "-nG"], capture_output=True, text=True, timeout=2) - groups = out.stdout.split() - except (FileNotFoundError, subprocess.TimeoutExpired): - groups = [] - for needed in ("render", "video"): - ok = needed in groups - marker = "[green]ok[/green]" if ok else "[yellow]missing[/yellow]" - console.print(f" {needed:<14} {marker}") - if "render" not in groups or "video" not in groups: - console.print( - " [yellow]→ add yourself with `sudo usermod -aG render,video $USER` " - "and re-login.[/yellow]" - ) + # Permissions (Linux-only) + if _IS_WINDOWS: + console.print("\n user groups:") + console.print(" [dim]Group checks are not available on Windows.[/dim]") + else: + console.print("\n user groups:") + try: + out = subprocess.run(["id", "-nG"], capture_output=True, text=True, timeout=2) + groups = out.stdout.split() + except (FileNotFoundError, subprocess.TimeoutExpired): + groups = [] + for needed in ("render", "video"): + ok = needed in groups + marker = "[green]ok[/green]" if ok else "[yellow]missing[/yellow]" + console.print(f" {needed:<14} {marker}") + if "render" not in groups or "video" not in groups: + console.print( + " [yellow]→ add yourself with `sudo usermod -aG render,video $USER` " + "and re-login.[/yellow]" + ) # oneAPI - oneapi_setvars = Path("/opt/intel/oneapi/setvars.sh") console.print("\n oneAPI:") - if oneapi_setvars.exists(): - console.print(f" setvars.sh: {oneapi_setvars}") + if _IS_WINDOWS: + oneapi_setvars = Path( + os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + ) / "Intel" / "oneAPI" / "setvars.bat" + if oneapi_setvars.exists(): + console.print(f" setvars.bat: {oneapi_setvars}") + else: + console.print( + " [yellow]Intel oneAPI setvars.bat not found — install Intel " + "oneAPI Base Toolkit if you're building llama.cpp from source.[/yellow]" + ) else: - console.print( - " [yellow]/opt/intel/oneapi/setvars.sh missing — install Intel " - "oneAPI Base Toolkit if you're building llama.cpp from source.[/yellow]" - ) + oneapi_setvars = Path("/opt/intel/oneapi/setvars.sh") + if oneapi_setvars.exists(): + console.print(f" setvars.sh: {oneapi_setvars}") + else: + console.print( + " [yellow]/opt/intel/oneapi/setvars.sh missing — install Intel " + "oneAPI Base Toolkit if you're building llama.cpp from source.[/yellow]" + ) # Config console.print("\n config:") @@ -604,9 +660,14 @@ def _on_signal(signum: int, _frame) -> None: # noqa: ANN001 _shutdown_subprocesses() # Re-raise as default so uvicorn's own handler (or python) finishes the job. _signal.signal(signum, _signal.SIG_DFL) - os.kill(os.getpid(), signum) + if _IS_WINDOWS: + sys.exit(0) + else: + os.kill(os.getpid(), signum) - for s in (_signal.SIGTERM, _signal.SIGINT): + for s in (getattr(_signal, "SIGTERM", None), _signal.SIGINT): + if s is None: + continue try: _signal.signal(s, _on_signal) except (OSError, ValueError): @@ -643,6 +704,9 @@ def mtp_info_cmd(path: Path) -> None: @click.option("--write", is_flag=True, help="Write the unit to ~/.config/systemd/user/") def systemd_unit(service_name: str, description: str, write: bool) -> None: """Print (or write) a systemd --user unit for `arc-llama serve`.""" + if _IS_WINDOWS: + console.print("[red]systemd is not available on Windows.[/red]") + sys.exit(1) arc = shutil.which("arc-llama") if not arc: arc = str(Path(sys.argv[0]).resolve()) diff --git a/src/arc_llama/config.py b/src/arc_llama/config.py index ecf1ff7..e83451e 100644 --- a/src/arc_llama/config.py +++ b/src/arc_llama/config.py @@ -60,14 +60,24 @@ def _xdg_config_home() -> Path: + if sys.platform == "win32": + return Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") def _xdg_data_home() -> Path: + if sys.platform == "win32": + return Path( + os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local" + ) return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") def _xdg_state_home() -> Path: + if sys.platform == "win32": + return Path( + os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local" + ) return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") @@ -252,12 +262,52 @@ def _strip_none(obj: Any) -> Any: return obj +def migrate_config(raw: dict[str, Any]) -> dict[str, Any]: + """Bump an on-disk config dict to the current schema version. + + Currently a no-op migration (v1 → v1), but the hook exists so future schema + changes can be handled automatically when users upgrade arc-llama. + """ + version = int(raw.get("version", 1)) + if version > CONFIG_VERSION: + raise ValueError( + f"config version {version} is newer than the supported version " + f"{CONFIG_VERSION}; upgrade arc-llama" + ) + raw["version"] = CONFIG_VERSION + # Ensure all top-level sections exist so downstream code can assume them. + raw.setdefault("server", {}) + raw.setdefault("paths", {}) + raw.setdefault("gpus", []) + raw.setdefault("models", []) + raw.setdefault("upstreams", []) + return raw + + +def validate_config(raw: dict[str, Any]) -> None: + """Basic structural validation for a loaded config dict.""" + if not isinstance(raw.get("version"), int): + raise ValueError("config 'version' must be an integer") + if not isinstance(raw.get("server", {}), dict): + raise ValueError("config 'server' must be a table") + if not isinstance(raw.get("paths", {}), dict): + raise ValueError("config 'paths' must be a table") + if not isinstance(raw.get("gpus", []), list): + raise ValueError("config 'gpus' must be an array") + if not isinstance(raw.get("models", []), list): + raise ValueError("config 'models' must be an array") + if not isinstance(raw.get("upstreams", []), list): + raise ValueError("config 'upstreams' must be an array") + + def load_config(path: Path | None = None) -> Config: path = path or default_config_path() if not path.exists(): return Config() with open(path, "rb") as f: raw = _toml_load(f) + raw = migrate_config(raw) + validate_config(raw) return Config( version=int(raw.get("version", CONFIG_VERSION)), server=ServerConfig(**raw.get("server", {})), diff --git a/src/arc_llama/launcher.py b/src/arc_llama/launcher.py index 2025ed8..969bca5 100644 --- a/src/arc_llama/launcher.py +++ b/src/arc_llama/launcher.py @@ -16,6 +16,7 @@ import os import signal import subprocess +import sys import time from dataclasses import dataclass from pathlib import Path @@ -32,6 +33,15 @@ DEFAULT_HEALTH_TIMEOUT = 120 # seconds — generous for cold-start SYCL JIT HEALTH_POLL_INTERVAL = 1.5 +# Log rotation for llama-server subprocess logs. +_MAX_LOG_BYTES = 50 * 1024 * 1024 +_LOG_BACKUPS = 3 + +_IS_WINDOWS = sys.platform == "win32" +# Not defined on POSIX. Fallback lets the Windows code path stay import-safe +# when exercised under tests that monkeypatch _IS_WINDOWS on a Linux runner. +_CTRL_BREAK_EVENT = getattr(signal, "CTRL_BREAK_EVENT", signal.SIGTERM) + # Linux prctl(2) constant. We don't import a real binding — one syscall. _PR_SET_PDEATHSIG = 1 @@ -82,6 +92,32 @@ def _preexec_isolate_and_pdeathsig() -> None: pass +def _rotate_log(log_path: Path) -> None: + """Rotate an existing log file so it doesn't grow unbounded. + + Keeps up to ``_LOG_BACKUPS`` historic files (``.log.1``, ``.log.2``, ...). + """ + if not log_path.exists(): + return + try: + if log_path.stat().st_size < _MAX_LOG_BYTES: + return + except OSError: + return + for i in range(_LOG_BACKUPS, 0, -1): + src = log_path.parent / f"{log_path.name}.{i}" + dst = log_path.parent / f"{log_path.name}.{i + 1}" + if src.exists(): + try: + src.replace(dst) + except OSError: + pass + try: + log_path.replace(log_path.parent / f"{log_path.name}.1") + except OSError: + pass + + @dataclass class LaunchPlan: """Everything needed to invoke llama-server for one model.""" @@ -172,6 +208,7 @@ def __init__(self, plan: LaunchPlan, name: str = "llama-server"): self.process: subprocess.Popen[bytes] | None = None self.started_at: float | None = None self._log_file: Any = None # file handle opened in start(), closed in stop() + self._log_path: Path | None = None @property def is_running(self) -> bool: @@ -183,19 +220,32 @@ def start(self, log_dir: Path | None = None) -> None: return stdout = subprocess.DEVNULL stderr = subprocess.DEVNULL + self._log_path = None if log_dir is not None: log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"{self.name}.log" + _rotate_log(log_path) + self._log_path = log_path self._log_file = open(log_path, "ab") stdout = self._log_file stderr = subprocess.STDOUT log.info("[%s] starting: %s", self.name, " ".join(self.plan.argv)) + popen_kwargs: dict[str, Any] = {} + if _IS_WINDOWS: + # A new process group lets us terminate the whole subtree cleanly + # without Unix-specific killpg. The constant is only defined on + # Windows; the getattr guard keeps tests on Linux valid. + popen_kwargs["creationflags"] = getattr( + subprocess, "CREATE_NEW_PROCESS_GROUP", 0 + ) + else: + popen_kwargs["preexec_fn"] = _preexec_isolate_and_pdeathsig self.process = subprocess.Popen( self.plan.argv, env=self.plan.env, stdout=stdout, stderr=stderr, - preexec_fn=_preexec_isolate_and_pdeathsig, + **popen_kwargs, ) self.started_at = time.time() @@ -215,28 +265,65 @@ async def wait_ready(self, timeout: float = DEFAULT_HEALTH_TIMEOUT) -> bool: await asyncio.sleep(HEALTH_POLL_INTERVAL) return False + def tail_log(self, lines: int = 50) -> str: + """Return the last *lines* of the llama-server log, if any.""" + if self._log_path is None: + return "" + try: + text = self._log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + all_lines = text.splitlines() + return "\n".join(all_lines[-lines:]) + def stop(self, drain_seconds: float = 3.0) -> None: if not self.is_running: return proc = self.process assert proc is not None log.info("[%s] stopping pid=%s", self.name, proc.pid) - try: - os.killpg(proc.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - proc.wait(timeout=drain_seconds) - except subprocess.TimeoutExpired: - log.warning("[%s] SIGTERM timed out, sending SIGKILL", self.name) + if _IS_WINDOWS: + # proc.terminate()/kill() both just call TerminateProcess on Windows — + # there's no graceful/forceful distinction, and neither touches child + # processes. CTRL_BREAK_EVENT goes to the whole CREATE_NEW_PROCESS_GROUP + # (the closest equivalent to SIGTERM here); taskkill /T kills the whole + # subtree, mirroring killpg on the Linux side below. try: - os.killpg(proc.pid, signal.SIGKILL) - except ProcessLookupError: + proc.send_signal(_CTRL_BREAK_EVENT) + except (ProcessLookupError, OSError): pass try: proc.wait(timeout=drain_seconds) except subprocess.TimeoutExpired: + log.warning( + "[%s] CTRL_BREAK timed out, force-killing process tree", self.name + ) + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, + timeout=drain_seconds, + ) + try: + proc.wait(timeout=drain_seconds) + except subprocess.TimeoutExpired: + pass + else: + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: pass + try: + proc.wait(timeout=drain_seconds) + except subprocess.TimeoutExpired: + log.warning("[%s] SIGTERM timed out, sending SIGKILL", self.name) + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + proc.wait(timeout=drain_seconds) + except subprocess.TimeoutExpired: + pass self.process = None self.started_at = None if self._log_file is not None: diff --git a/src/arc_llama/router.py b/src/arc_llama/router.py index 64cdb30..cabf635 100644 --- a/src/arc_llama/router.py +++ b/src/arc_llama/router.py @@ -15,13 +15,38 @@ import asyncio import logging +import time from pathlib import Path +from typing import Any from arc_llama.config import Config, GPUConfig, ModelConfig from arc_llama.launcher import LlamaServer, build_plan +from arc_llama.recipes import KVCacheType, estimate_kv_bytes log = logging.getLogger("arc_llama.router") +# Rough overhead budgets for VRAM estimation (MiB). +_VRAM_COMPUTE_BUFFER_MB = 768 +_VRAM_SAFETY_MARGIN_MB = 256 + + +def _estimate_model_vram_mb(model: ModelConfig) -> int: + """Rough VRAM footprint for one model instance. + + Includes the mapped weights (model file size), the KV cache at the + configured context/type, and a fixed compute-buffer + safety margin. + """ + path = Path(model.path) + try: + model_file_mb = path.stat().st_size // (1_048_576) + except OSError: + model_file_mb = 0 + recipe = model.recipe or {} + ctx = int(recipe.get("ctx", 8192)) + kv_type = KVCacheType(recipe.get("cache_type_k", "f16")) + kv_mb = estimate_kv_bytes(ctx, kv_type, model.kv_class) // (1_048_576) + return model_file_mb + kv_mb + _VRAM_COMPUTE_BUFFER_MB + _VRAM_SAFETY_MARGIN_MB + class Router: """Owns one LlamaServer per registered model and serialises swaps.""" @@ -32,6 +57,13 @@ def __init__(self, cfg: Config, log_dir: Path | None = None): self._servers: dict[str, LlamaServer] = {} # keyed by model.name self._lock = asyncio.Lock() self._loading_futures: dict[str, asyncio.Future[tuple[ModelConfig, LlamaServer]]] = {} + self.metrics: dict[str, Any] = { + "loads": 0, + "stops": 0, + "load_errors": 0, + "last_load_at": None, + "last_error": None, + } self._build_servers() def _build_servers(self) -> None: @@ -119,6 +151,8 @@ async def ensure_active(self, query: str) -> tuple[ModelConfig, LlamaServer]: if target_srv.is_running: return target_model, target_srv + self._check_vram_fit(target_model, target_gpu) + # We are the one responsible for starting. loop = asyncio.get_running_loop() future: asyncio.Future[tuple[ModelConfig, LlamaServer]] = loop.create_future() @@ -127,25 +161,59 @@ async def ensure_active(self, query: str) -> tuple[ModelConfig, LlamaServer]: target_srv.start(log_dir=self.log_dir) ready = await target_srv.wait_ready() if not ready: + tail = target_srv.tail_log(lines=40) log.error( "model %s failed health-check; stopping it", target_model.name, ) target_srv.stop() - raise RuntimeError( - f"llama-server for {target_model.name} did not become healthy" - ) + self.metrics["load_errors"] += 1 + self.metrics["last_error"] = f"{target_model.name} did not become healthy" + detail = f"llama-server for {target_model.name} did not become healthy" + if tail: + detail += "\n\n--- last log lines ---\n" + tail + raise RuntimeError(detail) + self.metrics["loads"] += 1 + self.metrics["last_load_at"] = time.time() + self.metrics["last_error"] = None result = (target_model, target_srv) future.set_result(result) return result - except Exception: - future.set_exception(RuntimeError( - f"llama-server for {target_model.name} did not become healthy" - )) + except Exception as exc: + if not future.done(): + self.metrics["load_errors"] += 1 + self.metrics["last_error"] = str(exc) + future.set_exception(RuntimeError( + f"llama-server for {target_model.name} did not become healthy" + )) raise finally: self._loading_futures.pop(target_model.name, None) + def _check_vram_fit(self, target: ModelConfig, target_gpu: GPUConfig) -> None: + """Refuse to load *target* if its estimated VRAM won't fit on target_gpu. + + In multi-resident mode this also accounts for other loaded models that + share the same GPU. + """ + if not target_gpu.vram_mb: + return + used_mb = _estimate_model_vram_mb(target) + for name, srv in self._servers.items(): + if name == target.name or not srv.is_running: + continue + other = next((m for m in self.cfg.models if m.name == name), None) + if other is None or other.gpu_pci_slot != target_gpu.pci_slot: + continue + used_mb += _estimate_model_vram_mb(other) + if used_mb > target_gpu.vram_mb: + target_mb = _estimate_model_vram_mb(target) + raise RuntimeError( + f"model {target.name!r} needs ~{target_mb} MiB on GPU " + f"{target_gpu.pci_slot} but only {target_gpu.vram_mb} MiB is available " + f"(estimated total with co-residents: {used_mb} MiB)" + ) + async def _evict_for(self, target: ModelConfig, target_gpu: GPUConfig) -> None: """Stop the right neighbours so the target can have its GPU.""" single = self.cfg.server.single_resident @@ -169,6 +237,7 @@ async def stop_one(self, name: str) -> bool: if srv is None or not srv.is_running: return False srv.stop() + self.metrics["stops"] += 1 return True async def stop_all(self) -> int: @@ -179,6 +248,7 @@ async def stop_all(self) -> int: if srv.is_running: srv.stop() stopped += 1 + self.metrics["stops"] += stopped return stopped async def rebuild_model(self, name: str) -> tuple[bool, bool]: diff --git a/src/arc_llama/server.py b/src/arc_llama/server.py index f902385..996087b 100644 --- a/src/arc_llama/server.py +++ b/src/arc_llama/server.py @@ -20,6 +20,7 @@ import io import json import logging +import time import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -62,6 +63,7 @@ async def lifespan(app: FastAPI): app.state.router = Router(cfg, log_dir=state_dir) app.state.upstream_mgr = UpstreamManager(cfg.upstreams) app.state.cfg = cfg + app.state.started_at = time.time() app.state.pending_confirmations: dict[str, tuple[asyncio.Event, dict[str, bool]]] = {} if state_dir: app.state.chat_store = ChatStore(state_dir / "chats") @@ -76,8 +78,44 @@ async def lifespan(app: FastAPI): app = FastAPI(title="arc-llama", version="0.1.0", lifespan=lifespan) @app.get("/health") - async def health() -> dict[str, str]: - return {"status": "ok"} + async def health(request: Request) -> dict[str, Any]: + """Liveness probe for the arc-llama router itself.""" + rt: Router = request.app.state.router + uptime = time.time() - request.app.state.started_at + loaded = [m.name for m in rt.all_models() if rt._servers.get(m.name) and rt._servers[m.name].is_running] + return { + "status": "ok", + "uptime_seconds": round(uptime, 2), + "loaded_models": loaded, + "loaded_model_count": len(loaded), + } + + @app.get("/admin/metrics") + async def admin_metrics(request: Request) -> dict[str, Any]: + """Operational counters and current GPU/model state.""" + rt: Router = request.app.state.router + c: Config = request.app.state.cfg + uptime = time.time() - request.app.state.started_at + loaded = [m.name for m in rt.all_models() if rt._servers.get(m.name) and rt._servers[m.name].is_running] + return { + "uptime_seconds": round(uptime, 2), + "loads": rt.metrics["loads"], + "stops": rt.metrics["stops"], + "load_errors": rt.metrics["load_errors"], + "last_load_at": rt.metrics["last_load_at"], + "last_error": rt.metrics["last_error"], + "active_models": loaded, + "gpus": [ + { + "pci_slot": g.pci_slot, + "name": g.name, + "arch": g.arch, + "vram_mb": g.vram_mb, + "enabled": g.enabled, + } + for g in c.gpus + ], + } @app.get("/v1/models") async def list_models(request: Request) -> dict: @@ -262,6 +300,65 @@ async def create_chat(request: Request) -> dict[str, Any]: raise HTTPException(status_code=409, detail=f"Chat already exists: {chat_id}") from None return chat.to_dict() + @app.post("/v1/chats/search") + async def search_chats(request: Request) -> dict[str, Any]: + """Search chat titles and messages. + + Body: {"query": "string", "limit": 20} + Returns matching chats with the indices of matching messages. + """ + try: + body = await request.json() + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") from e + query = body.get("query", "") + if not query: + raise HTTPException(status_code=400, detail="query is required") + limit = int(body.get("limit", 20)) + store: ChatStore = request.app.state.chat_store + results = store.search(query, limit=limit) + return { + "object": "list", + "data": [ + { + "chat": chat.summary(), + "matching_message_indices": indices, + } + for chat, indices in results + ], + } + + @app.get("/v1/chats/export") + async def export_chats(request: Request) -> dict[str, Any]: + """Export every chat as a portable JSON document.""" + store: ChatStore = request.app.state.chat_store + return {"version": 1, "exported_at": time.time(), "chats": store.export_all()} + + @app.post("/v1/chats/import") + async def import_chats(request: Request) -> dict[str, Any]: + """Import chats from an export document. + + Body: {"chats": [...], "overwrite": false} + Existing chats are skipped unless ``overwrite`` is true. + """ + try: + body = await request.json() + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") from e + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Body must be a JSON object") + chats = body.get("chats") + if not isinstance(chats, list): + raise HTTPException(status_code=400, detail="'chats' must be an array") + store: ChatStore = request.app.state.chat_store + result = store.import_chats(chats, overwrite=bool(body.get("overwrite", False))) + return { + "imported": result["imported"], + "skipped": result["skipped"], + "errors": result["errors"], + "error_details": result["error_details"], + } + @app.get("/v1/chats/{chat_id}") async def get_chat(chat_id: str, request: Request) -> dict[str, Any]: """Return a full chat including all messages.""" @@ -332,34 +429,6 @@ async def delete_chat(chat_id: str, request: Request) -> dict[str, Any]: raise HTTPException(status_code=404, detail="Chat not found") return {"deleted": True} - @app.post("/v1/chats/search") - async def search_chats(request: Request) -> dict[str, Any]: - """Search chat titles and messages. - - Body: {"query": "string", "limit": 20} - Returns matching chats with the indices of matching messages. - """ - try: - body = await request.json() - except json.JSONDecodeError as e: - raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") from e - query = body.get("query", "") - if not query: - raise HTTPException(status_code=400, detail="query is required") - limit = int(body.get("limit", 20)) - store: ChatStore = request.app.state.chat_store - results = store.search(query, limit=limit) - return { - "object": "list", - "data": [ - { - "chat": chat.summary(), - "matching_message_indices": indices, - } - for chat, indices in results - ], - } - # ------------------------------------------------------------------ # Admin (used by the web UI / TUI) # ------------------------------------------------------------------ diff --git a/src/arc_llama/static/chat.css b/src/arc_llama/static/chat.css new file mode 100644 index 0000000..965293e --- /dev/null +++ b/src/arc_llama/static/chat.css @@ -0,0 +1,1637 @@ +:root { + --bg: #0a0c10; + --bg-elev: #11141a; + --bg-code: #080a0d; + --fg: #e6edf3; + --fg-dim: #8b949e; + --fg-mute: #5f6670; + --accent: #58a6ff; + --accent-deep: #1f6feb; + --accent-bright: #79c0ff; + --border: #30363d; + --border-subtle: #21262d; + --success: #3fb950; + --warn: #d29922; + --error: #f85149; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --space-unit: 4px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.24); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.32); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.35); + --transition-fast: 0.12s ease; + --transition-base: 0.18s ease; + + /* Legacy aliases consumed by existing JS/CSS */ + --glass: var(--bg-elev); + --glass-border: var(--border-subtle); + --user-tint: #151821; + --accent-gradient: linear-gradient(90deg, var(--accent-deep), var(--accent) 55%, var(--accent-bright)); + --radius: var(--radius-lg); + --shadow: var(--shadow-md); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + display: flex; + flex-direction: column; + align-items: center; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { color: var(--fg-dim); text-decoration: none; transition: color var(--transition-fast); } +a:hover { color: var(--accent-bright); } + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.10); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.16); } + +/* ------------------------------------------------------------------ + Header + ------------------------------------------------------------------ */ +header { + width: 100%; + max-width: 900px; + padding: calc(var(--space-unit) * 4) calc(var(--space-unit) * 6); + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(var(--space-unit) * 4); + background: var(--bg-elev); + border-bottom: 1px solid var(--border-subtle); + box-shadow: var(--shadow-sm); +} + +.brand { + display: flex; + align-items: baseline; + gap: calc(var(--space-unit) * 3); +} + +header h1 { + margin: 0; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--fg); +} + +.tagline { + font-size: 12px; + color: var(--fg-mute); + font-weight: 500; +} + +.meta { + display: flex; + align-items: center; + gap: calc(var(--space-unit) * 3); +} + +.mode-toggle { + display: inline-flex; + align-items: center; + background: var(--bg-code); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 3px; + gap: 2px; +} + +.mode-toggle button { + appearance: none; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--fg-dim); + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 5px 12px; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.mode-toggle button:hover { color: var(--fg); } + +.mode-toggle button.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); + box-shadow: var(--shadow-sm); +} + +.manager-link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + color: var(--fg-dim); + padding: 6px 10px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg); + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); +} + +.manager-link:hover { + color: var(--accent-bright); + background: rgba(88, 166, 255, 0.08); + border-color: rgba(88, 166, 255, 0.25); +} + +/* ------------------------------------------------------------------ + Model bar + ------------------------------------------------------------------ */ +.model-bar { + width: 100%; + max-width: 900px; + padding: calc(var(--space-unit) * 3) calc(var(--space-unit) * 6) calc(var(--space-unit) * 4); + display: flex; + align-items: center; + gap: 12px; +} + +.model-select { + appearance: none; + background: var(--bg-elev) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E") no-repeat right 11px center; + color: var(--fg); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 7px 30px 7px 12px; + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + min-width: 260px; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast); +} + +.model-select:hover { border-color: var(--border); } + +.model-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.15); +} + +.model-select option { background: var(--bg-elev); color: var(--fg); } + +.model-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + border: 1px solid transparent; + line-height: 1.2; + color: var(--fg-mute); + background: rgba(255, 255, 255, 0.04); + border-color: var(--border); + transition: color 0.2s, background 0.2s, border-color 0.2s; +} + +.model-status .indicator { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.85; +} + +.model-status .name { font-weight: 600; color: inherit; } + +.model-status.ready { + color: var(--success); + background: rgba(63, 185, 80, 0.10); + border-color: rgba(63, 185, 80, 0.25); +} + +.model-status.loading { + color: var(--accent-bright); + background: rgba(88, 166, 255, 0.10); + border-color: rgba(88, 166, 255, 0.28); +} + +.model-status.swapping { + color: var(--warn); + background: rgba(210, 153, 34, 0.10); + border-color: rgba(210, 153, 34, 0.28); +} + +.model-status.loading .indicator, +.model-status.swapping .indicator { animation: pulse 1.2s infinite; } + +@keyframes pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ------------------------------------------------------------------ + Main chat log + ------------------------------------------------------------------ */ +main { + flex: 1; + width: 100%; + max-width: 900px; + overflow-y: auto; + padding: 16px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.empty-state { + margin: auto; + padding: calc(var(--space-unit) * 12) calc(var(--space-unit) * 6); + text-align: center; + color: var(--fg-mute); + font-size: 13px; + display: flex; + flex-direction: column; + align-items: center; + gap: calc(var(--space-unit) * 2); + max-width: 360px; +} + +.empty-state .icon { + width: 40px; + height: 40px; + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + margin-bottom: calc(var(--space-unit) * 2); + color: var(--fg-dim); +} + +.empty-state .icon svg { fill: currentColor; } + +.empty-state .title { + color: var(--fg-dim); + font-weight: 600; + font-size: 14px; +} + +.empty-state .hint { + color: var(--fg-mute); + font-size: 12px; + line-height: 1.5; +} + +.message { + background: var(--bg-elev); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 18px; + max-width: 100%; + box-shadow: var(--shadow-sm); + animation: msg-in 0.2s ease; +} + +@keyframes msg-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.message.user { + background: rgba(88, 166, 255, 0.06); + border-left: 3px solid var(--accent); + align-self: flex-end; + max-width: 82%; +} + +.message.assistant { + border-left: 3px solid var(--accent-bright); +} + +.message.system { + background: rgba(88, 166, 255, 0.05); + border-color: rgba(88, 166, 255, 0.15); + align-self: center; + max-width: 92%; + font-size: 13px; +} + +.message.error-card { + background: rgba(248, 81, 73, 0.06); + border-color: rgba(248, 81, 73, 0.2); + color: #ffb3ae; +} + +.message .role { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-mute); + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 8px; +} + +.message.assistant .role { color: var(--accent-bright); } +.message.user .role { color: var(--fg-dim); } +.message.system .role { color: var(--accent-bright); } +.message.error-card .role { color: var(--error); } + +.message .content { + word-wrap: break-word; +} + +.message.user .content, +.message.system .content, +.message.error-card .content { + white-space: pre-wrap; +} + +.message.assistant .content { + font-size: 14px; + line-height: 1.65; +} + +.message.assistant .content > *:last-child { + margin-bottom: 0; +} + +.message.user .content { + font-weight: 400; + font-size: 14px; + line-height: 1.55; +} + +.token-chunk { + opacity: 0.5; + transition: opacity 0.12s ease; +} +.token-chunk.revealed { opacity: 1; } + +.message pre { + background: var(--bg-code); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 14px 16px; + overflow-x: auto; + margin: 14px 0; +} + +.message pre code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 13px; + line-height: 1.6; + color: #c9d1d9; + background: transparent; + padding: 0; +} + +.code-block-wrapper { + position: relative; + margin: 14px 0; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-code); + overflow: hidden; +} + +.code-block-wrapper .code-lang { + position: absolute; + top: 6px; + left: 12px; + font-size: 11px; + font-weight: 600; + color: var(--fg-mute); + text-transform: uppercase; + letter-spacing: 0.04em; + pointer-events: none; +} + +.code-block-wrapper pre { + margin: 0; + border: none; + border-radius: 0; + padding: 34px 16px 14px; + background: transparent; +} + +.code-block-wrapper .copy-code-btn { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--fg-dim); + cursor: pointer; + opacity: 0; + transition: opacity var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); +} + +.code-block-wrapper:hover .copy-code-btn { opacity: 1; } + +.code-block-wrapper .copy-code-btn:hover { + background: rgba(88, 166, 255, 0.12); + border-color: rgba(88, 166, 255, 0.35); + color: var(--accent-bright); +} + +.code-block-wrapper .copy-code-btn.copied { + background: rgba(63, 185, 80, 0.15); + border-color: rgba(63, 185, 80, 0.4); + color: var(--success); + opacity: 1; +} + +.message code { + background: rgba(230, 237, 243, 0.08); + padding: 2px 5px; + border-radius: var(--radius-sm); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + color: var(--accent-bright); +} + +.message p { margin: 0 0 10px; } +.message p:last-child { margin-bottom: 0; } + +.message h1, .message h2, .message h3, .message h4, .message h5, .message h6 { + margin: 18px 0 10px; + font-weight: 700; + line-height: 1.25; + color: var(--fg); +} + +.message h1 { font-size: 18px; } +.message h2 { font-size: 16px; } +.message h3 { font-size: 15px; } +.message h4 { font-size: 14px; } +.message h5 { font-size: 13px; } +.message h6 { font-size: 12px; color: var(--fg-dim); } + +.message blockquote { + margin: 12px 0; + padding: 10px 16px; + border-left: 2px solid var(--accent); + background: rgba(88, 166, 255, 0.05); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + color: var(--fg-dim); +} + +.message ul, .message ol { + margin: 10px 0; + padding-left: 24px; +} + +.message li { margin: 4px 0; } +.message del { text-decoration: line-through; opacity: 0.55; } + +.message hr { + border: none; + height: 1px; + background: var(--border-subtle); + margin: 16px 0; +} + +.message a { + color: var(--accent-bright); + text-decoration: underline; + text-underline-offset: 2px; +} + +.message a:hover { color: var(--accent); } + +.thinking-block { + margin-bottom: 14px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-code); +} + +.thinking-toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(88, 166, 255, 0.06); + cursor: pointer; + user-select: none; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-dim); + transition: background var(--transition-fast); +} + +.thinking-toggle:hover { background: rgba(88, 166, 255, 0.10); } + +.thinking-toggle .chevron { + transition: transform var(--transition-fast); + font-size: 9px; + display: inline-block; + color: var(--accent-bright); +} + +.thinking-toggle.open .chevron { transform: rotate(90deg); } + +.thinking-content { + font-size: 12px; + color: var(--fg-dim); + font-style: italic; + line-height: 1.55; + white-space: pre-wrap; + max-height: 0; + overflow: hidden; + transition: max-height 0.25s ease, padding 0.25s ease; + padding: 0 12px; +} + +.thinking-content.open { + max-height: 2000px; + padding: 10px 12px; +} + +/* ------------------------------------------------------------------ + Input area + ------------------------------------------------------------------ */ +.input-area { + width: 100%; + max-width: 900px; + padding: 12px 24px 24px; +} + +.input-wrap { + position: relative; + display: flex; + align-items: flex-end; + gap: 8px; + background: var(--bg-elev); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 10px 12px 10px 14px; + box-shadow: var(--shadow-sm); + transition: border-color var(--transition-base), box-shadow var(--transition-base); +} + +.input-wrap:focus-within, +.input-wrap.generating { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.12), var(--shadow-md); +} + +#message-input { + flex: 1; + background: transparent; + border: none; + color: var(--fg); + font: inherit; + font-size: 14px; + line-height: 1.5; + resize: none; + outline: none; + min-height: 24px; + max-height: 96px; + padding: 7px 0; + field-sizing: content; +} + +#message-input::placeholder { color: var(--fg-mute); } + +#send-button { + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + border: none; + background: var(--accent-deep); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background var(--transition-fast), transform 0.1s, box-shadow var(--transition-fast); +} + +#send-button:hover { background: var(--accent); } +#send-button:active { transform: translateY(1px); } +#send-button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } +#send-button svg { width: 16px; height: 16px; fill: currentColor; } + +#attach-button { + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + background: transparent; + color: var(--fg-mute); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); +} + +#attach-button:hover { + color: var(--accent-bright); + background: rgba(88, 166, 255, 0.08); + border-color: rgba(88, 166, 255, 0.15); +} + +#attach-button:disabled { opacity: 0.45; cursor: not-allowed; } +#attach-button svg { width: 18px; height: 18px; fill: currentColor; } + +#pdf-input { display: none; } + +#attachment-strip { + display: flex; + align-items: center; + gap: 8px; + flex: 0 1 auto; + min-width: 0; + overflow-x: auto; + padding-right: 4px; + scrollbar-width: none; +} + +#attachment-strip::-webkit-scrollbar { display: none; } + +.attachment-chip { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 9px; + border-radius: var(--radius-sm); + background: rgba(88, 166, 255, 0.10); + border: 1px solid rgba(88, 166, 255, 0.2); + color: var(--fg-dim); + font-size: 12px; + max-width: 180px; + transition: opacity var(--transition-base), border-color var(--transition-fast), background var(--transition-fast); +} + +.attachment-chip:hover { border-color: rgba(88, 166, 255, 0.35); } + +.attachment-chip.error { + background: rgba(248, 81, 73, 0.08); + border-color: rgba(248, 81, 73, 0.2); + color: #ffb3ae; +} + +.attachment-chip.error:hover { border-color: rgba(248, 81, 73, 0.35); } + +.attachment-chip.processing { opacity: 0.7; } + +.attachment-chip .filename { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; +} + +.attachment-chip .remove { + flex: 0 0 auto; + width: 16px; + height: 16px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; + opacity: 0.7; + transition: opacity var(--transition-fast), background var(--transition-fast); +} + +.attachment-chip .remove:hover { opacity: 1; background: rgba(255, 255, 255, 0.08); } + +.attachment-chip .remove svg { fill: currentColor; } + +.attachment-chip .attachment-icon { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent-bright); +} + +.attachment-chip .attachment-icon svg { fill: currentColor; } + +.attachment-chip .spinner { + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + opacity: 0.7; +} + +.hint { + text-align: center; + font-size: 11px; + color: var(--fg-mute); + margin-top: 8px; +} + +/* ------------------------------------------------------------------ + Context meter + ------------------------------------------------------------------ */ +.ctx-meter { + width: 100%; + max-width: 900px; + padding: 0 24px 12px; + opacity: 0; + transition: opacity 0.4s ease; +} + +.ctx-meter.visible { opacity: 1; } + +.ctx-bar-track { + height: 4px; + background: var(--border-subtle); + border-radius: 999px; + overflow: hidden; + margin-bottom: 6px; +} + +.ctx-bar-fill { + height: 100%; + border-radius: 999px; + background: var(--accent-gradient); + width: 0%; + transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease; +} + +.ctx-bar-fill.warn { filter: brightness(1.25); } +.ctx-bar-fill.critical { filter: brightness(1.6) saturate(0.5); } + +.ctx-label { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--fg-mute); +} + +.ctx-tps { color: var(--fg-dim); } + +/* ------------------------------------------------------------------ + Side-panel toggles + ------------------------------------------------------------------ */ +#settings-toggle, #history-toggle { + position: fixed; + bottom: 20px; + width: 40px; + height: 40px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-elev); + color: var(--fg-mute); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast), transform var(--transition-base); + z-index: 100; + box-shadow: var(--shadow-sm); +} + +#history-toggle { left: 20px; } +#settings-toggle { right: 20px; } + +#settings-toggle:hover, #history-toggle:hover { + color: var(--accent-bright); + border-color: rgba(88, 166, 255, 0.25); + background: var(--bg); +} + +#settings-toggle.open { transform: rotate(45deg); color: var(--accent-bright); border-color: rgba(88, 166, 255, 0.25); } +#history-toggle.open { color: var(--accent-bright); border-color: rgba(88, 166, 255, 0.25); } + +/* ------------------------------------------------------------------ + Side panels + ------------------------------------------------------------------ */ +#settings-panel, #history-panel { + position: fixed; + top: 0; + bottom: 0; + width: 300px; + background: var(--bg); + padding: 24px; + transform: translateX(100%); + transition: transform 0.25s ease; + z-index: 99; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: var(--shadow-lg); +} + +#settings-panel { right: 0; border-left: 1px solid var(--border-subtle); } +#history-panel { left: 0; border-right: 1px solid var(--border-subtle); transform: translateX(-100%); } +#settings-panel.open { transform: translateX(0); } +#history-panel.open { transform: translateX(0); } + +.s-title, .h-title { + font-size: 11px; + font-weight: 700; + color: var(--fg-mute); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.s-model-name { + font-size: 13px; + font-weight: 600; + color: var(--accent-bright); + word-break: break-all; + margin-top: 4px; +} + +.s-field { display: flex; flex-direction: column; gap: 6px; } + +.s-field label { + font-size: 10px; + color: var(--fg-mute); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; +} + +.s-field input, +.s-field select { + background: var(--bg-elev); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--fg); + font: inherit; + font-size: 13px; + padding: 7px 10px; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); +} + +.s-field input:hover, +.s-field select:hover { border-color: var(--border); } + +.s-field input:focus, +.s-field select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.12); +} + +.s-field select option { background: var(--bg-elev); } + +.s-apply { + width: 100%; + padding: 9px; + border-radius: var(--radius-md); + border: none; + background: var(--accent-deep); + color: #fff; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background var(--transition-fast), transform 0.1s; +} + +.s-apply:hover { background: var(--accent); } +.s-apply:active { transform: translateY(1px); } +.s-apply:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } + +.s-note { font-size: 11px; color: var(--fg-mute); text-align: center; } +.s-upstream { font-size: 13px; color: var(--fg-mute); text-align: center; padding: 20px 0; } +.s-feedback { font-size: 12px; text-align: center; min-height: 16px; } + +.h-new { + width: 100%; + padding: 8px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-elev); + color: var(--fg); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast), transform 0.1s; +} + +.h-new:hover { border-color: var(--accent); background: rgba(88, 166, 255, 0.08); color: var(--accent-bright); } +.h-new:active { transform: translateY(1px); } + +.h-actions { + display: flex; + gap: 8px; + margin: 10px 0 12px; +} + +.h-action { + flex: 1; + padding: 6px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-elev); + color: var(--fg-mute); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: border-color var(--transition-fast), color var(--transition-fast); +} + +.h-action:hover { border-color: var(--accent); color: var(--accent-bright); } + +.h-list { display: flex; flex-direction: column; gap: 8px; } +.h-empty { font-size: 13px; color: var(--fg-mute); text-align: center; padding: 24px 0; } + +.h-card { + position: relative; + background: var(--bg-elev); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 12px; + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast), transform 0.1s; + box-shadow: var(--shadow-sm); +} + +.h-card:hover { + border-color: rgba(88, 166, 255, 0.25); + background: rgba(88, 166, 255, 0.05); +} + +.h-card:active { transform: translateY(1px); } + +.h-card-title { + font-size: 13px; + font-weight: 600; + color: var(--fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 24px; +} + +.h-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 4px; + font-size: 11px; + color: var(--fg-mute); +} + +.h-delete { + position: absolute; + top: 10px; + right: 10px; + width: 20px; + height: 20px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--fg-mute); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + line-height: 1; + transition: color var(--transition-fast), background var(--transition-fast); +} + +.h-delete:hover { color: var(--error); background: rgba(248, 81, 73, 0.08); } + +#chat-log.hidden { display: none; } + +@keyframes thinking-dots { + 0%, 100% { opacity: 0.3; transform: translateY(0); } + 50% { opacity: 1; transform: translateY(-2px); } +} + +.agent-pill { + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + font-size: 11px; + color: var(--fg-dim); + background: var(--bg-elev); + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 5px 12px; + margin-bottom: 10px; + animation: agent-in 0.25s ease; +} + +@keyframes agent-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.agent-pill .spinner { + width: 12px; + height: 12px; + border: 2px solid var(--fg-dim); + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* ------------------------------------------------------------------ + Agent log + ------------------------------------------------------------------ */ +#agent-log { + flex: 1; + width: 100%; + max-width: 900px; + overflow-y: auto; + padding: 16px 24px; + font-size: 14px; + line-height: 1.6; + color: var(--fg); + display: flex; + flex-direction: column; + gap: 12px; +} + +#agent-log.hidden { display: none; } + +.agent-log-line:empty, +.agent-assistant:empty { display: none; } + +.agent-ascii-bg { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + line-height: 1.15; + white-space: pre; + color: var(--fg-dim); + opacity: 0.25; + pointer-events: none; + user-select: none; + z-index: 0; + transition: opacity 0.4s ease, color 0.4s ease; + max-width: 90vw; + max-height: 90vh; + overflow: hidden; + text-shadow: 0 0 20px rgba(0, 0, 0, 0.5); +} + +.agent-ascii-bg.state-thinking { opacity: 0.40; color: var(--accent-bright); } +.agent-ascii-bg.state-working { opacity: 0.35; color: var(--warn); } +.agent-ascii-bg.state-success { opacity: 0.35; color: var(--success); } +.agent-ascii-bg.state-error { opacity: 0.40; color: var(--error); } +.agent-ascii-bg.state-done { opacity: 0.35; color: var(--success); } + +#agent-log > *:not(.agent-ascii-bg) { position: relative; z-index: 1; } + +.agent-log-line { + position: relative; + padding: 10px 0 10px 18px; + border-left: 2px solid transparent; + animation: agent-fade-in 0.2s ease; + width: 100%; +} + +@keyframes agent-fade-in { + from { opacity: 0; transform: translateX(-4px); } + to { opacity: 1; transform: translateX(0); } +} + +.agent-log-line::before { + content: ""; + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 2px; + background: var(--border-subtle); +} + +.agent-log-line.status::before { background: var(--fg-mute); opacity: 0.4; } +.agent-log-line.assistant::before { background: var(--accent); opacity: 0.5; } +.agent-log-line.tool::before { background: var(--warn); opacity: 0.5; } +.agent-log-line.error::before { background: var(--error); opacity: 0.5; } +.agent-log-line.done::before { background: var(--success); opacity: 0.5; } + +.agent-prompt { + color: var(--accent-bright); + margin-bottom: 12px; + padding-left: 18px; + border-left: 2px solid var(--accent); + animation: agent-fade-in 0.2s ease; + font-weight: 600; +} + +.agent-prompt::before { + content: "> "; + color: var(--accent-bright); + font-weight: 700; +} + +.agent-status { color: var(--fg-mute); font-size: 12px; opacity: 0.9; } + +.agent-thinking { + display: flex; + align-items: center; + gap: 8px; + color: var(--fg-dim); + font-style: italic; +} + +.agent-thinking .dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent-bright); + animation: thinking-dots 1.2s ease-in-out infinite; +} + +.agent-thinking .dot:nth-child(2) { animation-delay: 0.15s; } +.agent-thinking .dot:nth-child(3) { animation-delay: 0.3s; } + +.agent-assistant { + color: var(--fg); + word-break: break-word; +} + +.agent-assistant p { margin: 0 0 10px; } +.agent-assistant p:last-child { margin-bottom: 0; } +.agent-assistant pre { + background: var(--bg-code); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 12px 14px; + overflow-x: auto; + margin: 8px 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; +} +.agent-assistant code { + background: rgba(230, 237, 243, 0.08); + padding: 2px 5px; + border-radius: var(--radius-sm); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + color: var(--accent-bright); +} +.agent-assistant ul, .agent-assistant ol { margin: 8px 0; padding-left: 22px; } +.agent-assistant li { margin: 3px 0; } +.agent-assistant > *:last-child { margin-bottom: 0; } +.agent-assistant .code-block-wrapper:last-child { margin-bottom: 0; } + +.agent-assistant pre { + background: var(--bg-code); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 12px 14px; + overflow-x: auto; + margin: 8px 0 0; + font-family: inherit; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +.agent-error { color: var(--error); white-space: pre-wrap; word-break: break-word; } + +.agent-error pre { + background: transparent; + border: none; + padding: 0; + margin: 4px 0 0; + color: inherit; + font-family: inherit; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +.agent-done { color: var(--fg-mute); font-size: 12px; display: inline-flex; align-items: center; gap: 6px; } +.agent-done svg { width: 14px; height: 14px; fill: currentColor; } + +.agent-tool { + display: flex; + flex-direction: column; + gap: 6px; + background: var(--bg-elev); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 10px 12px; + margin: 4px 0 8px; +} + +.agent-tool.pending { border-color: rgba(210, 153, 34, 0.35); } +.agent-tool.success { border-color: rgba(63, 185, 80, 0.35); } +.agent-tool.error { border-color: rgba(248, 81, 73, 0.35); } +.agent-tool.confirm-required { border-color: rgba(210, 153, 34, 0.5); } + +.agent-tool-header { + display: flex; + align-items: center; + gap: 10px; + min-height: 22px; +} + +.agent-step-number { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--border-subtle); + color: var(--fg-mute); + font-size: 10px; + font-weight: 700; + flex: 0 0 auto; +} + +.agent-tool.success .agent-step-number { background: rgba(63, 185, 80, 0.2); color: var(--success); } +.agent-tool.error .agent-step-number { background: rgba(248, 81, 73, 0.15); color: var(--error); } +.agent-tool.pending .agent-step-number { background: rgba(210, 153, 34, 0.15); color: var(--warn); } + +.agent-tool-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: var(--warn); + flex: 0 0 auto; +} + +.agent-tool.success .agent-tool-icon { color: var(--success); } +.agent-tool.error .agent-tool-icon { color: var(--error); } +.agent-tool-icon svg { width: 14px; height: 14px; fill: currentColor; } + +.agent-tool-title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} + +.agent-tool-kind { + color: var(--fg); + font-weight: 600; + font-size: 13px; +} + +.agent-tool-target { + color: var(--fg-dim); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.agent-tool-status { + margin-left: auto; + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--fg-mute); + font-weight: 600; +} + +.agent-tool.success .agent-tool-status { color: var(--success); } +.agent-tool.error .agent-tool-status { color: var(--error); } + +.agent-tool-status .spinner { + width: 11px; + height: 11px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.agent-tool-status .status-icon { + width: 12px; + height: 12px; + display: flex; + align-items: center; + justify-content: center; +} + +.agent-tool-status .status-icon svg { width: 12px; height: 12px; fill: currentColor; } + +.agent-tool-details { margin-left: 0; border-left: none; padding-left: 28px; } + +.agent-tool-detail { margin-top: 4px; } + +.agent-tool-detail-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + user-select: none; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fg-mute); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.agent-tool-detail-toggle:hover { background: rgba(230, 237, 243, 0.05); color: var(--fg-dim); } + +.agent-tool-detail-toggle .chevron { + font-size: 8px; + transition: transform var(--transition-fast); + display: inline-block; +} + +.agent-tool-detail-toggle.open .chevron { transform: rotate(90deg); } + +.agent-tool-detail-body { + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease, padding 0.2s ease; + padding: 0 6px; +} + +.agent-tool-detail-body.open { + max-height: 800px; + padding: 6px; +} + +.agent-tool-detail-body pre { + background: var(--bg-code); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 10px 12px; + margin: 0; + font-family: inherit; + font-size: 11px; + white-space: pre-wrap; + word-break: break-word; +} + +.agent-confirm-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.agent-confirm-actions { display: flex; gap: 6px; } + +.agent-confirm-btn { + appearance: none; + padding: 5px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-elev); + color: var(--fg-dim); + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform 0.1s; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.agent-confirm-btn:hover { background: var(--bg); color: var(--fg); } +.agent-confirm-btn:active { transform: translateY(1px); } +.agent-confirm-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } +.agent-confirm-btn svg { width: 14px; height: 14px; fill: currentColor; } + +.agent-confirm-btn.approve { + background: rgba(63, 185, 80, 0.10); + border-color: rgba(63, 185, 80, 0.25); + color: var(--success); +} + +.agent-confirm-btn.approve:hover { background: rgba(63, 185, 80, 0.16); border-color: rgba(63, 185, 80, 0.4); } + +.agent-confirm-btn.deny { + background: rgba(248, 81, 73, 0.08); + border-color: rgba(248, 81, 73, 0.25); + color: var(--error); +} + +.agent-confirm-btn.deny:hover { background: rgba(248, 81, 73, 0.14); border-color: rgba(248, 81, 73, 0.4); } + +.agent-confirm-btn .spinner { + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.agent-thinking-block { + margin: 8px 0 10px; + border-left: 2px solid rgba(88, 166, 255, 0.25); + padding-left: 10px; +} + +.agent-thinking-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + user-select: none; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fg-mute); + transition: background var(--transition-fast); +} + +.agent-thinking-toggle:hover { background: rgba(230, 237, 243, 0.05); } + +.agent-thinking-toggle .chevron { + font-size: 8px; + transition: transform var(--transition-fast); + display: inline-block; +} + +.agent-thinking-toggle.open .chevron { transform: rotate(90deg); } + +.agent-thinking-content { + font-size: 12px; + color: var(--fg-dim); + font-style: italic; + line-height: 1.55; + white-space: pre-wrap; + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease, padding 0.2s ease; + padding: 0 6px; +} + +.agent-thinking-content.open { + max-height: 2000px; + padding: 6px; +} + +/* ------------------------------------------------------------------ + Slash command palette + ------------------------------------------------------------------ */ +.command-palette { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 8px); + background: var(--bg-elev); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 6px; + z-index: 50; + display: none; + flex-direction: column; + gap: 2px; + max-height: 260px; + overflow-y: auto; +} + +.command-palette.open { display: flex; } + +.command-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; + color: var(--fg); + transition: background var(--transition-fast); +} + +.command-item:hover, +.command-item.selected { background: rgba(88, 166, 255, 0.12); } + +.command-item .cmd-name { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-weight: 700; + color: var(--accent-bright); + min-width: 80px; +} + +.command-item .cmd-desc { color: var(--fg-dim); font-size: 12px; } +.command-item .cmd-hint { margin-left: auto; font-size: 11px; color: var(--fg-mute); } + +.command-hint { background: rgba(88, 166, 255, 0.05); border-color: rgba(88, 166, 255, 0.15); } +.command-hint .role { color: var(--accent-bright); } +.command-hint .content { font-size: 13px; color: var(--fg-dim); } +.command-hint .content strong { color: var(--fg); } +.command-hint .content code { + background: rgba(230, 237, 243, 0.08); + padding: 2px 5px; + border-radius: var(--radius-sm); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; +} + +/* ------------------------------------------------------------------ + Agent controls + ------------------------------------------------------------------ */ +.agent-controls { + display: flex; + align-items: center; + gap: 16px; + font-size: 12px; + color: var(--fg-dim); + margin-top: 10px; +} + +.agent-controls label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.agent-controls input[type="checkbox"] { accent-color: var(--accent); } + +.agent-controls input[type="number"] { + width: 50px; + background: var(--bg-elev); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--fg); + padding: 3px 6px; + font: inherit; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.agent-controls input[type="number"]:hover { border-color: var(--border); } + +.agent-controls input[type="number"]:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.12); +} + +@media (max-width: 640px) { + header { flex-wrap: wrap; } + .meta { width: 100%; justify-content: flex-end; } + .model-bar { flex-wrap: wrap; } + .model-select { min-width: 0; flex: 1; } + main { padding: 12px 16px; } + .input-area { padding: 10px 16px 16px; } + .ctx-meter { padding: 0 16px 10px; } + #settings-panel, #history-panel { width: 260px; } +} \ No newline at end of file diff --git a/src/arc_llama/static/chat.html b/src/arc_llama/static/chat.html index bb254d5..1e8a3a7 100644 --- a/src/arc_llama/static/chat.html +++ b/src/arc_llama/static/chat.html @@ -4,1430 +4,12 @@