From ea3da701c677009b767040ec149b51420706195a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 24 Apr 2026 14:56:24 -0700 Subject: [PATCH] feat(models): extended model registry + manage.sh benchmark subcommands - app/models.py: add StyleModel and VoiceModel entries; expand cf-text and benchmark model metadata (vram_mb, description, tags) - tests/test_models.py: coverage for new model types and registry helpers - ModelsView.vue: updated model browser with style/voice filter tabs - manage.sh: add benchmark-style and benchmark-voice subcommands - config/label_tool.yaml.example: add style + voice benchmark config stubs - web/.gitignore: add node_modules and dist entries --- app/models.py | 686 +++++++++++++++++++++++++++++---- config/label_tool.yaml.example | 31 +- manage.sh | 26 ++ tests/test_models.py | 151 +++++++- web/.gitignore | 4 + web/src/views/ModelsView.vue | 404 +++++++++++++++---- 6 files changed, 1150 insertions(+), 152 deletions(-) diff --git a/app/models.py b/app/models.py index 4a6ffcd..c332a34 100644 --- a/app/models.py +++ b/app/models.py @@ -14,11 +14,12 @@ from __future__ import annotations import json import logging +import os import shutil import threading from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, TypedDict from uuid import uuid4 import httpx @@ -39,21 +40,67 @@ _ROOT = Path(__file__).parent.parent _MODELS_DIR: Path = _ROOT / "models" _QUEUE_DIR: Path = _ROOT / "data" +# Service-specific model destinations. +# cf-text models land on the NFS-mounted shared asset store so every cluster +# node can reach them without a separate download. Avocet classifiers stay local +# because they are fine-tuned in-place and are only consumed by avocet itself. +# Override via CF_TEXT_MODELS_DIR env var (useful for dev / non-NFS setups). +_CF_TEXT_MODELS_DIR: Path = Path( + os.environ.get("CF_TEXT_MODELS_DIR", "/Library/Assets/LLM/cf-text/models") +) + +# Directory containing per-node YAML profiles for cf-orch. +# Auto-registration writes new catalog entries here on model download. +_CF_ORCH_PROFILES_DIR: Path = Path( + os.environ.get( + "CF_ORCH_PROFILES_DIR", + "/Library/Development/CircuitForge/circuitforge-orch/circuitforge_orch/profiles/nodes", + ) +) + router = APIRouter() # ── Download progress shared state ──────────────────────────────────────────── # Updated by the background download thread; read by GET /download/stream. _download_progress: dict[str, Any] = {} -# ── HF pipeline_tag → adapter recommendation ────────────────────────────────── -_TAG_TO_ADAPTER: dict[str, str] = { - "zero-shot-classification": "ZeroShotAdapter", - "text-classification": "ZeroShotAdapter", - "natural-language-inference": "ZeroShotAdapter", - "sentence-similarity": "RerankerAdapter", - "text-ranking": "RerankerAdapter", - "text-generation": "GenerationAdapter", - "text2text-generation": "GenerationAdapter", +# ── HF pipeline_tag → CF service info ──────────────────────────────────────── + + +class _TagInfo(TypedDict): + adapter: str | None # Avocet adapter class, or None if handled by another service + role: str # Human-readable model role (classifier, stt, tts, vision, …) + service: str # CF service that consumes this model type + + +_TAG_TO_INFO: dict[str, _TagInfo] = { + # Avocet email classifiers + "zero-shot-classification": {"adapter": "ZeroShotAdapter", "role": "classifier", "service": "avocet"}, + "text-classification": {"adapter": "ZeroShotAdapter", "role": "classifier", "service": "avocet"}, + "natural-language-inference": {"adapter": "ZeroShotAdapter", "role": "classifier", "service": "avocet"}, + "sentence-similarity": {"adapter": "RerankerAdapter", "role": "reranker", "service": "avocet"}, + "text-ranking": {"adapter": "RerankerAdapter", "role": "reranker", "service": "avocet"}, + "text-generation": {"adapter": "GenerationAdapter", "role": "generator", "service": "cf-text"}, + "text2text-generation": {"adapter": "GenerationAdapter", "role": "generator", "service": "cf-text"}, + "summarization": {"adapter": "GenerationAdapter", "role": "generator", "service": "cf-text"}, + # STT — cf-stt speech recognition service + "automatic-speech-recognition": {"adapter": None, "role": "stt", "service": "cf-stt"}, + # Audio language models — audio + text → text (understanding, QA, captioning) + "audio-text-to-text": {"adapter": None, "role": "alm", "service": "cf-stt"}, + # Audio classification — cf-voice sidecar context stream + "audio-classification": {"adapter": None, "role": "classifier", "service": "cf-voice"}, + # TTS — cf-tts text-to-speech service + "text-to-speech": {"adapter": None, "role": "tts", "service": "cf-tts"}, + # Vision — cf-vision image classification / embedding / VLM service + "image-classification": {"adapter": None, "role": "vision", "service": "cf-vision"}, + "zero-shot-image-classification": {"adapter": None, "role": "vision", "service": "cf-vision"}, + "image-feature-extraction": {"adapter": None, "role": "embedding", "service": "cf-vision"}, + "image-text-to-text": {"adapter": None, "role": "vlm", "service": "cf-vision"}, + "visual-question-answering": {"adapter": None, "role": "vlm", "service": "cf-vision"}, + # Image generation — cf-image (text → image; distinct from cf-vision image understanding) + "text-to-image": {"adapter": None, "role": "image-gen", "service": "cf-image"}, + # Embedding — cf-core shared embedding layer + "feature-extraction": {"adapter": None, "role": "embedding", "service": "cf-core"}, } @@ -84,14 +131,31 @@ def _write_queue(records: list[dict]) -> None: def _safe_model_name(repo_id: str) -> str: - """Convert repo_id to a filesystem-safe directory name (HF convention).""" + """Convert repo_id to a filesystem-safe directory name. + + Uses the HuggingFace Hub convention: owner/model-name → owner--model-name. + This matches what snapshot_download produces under local_dir and what + cf-orch uses when constructing model paths for cf-text allocations. + """ return repo_id.replace("/", "--") -def _is_installed(repo_id: str) -> bool: - """Check if a model is already downloaded in _MODELS_DIR.""" +def _model_dir_for(repo_id: str, service: str | None) -> Path: + """Return the download destination directory for a model. + + cf-text models → NFS shared asset store (_CF_TEXT_MODELS_DIR) so every + cluster node can load them without a separate download. + All other services (avocet classifiers, fine-tunes) → local _MODELS_DIR. + """ safe_name = _safe_model_name(repo_id) - model_dir = _MODELS_DIR / safe_name + if service == "cf-text": + return _CF_TEXT_MODELS_DIR / safe_name + return _MODELS_DIR / safe_name + + +def _is_installed(repo_id: str, service: str | None = None) -> bool: + """Check if a model is already downloaded in the appropriate destination.""" + model_dir = _model_dir_for(repo_id, service) return model_dir.exists() and ( (model_dir / "config.json").exists() or (model_dir / "training_info.json").exists() @@ -125,48 +189,289 @@ def _get_queue_entry(entry_id: str) -> dict | None: return None +# ── cf-orch catalog auto-registration ───────────────────────────────────────── + + +def _catalog_key(repo_id: str) -> str: + """Derive a readable catalog key from repo_id. + + ibm-granite/granite-4.1-8b → granite-4.1-8b + facebook/bart-large-cnn → bart-large-cnn + """ + return repo_id.split("/", 1)[-1].lower() + + +def _insert_catalog_entry(content: str, entry_lines: str) -> str: + """Insert entry_lines at the end of the cf-text.catalog section. + + Scans line by line to preserve all comments and original formatting. + Returns content unchanged if the catalog section cannot be located. + """ + lines = content.splitlines(keepends=True) + + in_cf_text = False + in_catalog = False + + for i, line in enumerate(lines): + stripped = line.lstrip() + indent = len(line) - len(stripped) + blank_or_comment = not stripped or stripped.startswith("#") + + if not in_cf_text: + if indent == 2 and stripped.startswith("cf-text:"): + in_cf_text = True + continue + + if not in_catalog: + if indent == 4 and stripped.startswith("catalog:"): + in_catalog = True + elif not blank_or_comment and indent <= 2: + # Left cf-text section without finding a catalog + return content + continue + + # Inside catalog: first non-blank/comment line with indent < 6 ends it + if not blank_or_comment and indent < 6: + prefix = "\n" if lines[i - 1].strip() else "" + lines.insert(i, prefix + entry_lines) + return "".join(lines) + + # Catalog ran to EOF — append there + if in_catalog: + prefix = "\n" if lines and lines[-1].strip() else "" + lines.append(prefix + entry_lines) + return "".join(lines) + + return content + + +def _register_in_node_catalogs( + repo_id: str, + local_path: Path, + vram_mb_fp16: int, + role: str, +) -> list[str]: + """Insert a cf-text catalog entry into every eligible node YAML. + + A node is eligible when: + - It has a ``cf-text.catalog`` section + - The model fits within the node's ``cf-text.max_mb`` at FP16 *or* 4-bit + - Neither the model key nor the local path is already in the catalog + + Returns the list of node names that were updated. + """ + try: + import yaml # lazy — not in the critical import path + except ImportError: + logger.warning("PyYAML not available — skipping catalog registration for %s", repo_id) + return [] + + profiles_dir = _CF_ORCH_PROFILES_DIR + if not profiles_dir.exists(): + logger.warning( + "cf-orch profiles dir not found: %s — skipping catalog registration", profiles_dir + ) + return [] + + model_key = _catalog_key(repo_id) + local_path_str = str(local_path) + vram_4bit = round(vram_mb_fp16 / 4 * 1.1) + updated: list[str] = [] + + for yaml_file in sorted(profiles_dir.glob("*.yaml")): + try: + content = yaml_file.read_text(encoding="utf-8") + data = yaml.safe_load(content) + + cf_text = (data.get("services") or {}).get("cf-text") + if not cf_text: + continue + + max_mb: int = cf_text.get("max_mb", 0) + catalog: dict = cf_text.get("catalog") or {} + + # Skip if key already exists + if model_key in catalog: + logger.debug("Key %r already in %s — skipping", model_key, yaml_file.name) + continue + + # Skip if any existing entry already points at this path (or a file within it) + registered_paths = { + str(entry.get("path", "")) + for entry in catalog.values() + if isinstance(entry, dict) + } + if local_path_str in registered_paths or any( + p.startswith(local_path_str + "/") for p in registered_paths + ): + logger.debug("Path %s already registered in %s — skipping", local_path_str, yaml_file.name) + continue + + # Determine whether model fits at FP16 or needs 4-bit + if vram_mb_fp16 <= max_mb: + vram_for_node = vram_mb_fp16 + needs_4bit = False + elif vram_4bit <= max_mb: + vram_for_node = vram_4bit + needs_4bit = True + else: + logger.debug( + "%s too large for %s (fp16=%d MB, 4bit=%d MB, max=%d MB)", + repo_id, yaml_file.name, vram_mb_fp16, vram_4bit, max_mb, + ) + continue + + desc = f"{repo_id} ({role}, downloaded via avocet)" + if needs_4bit: + desc += " — CF_TEXT_4BIT=1 required" + + vram_comment = ( + f" # 4-bit estimate; FP16 footprint is {vram_mb_fp16} MB" + if needs_4bit + else f" # FP16 file-size estimate" + ) + entry_block = ( + f" # auto-registered by avocet on download\n" + f" {model_key}:\n" + f" path: {local_path_str}\n" + f" vram_mb: {vram_for_node}{vram_comment}\n" + f" description: \"{desc}\"\n" + ) + + new_content = _insert_catalog_entry(content, entry_block) + if new_content == content: + logger.warning("Could not find catalog insertion point in %s", yaml_file.name) + continue + + yaml_file.write_text(new_content, encoding="utf-8") + updated.append(yaml_file.stem) + logger.info( + "Registered %s in %s (vram_mb=%d, 4bit=%s)", + model_key, yaml_file.name, vram_for_node, needs_4bit, + ) + + except Exception as exc: + logger.warning("Could not update %s: %s", yaml_file.name, exc) + + return updated + + # ── Background download ──────────────────────────────────────────────────────── -def _run_download(entry_id: str, repo_id: str, pipeline_tag: str | None, adapter_recommendation: str | None) -> None: - """Background thread: download model via huggingface_hub.snapshot_download.""" +def _poll_disk_progress(local_dir: Path, total_bytes: int, stop_event: threading.Event) -> None: + """Side-thread: poll local_dir size every 2s and update _download_progress. + + snapshot_download is a blocking call with no progress callback, so we watch + the destination directory grow on disk as a proxy for download progress. + total_bytes=0 means we don't know the target size; pct stays 0 until done. + """ + import time + while not stop_event.is_set(): + try: + downloaded = sum( + f.stat().st_size for f in local_dir.rglob("*") if f.is_file() + ) + _download_progress["downloaded_bytes"] = downloaded + if total_bytes > 0: + _download_progress["total_bytes"] = total_bytes + _download_progress["pct"] = min(downloaded / total_bytes * 100, 99.0) + except Exception: + pass + time.sleep(2) + + +def _run_download( + entry_id: str, + repo_id: str, + pipeline_tag: str | None, + adapter_recommendation: str | None, + role: str | None = None, + service: str | None = None, + model_size_bytes: int = 0, +) -> None: + """Background thread: download model via huggingface_hub.snapshot_download. + + model_size_bytes is the sum of file sizes reported by the HF API (siblings). + It is used to estimate vram_mb and written to model_info.json so cf-orch can + budget VRAM when allocating a cf-text instance for this model. + """ global _download_progress - safe_name = _safe_model_name(repo_id) - local_dir = _MODELS_DIR / safe_name + local_dir = _model_dir_for(repo_id, service) _download_progress = { "active": True, "repo_id": repo_id, "downloaded_bytes": 0, - "total_bytes": 0, + "total_bytes": model_size_bytes, "pct": 0.0, "done": False, "error": None, } + stop_poll = threading.Event() + poll_thread = threading.Thread( + target=_poll_disk_progress, + args=(local_dir, model_size_bytes, stop_poll), + daemon=True, + name=f"model-poll-{entry_id}", + ) + try: if snapshot_download is None: raise RuntimeError("huggingface_hub is not installed") + local_dir.mkdir(parents=True, exist_ok=True) + poll_thread.start() snapshot_download( repo_id=repo_id, local_dir=str(local_dir), ) - # Write model_info.json alongside downloaded files + # Estimate VRAM from reported file size. + # HF siblings sizes are pre-quantisation file sizes; add 10% for KV cache + # and runtime overhead. Falls back to a stat of the local dir if 0. + if model_size_bytes == 0: + model_size_bytes = sum( + f.stat().st_size for f in local_dir.rglob("*") if f.is_file() + ) + vram_mb = int(model_size_bytes / (1024 * 1024) * 1.1) + + # Write model_info.json alongside downloaded files. + # local_path + vram_mb are read by cf-orch at allocation time to resolve + # the full model path and grant the correct VRAM lease. model_info = { "repo_id": repo_id, "pipeline_tag": pipeline_tag, "adapter_recommendation": adapter_recommendation, + "role": role, + "service": service, + "model_size_bytes": model_size_bytes, + "vram_mb": vram_mb, + "local_path": str(local_dir), "downloaded_at": datetime.now(timezone.utc).isoformat(), } - local_dir.mkdir(parents=True, exist_ok=True) (local_dir / "model_info.json").write_text( json.dumps(model_info, indent=2), encoding="utf-8" ) + # Auto-register cf-text models in the cf-orch node YAML catalogs so they + # appear in the benchmark model list without a manual YAML edit. + if service == "cf-text": + registered_on = _register_in_node_catalogs( + repo_id=repo_id, + local_path=local_dir, + vram_mb_fp16=vram_mb, + role=role or "generator", + ) + if registered_on: + logger.info( + "Auto-registered %s in node catalogs: %s", + repo_id, ", ".join(registered_on), + ) + _download_progress["done"] = True _download_progress["pct"] = 100.0 - _update_queue_entry(entry_id, {"status": "ready"}) + _update_queue_entry(entry_id, {"status": "ready", "local_path": str(local_dir)}) except Exception as exc: logger.exception("Download failed for %s: %s", repo_id, exc) @@ -174,6 +479,7 @@ def _run_download(entry_id: str, repo_id: str, pipeline_tag: str | None, adapter _download_progress["done"] = True _update_queue_entry(entry_id, {"status": "failed", "error": str(exc)}) finally: + stop_poll.set() _download_progress["active"] = False @@ -199,11 +505,15 @@ def lookup_model(repo_id: str) -> dict: data = resp.json() pipeline_tag = data.get("pipeline_tag") - adapter_recommendation = _TAG_TO_ADAPTER.get(pipeline_tag) if pipeline_tag else None + tag_info = _TAG_TO_INFO.get(pipeline_tag) if pipeline_tag else None + adapter_recommendation = tag_info["adapter"] if tag_info else None + role = tag_info["role"] if tag_info else None + service = tag_info["service"] if tag_info else None # Determine compatibility and surface a human-readable warning - _supported = ", ".join(sorted(_TAG_TO_ADAPTER.keys())) - if adapter_recommendation is not None: + _supported = ", ".join(sorted(_TAG_TO_INFO.keys())) + if tag_info is not None: + # Any recognized tag is compatible — avocet adapters or another CF service compatible = True warning: str | None = None elif pipeline_tag is None: @@ -216,7 +526,7 @@ def lookup_model(repo_id: str) -> dict: else: compatible = False warning = ( - f"\"{pipeline_tag}\" models are not supported by Avocet's email classification adapters. " + f"\"{pipeline_tag}\" models are not yet supported by the CircuitForge model ecosystem. " f"Supported task types: {_supported}." ) logger.warning("Unsupported pipeline_tag %r for %s", pipeline_tag, repo_id) @@ -234,6 +544,8 @@ def lookup_model(repo_id: str) -> dict: "repo_id": repo_id, "pipeline_tag": pipeline_tag, "adapter_recommendation": adapter_recommendation, + "role": role, + "service": service, "compatible": compatible, "warning": warning, "model_size_bytes": model_size_bytes, @@ -261,12 +573,18 @@ class QueueAddRequest(BaseModel): repo_id: str pipeline_tag: str | None = None adapter_recommendation: str | None = None + role: str | None = None + service: str | None = None + # Sum of file sizes from HF API siblings list; 0 if unknown. + # Stored in the queue entry so approve can pass it to _run_download + # without a second HF API round-trip. + model_size_bytes: int = 0 @router.post("/queue", status_code=201) def add_to_queue(req: QueueAddRequest) -> dict: """Add a model to the approval queue with status 'pending'.""" - if _is_installed(req.repo_id): + if _is_installed(req.repo_id, service=req.service): raise HTTPException(409, f"{req.repo_id!r} is already installed") if _is_queued(req.repo_id): raise HTTPException(409, f"{req.repo_id!r} is already in the queue") @@ -276,6 +594,9 @@ def add_to_queue(req: QueueAddRequest) -> dict: "repo_id": req.repo_id, "pipeline_tag": req.pipeline_tag, "adapter_recommendation": req.adapter_recommendation, + "role": req.role, + "service": req.service, + "model_size_bytes": req.model_size_bytes, "status": "pending", "queued_at": datetime.now(timezone.utc).isoformat(), } @@ -300,7 +621,15 @@ def approve_queue_entry(entry_id: str) -> dict: thread = threading.Thread( target=_run_download, - args=(entry_id, entry["repo_id"], entry.get("pipeline_tag"), entry.get("adapter_recommendation")), + args=( + entry_id, + entry["repo_id"], + entry.get("pipeline_tag"), + entry.get("adapter_recommendation"), + entry.get("role"), + entry.get("service"), + entry.get("model_size_bytes", 0), + ), daemon=True, name=f"model-download-{entry_id}", ) @@ -368,81 +697,286 @@ def download_stream() -> StreamingResponse: ) +# ── POST /sync-catalogs ──────────────────────────────────────────────────────── + +@router.post("/sync-catalogs") +def sync_catalogs() -> dict: + """Scan all installed cf-text models and register any missing from node YAMLs. + + Reads model_info.json from each directory in the cf-text models dir and calls + _register_in_node_catalogs() for each. Idempotent — skips models already + present by key or path. + + Returns a summary of registrations performed. + """ + if not _CF_TEXT_MODELS_DIR.exists(): + return {"registered": {}, "skipped": [], "message": "cf-text models dir not found"} + + registered: dict[str, list[str]] = {} + skipped: list[str] = [] + + for model_dir in sorted(_CF_TEXT_MODELS_DIR.iterdir()): + if not model_dir.is_dir(): + continue + info_file = model_dir / "model_info.json" + if not info_file.exists(): + skipped.append(model_dir.name) + continue + + try: + info = json.loads(info_file.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Could not read model_info.json for %s: %s", model_dir.name, exc) + skipped.append(model_dir.name) + continue + + if info.get("service") != "cf-text": + skipped.append(model_dir.name) + continue + + repo_id = info.get("repo_id", model_dir.name) + vram_mb = info.get("vram_mb", 0) + role = info.get("role", "generator") + + updated_nodes = _register_in_node_catalogs( + repo_id=repo_id, + local_path=model_dir, + vram_mb_fp16=vram_mb, + role=role, + ) + if updated_nodes: + registered[repo_id] = updated_nodes + else: + skipped.append(repo_id) + + return { + "registered": registered, + "skipped": skipped, + "message": ( + f"Registered {len(registered)} model(s) on " + f"{sum(len(v) for v in registered.values())} node(s)" + if registered + else "All models already registered (or no eligible nodes found)" + ), + } + + # ── GET /installed ───────────────────────────────────────────────────────────── @router.get("/installed") def list_installed() -> list[dict]: - """Scan _MODELS_DIR and return info on each installed model.""" - if not _MODELS_DIR.exists(): - return [] + """Scan all model directories and return info on each installed model. + + Scans both the local avocet models dir (classifiers, fine-tunes) and the + shared NFS cf-text models dir, deduplicating by directory path. + + Falls back to queue entry data when model_info.json has null service/role, + so models downloaded before the pipeline_tag registry existed still group + correctly in the UI. + """ + scan_dirs = [_MODELS_DIR] + if _CF_TEXT_MODELS_DIR != _MODELS_DIR and _CF_TEXT_MODELS_DIR.exists(): + scan_dirs.append(_CF_TEXT_MODELS_DIR) + + # Build a lookup from safe directory name → queue entry for fallback enrichment. + queue_by_safe_name: dict[str, dict] = { + _safe_model_name(r["repo_id"]): r + for r in _read_queue() + if r.get("repo_id") and r.get("status") not in ("dismissed",) + } results: list[dict] = [] - for sub in _MODELS_DIR.iterdir(): - if not sub.is_dir(): + seen: set[Path] = set() + + for scan_dir in scan_dirs: + if not scan_dir.exists(): continue + for sub in scan_dir.iterdir(): + if not sub.is_dir() or sub in seen: + continue + seen.add(sub) - has_training_info = (sub / "training_info.json").exists() - has_config = (sub / "config.json").exists() - has_model_info = (sub / "model_info.json").exists() + has_training_info = (sub / "training_info.json").exists() + has_config = (sub / "config.json").exists() + has_model_info = (sub / "model_info.json").exists() - if not (has_training_info or has_config or has_model_info): - continue + if not (has_training_info or has_config or has_model_info): + continue - model_type = "finetuned" if has_training_info else "downloaded" + model_type = "finetuned" if has_training_info else "downloaded" - # Compute directory size - size_bytes = sum(f.stat().st_size for f in sub.rglob("*") if f.is_file()) + # Compute directory size + size_bytes = sum(f.stat().st_size for f in sub.rglob("*") if f.is_file()) - # Load adapter/model_id from model_info.json or training_info.json - adapter: str | None = None - model_id: str | None = None + adapter: str | None = None + model_id: str | None = None + role: str | None = None + service: str | None = None + vram_mb: int | None = None - if has_model_info: - try: - info = json.loads((sub / "model_info.json").read_text(encoding="utf-8")) - adapter = info.get("adapter_recommendation") - model_id = info.get("repo_id") - except Exception: - pass - elif has_training_info: - try: - info = json.loads((sub / "training_info.json").read_text(encoding="utf-8")) - adapter = info.get("adapter") - model_id = info.get("base_model") or info.get("model_id") - except Exception: - pass + if has_model_info: + try: + info = json.loads((sub / "model_info.json").read_text(encoding="utf-8")) + adapter = info.get("adapter_recommendation") + model_id = info.get("repo_id") + role = info.get("role") + service = info.get("service") + vram_mb = info.get("vram_mb") + except Exception: + pass + elif has_training_info: + try: + info = json.loads((sub / "training_info.json").read_text(encoding="utf-8")) + adapter = info.get("adapter") + model_id = info.get("base_model") or info.get("model_id") + role = info.get("role", "classifier") + service = info.get("service", "avocet") + except Exception: + pass - results.append({ - "name": sub.name, - "path": str(sub), - "type": model_type, - "adapter": adapter, - "size_bytes": size_bytes, - "model_id": model_id, - }) + # Fall back to queue entry when model_info.json has null service/role. + # This covers models downloaded before the pipeline_tag registry existed. + if (role is None or service is None) and sub.name in queue_by_safe_name: + q = queue_by_safe_name[sub.name] + role = role or q.get("role") + service = service or q.get("service") + model_id = model_id or q.get("repo_id") + + # Last resort: re-derive from pipeline_tag if we still have no service. + if service is None and model_id: + hf_url = f"https://huggingface.co/api/models/{model_id}" + # Only attempt if we have a pipeline_tag cached somewhere. + for q in queue_by_safe_name.values(): + if q.get("repo_id") == model_id and q.get("pipeline_tag"): + tag_info = _TAG_TO_INFO.get(q["pipeline_tag"]) + if tag_info: + role = role or tag_info["role"] + service = service or tag_info["service"] + break + + results.append({ + "name": sub.name, + "path": str(sub), + "type": model_type, + "adapter": adapter, + "role": role, + "service": service, + "size_bytes": size_bytes, + "vram_mb": vram_mb, + "model_id": model_id, + }) return results +# ── PATCH /installed/{name} ──────────────────────────────────────────────────── + +class InstalledModelPatch(BaseModel): + service: str + role: str + + +@router.patch("/installed/{name}") +def patch_installed(name: str, body: InstalledModelPatch) -> dict: + """Manually assign service and role to an installed model. + + Writes the updated values back to model_info.json so they survive restarts, + and updates any matching queue entry so the UI shows the correct chip. + """ + if "/" in name or "\\" in name or ".." in name or not name or name.startswith("."): + raise HTTPException(400, f"Invalid model name {name!r}") + + candidate_dirs = [_MODELS_DIR] + if _CF_TEXT_MODELS_DIR != _MODELS_DIR: + candidate_dirs.append(_CF_TEXT_MODELS_DIR) + + model_path: Path | None = None + for base in candidate_dirs: + candidate = base / name + try: + candidate.resolve().relative_to(base.resolve()) + except ValueError: + raise HTTPException(400, f"Path traversal detected for name {name!r}") + if candidate.exists(): + model_path = candidate + break + + if model_path is None: + raise HTTPException(404, f"Installed model {name!r} not found") + + info_path = model_path / "model_info.json" + if info_path.exists(): + try: + info = json.loads(info_path.read_text(encoding="utf-8")) + except Exception: + info = {} + else: + info = {} + + info["service"] = body.service + info["role"] = body.role + info_path.write_text(json.dumps(info, indent=2), encoding="utf-8") + + # Mirror the update into any matching queue entry. + records = _read_queue() + updated = False + for r in records: + local = r.get("local_path", "") + matches = (local and Path(local).name == name) or _safe_model_name(r.get("repo_id", "")) == name + if matches and r.get("status") not in ("dismissed",): + r["service"] = body.service + r["role"] = body.role + updated = True + if updated: + _write_queue(records) + + return {"ok": True, "service": body.service, "role": body.role} + + # ── DELETE /installed/{name} ─────────────────────────────────────────────────── @router.delete("/installed/{name}") def delete_installed(name: str) -> dict: - """Remove an installed model directory by name. Blocks path traversal.""" - # Validate: single path component, no slashes or '..' + """Remove an installed model directory by name. Blocks path traversal. + + Searches both the local avocet models dir and the shared cf-text models dir. + Also dismisses any matching queue entry so the UI doesn't show a stale "ready" card. + """ if "/" in name or "\\" in name or ".." in name or not name or name.startswith("."): raise HTTPException(400, f"Invalid model name {name!r}: must be a single directory name with no path separators or '..'") - model_path = _MODELS_DIR / name + # Search both model directories + candidate_dirs = [_MODELS_DIR] + if _CF_TEXT_MODELS_DIR != _MODELS_DIR: + candidate_dirs.append(_CF_TEXT_MODELS_DIR) - # Extra safety: confirm resolved path is inside _MODELS_DIR - try: - model_path.resolve().relative_to(_MODELS_DIR.resolve()) - except ValueError: - raise HTTPException(400, f"Path traversal detected for name {name!r}") + model_path: Path | None = None + for base in candidate_dirs: + candidate = base / name + try: + candidate.resolve().relative_to(base.resolve()) + except ValueError: + raise HTTPException(400, f"Path traversal detected for name {name!r}") + if candidate.exists(): + model_path = candidate + break - if not model_path.exists(): - raise HTTPException(404, f"Installed model {name!r} not found") + if model_path is None: + raise HTTPException(404, f"Installed model {name!r} not found in any model directory") shutil.rmtree(model_path) + + # Dismiss any queue entries whose local_path matches, or whose repo_id maps to this dir name. + records = _read_queue() + updated = False + for r in records: + local = r.get("local_path", "") + matches_path = local and Path(local).name == name + matches_name = _safe_model_name(r.get("repo_id", "")) == name + if (matches_path or matches_name) and r.get("status") != "dismissed": + r["status"] = "dismissed" + updated = True + if updated: + _write_queue(records) + return {"ok": True} diff --git a/config/label_tool.yaml.example b/config/label_tool.yaml.example index 61a6d47..3407a0f 100644 --- a/config/label_tool.yaml.example +++ b/config/label_tool.yaml.example @@ -57,11 +57,32 @@ imitate: - id: peregrine name: Peregrine icon: "🦅" - description: Job search assistant - base_url: http://localhost:8502 - sample_endpoint: /api/jobs - text_fields: [title, description] - prompt_template: "Analyze this job listing and identify key requirements:\n\n{text}" + description: Job search assistant — live job listings + base_url: http://localhost:8601 + health_path: /api/jobs/counts + sample_endpoint: /api/jobs?status=pending&limit=5 + text_fields: [title, company, description] + prompt_template: "Analyze this job listing and identify the key requirements, must-have skills, and any culture signals that would help tailor an application:\n\n{text}" + + - id: osprey + name: Osprey + icon: "📞" + description: Gov't hold-line automation — recent call records + base_url: http://localhost:8520 + health_path: /api/health + sample_endpoint: /api/calls/recent + text_fields: [agency, issue, notes] + prompt_template: "Draft a clear, professional follow-up letter for this government hold-line call. Include what was discussed, what action the agency committed to, and a polite deadline for response:\n\n{text}" + + - id: linnet + name: Linnet + icon: "🐦" + description: Real-time tone annotation — Elcor-style subtext for ND users + base_url: http://localhost:8522 + health_path: /health + sample_endpoint: /samples + text_fields: [text, context] + prompt_template: "Annotate the emotional tone and subtext of the following text using explicit Elcor-style markers (e.g. [SINCERELY], [UNCERTAIN], [FRUSTRATED]). Identify implied emotions, potential sarcasm, and any ambiguity that might be misread by neurodivergent readers:\n\n{text}" - id: kiwi name: Kiwi diff --git a/manage.sh b/manage.sh index 847e8c3..daf147d 100755 --- a/manage.sh +++ b/manage.sh @@ -90,6 +90,12 @@ usage() { echo -e " ${GREEN}score [args]${NC} Shortcut: --score [args]" echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]" echo "" + echo " Writing Style Benchmark:" + echo -e " ${GREEN}style-bench [args]${NC} Run benchmark_style.py (args passed through)" + echo -e " ${GREEN}style-list${NC} List available ollama models for style bench" + echo -e " ${GREEN}style-run [args]${NC} Run writing style benchmark (--models, --samples, --include-large, --scan-disk PATH, --cforch)" + echo -e " ${GREEN}style-last${NC} Print most recent writing style benchmark report" + echo "" echo " Dev:" echo -e " ${GREEN}dev${NC} Hot-reload: uvicorn --reload (:8503) + Vite HMR (:5173)" echo -e " ${GREEN}test${NC} Run pytest suite" @@ -249,6 +255,26 @@ case "$CMD" in exec "$0" benchmark --compare "$@" ;; + style-bench) + info "Running writing style benchmark (${ENV_BM})…" + if [[ ! -x "$PYTHON_BM" ]]; then + error "Python not found in ${ENV_BM} env at ${PYTHON_BM}" + fi + "$PYTHON_BM" scripts/benchmark_style.py "$@" + ;; + + style-list) + exec "$0" style-bench --list-models + ;; + + style-run) + exec "$0" style-bench --run "$@" + ;; + + style-last) + exec "$0" style-bench --show-last + ;; + help|--help|-h) usage ;; diff --git a/tests/test_models.py b/tests/test_models.py index d73e845..19af1e7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -122,17 +122,88 @@ def test_lookup_returns_correct_shape(client): assert data["already_queued"] is False -def test_lookup_unknown_pipeline_tag_returns_null_adapter(client): - """An unrecognised pipeline_tag yields adapter_recommendation=null.""" +def test_lookup_unknown_pipeline_tag_returns_null_adapter_and_incompatible(client): + """An unrecognised pipeline_tag yields adapter_recommendation=null and compatible=False.""" mock_resp = MagicMock() mock_resp.status_code = 200 - mock_resp.json.return_value = _make_hf_response("org/m", "audio-classification") + mock_resp.json.return_value = _make_hf_response("org/m", "reinforcement-learning") with patch("app.models.httpx.get", return_value=mock_resp): r = client.get("/api/models/lookup", params={"repo_id": "org/m"}) assert r.status_code == 200 - assert r.json()["adapter_recommendation"] is None + data = r.json() + assert data["adapter_recommendation"] is None + assert data["compatible"] is False + assert data["role"] is None + assert data["service"] is None + assert "CircuitForge model ecosystem" in data["warning"] + + +def test_lookup_stt_tag_returns_compatible_with_cf_stt_service(client): + """automatic-speech-recognition tag yields compatible=True, service=cf-stt.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = _make_hf_response("openai/whisper-base", "automatic-speech-recognition") + + with patch("app.models.httpx.get", return_value=mock_resp): + r = client.get("/api/models/lookup", params={"repo_id": "openai/whisper-base"}) + + assert r.status_code == 200 + data = r.json() + assert data["compatible"] is True + assert data["adapter_recommendation"] is None + assert data["role"] == "stt" + assert data["service"] == "cf-stt" + assert data["warning"] is None + + +def test_lookup_vision_tag_returns_compatible_with_cf_vision_service(client): + """image-classification tag yields compatible=True, service=cf-vision.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = _make_hf_response("google/siglip-base", "image-classification") + + with patch("app.models.httpx.get", return_value=mock_resp): + r = client.get("/api/models/lookup", params={"repo_id": "google/siglip-base"}) + + assert r.status_code == 200 + data = r.json() + assert data["compatible"] is True + assert data["role"] == "vision" + assert data["service"] == "cf-vision" + + +def test_lookup_audio_classification_tag_returns_cf_voice_service(client): + """audio-classification tag yields compatible=True, service=cf-voice.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = _make_hf_response("org/audio-model", "audio-classification") + + with patch("app.models.httpx.get", return_value=mock_resp): + r = client.get("/api/models/lookup", params={"repo_id": "org/audio-model"}) + + assert r.status_code == 200 + data = r.json() + assert data["compatible"] is True + assert data["role"] == "classifier" + assert data["service"] == "cf-voice" + + +def test_lookup_embedding_tag_returns_compatible_with_cf_core_service(client): + """feature-extraction tag yields compatible=True, service=cf-core.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = _make_hf_response("BAAI/bge-small-en", "feature-extraction") + + with patch("app.models.httpx.get", return_value=mock_resp): + r = client.get("/api/models/lookup", params={"repo_id": "BAAI/bge-small-en"}) + + assert r.status_code == 200 + data = r.json() + assert data["compatible"] is True + assert data["role"] == "embedding" + assert data["service"] == "cf-core" def test_lookup_already_queued_flag(client): @@ -181,6 +252,26 @@ def test_queue_add_returns_entry_fields(client): assert entry["adapter_recommendation"] == "ZeroShotAdapter" +def test_queue_preserves_role_and_service(client): + """POST /queue with role/service fields round-trips them through GET /queue.""" + r = client.post("/api/models/queue", json={ + "repo_id": "openai/whisper-base", + "pipeline_tag": "automatic-speech-recognition", + "adapter_recommendation": None, + "role": "stt", + "service": "cf-stt", + }) + assert r.status_code == 201 + entry = r.json() + assert entry["role"] == "stt" + assert entry["service"] == "cf-stt" + + r2 = client.get("/api/models/queue") + items = r2.json() + assert items[0]["role"] == "stt" + assert items[0]["service"] == "cf-stt" + + # ── POST /queue — 409 duplicate ──────────────────────────────────────────────── def test_queue_duplicate_returns_409(client): @@ -317,7 +408,12 @@ def test_installed_detects_downloaded_model(client, tmp_path): model_dir.mkdir() (model_dir / "config.json").write_text(json.dumps({"model_type": "bert"}), encoding="utf-8") (model_dir / "model_info.json").write_text( - json.dumps({"repo_id": "org/mymodel", "adapter_recommendation": "ZeroShotAdapter"}), + json.dumps({ + "repo_id": "org/mymodel", + "adapter_recommendation": "ZeroShotAdapter", + "role": "classifier", + "service": "avocet", + }), encoding="utf-8", ) @@ -329,6 +425,51 @@ def test_installed_detects_downloaded_model(client, tmp_path): assert items[0]["name"] == "org--mymodel" assert items[0]["adapter"] == "ZeroShotAdapter" assert items[0]["model_id"] == "org/mymodel" + assert items[0]["role"] == "classifier" + assert items[0]["service"] == "avocet" + + +def test_installed_stt_model_surfaces_role_and_service(client): + """A downloaded STT model's role/service are returned by GET /installed.""" + from app import models as models_module + + model_dir = models_module._MODELS_DIR / "openai--whisper-base" + model_dir.mkdir() + (model_dir / "config.json").write_text(json.dumps({"model_type": "whisper"}), encoding="utf-8") + (model_dir / "model_info.json").write_text( + json.dumps({ + "repo_id": "openai/whisper-base", + "adapter_recommendation": None, + "role": "stt", + "service": "cf-stt", + }), + encoding="utf-8", + ) + + r = client.get("/api/models/installed") + assert r.status_code == 200 + items = r.json() + assert items[0]["role"] == "stt" + assert items[0]["service"] == "cf-stt" + assert items[0]["adapter"] is None + + +def test_installed_finetuned_model_defaults_to_avocet_service(client): + """Fine-tuned models with no role/service in training_info default to avocet/classifier.""" + from app import models as models_module + + model_dir = models_module._MODELS_DIR / "my-finetuned-v2" + model_dir.mkdir() + (model_dir / "training_info.json").write_text( + json.dumps({"base_model": "microsoft/deberta-v3-base", "epochs": 3}), + encoding="utf-8", + ) + + r = client.get("/api/models/installed") + assert r.status_code == 200 + items = r.json() + assert items[0]["role"] == "classifier" + assert items[0]["service"] == "avocet" def test_installed_detects_finetuned_model(client): diff --git a/web/.gitignore b/web/.gitignore index a547bf3..0f48f4d 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Local environment overrides +.env + diff --git a/web/src/views/ModelsView.vue b/web/src/views/ModelsView.vue index 3d7871b..34cba78 100644 --- a/web/src/views/ModelsView.vue +++ b/web/src/views/ModelsView.vue @@ -42,6 +42,12 @@ {{ lookupResult.pipeline_tag }} + + {{ lookupResult.role }} + + + {{ lookupResult.service }} + {{ lookupResult.adapter_recommendation }} @@ -61,11 +67,10 @@ @@ -90,7 +95,9 @@
- {{ model.pipeline_tag }} + {{ model.pipeline_tag }} + {{ model.role }} + {{ model.service }} {{ model.adapter_recommendation }}
@@ -116,6 +123,8 @@
{{ model.pipeline_tag }} + {{ model.role }} + {{ model.service }}
-
- - - - - - - - - - - - - - - - - - - -
NameTypeAdapterSize
{{ model.name }} - - {{ model.type }} - - {{ model.adapter ?? '—' }}{{ humanBytes(model.size) }} - -
-
+ @@ -194,6 +256,8 @@ interface LookupResult { repo_id: string pipeline_tag: string | null adapter_recommendation: string | null + role: string | null + service: string | null compatible: boolean warning: string | null size: number | null @@ -208,20 +272,27 @@ interface QueuedModel { status: 'pending' | 'downloading' | 'done' | 'error' pipeline_tag: string | null adapter_recommendation: string | null + role: string | null + service: string | null } interface InstalledModel { name: string type: 'finetuned' | 'downloaded' adapter: string | null - size: number + role: string | null + service: string | null + size_bytes: number + model_id: string | null } interface SseProgressEvent { - model_id: string - pct: number | null - status: 'progress' | 'done' | 'error' - message?: string + type: 'progress' | 'done' | 'error' | 'idle' + repo_id?: string + pct?: number + downloaded_bytes?: number + total_bytes?: number + error?: string } // ── State ───────────────────────────────────────────── @@ -235,7 +306,8 @@ const addingToQueue = ref(false) const queuedModels = ref([]) const installedModels = ref([]) -const downloadProgress = ref>({}) +const downloadProgress = ref>({}) +const classifyDraft = ref>({}) const downloadErrors = ref>({}) let pollInterval: ReturnType | null = null @@ -251,8 +323,69 @@ const downloadingModels = computed(() => queuedModels.value.filter(m => m.status === 'downloading') ) +const SERVICE_ORDER = ['avocet', 'cf-text', 'cf-stt', 'cf-tts', 'cf-vision', 'cf-image', 'cf-core', 'cf-voice', 'other'] + +const CLASSIFIABLE_SERVICES = [ + { value: 'avocet', label: 'Avocet — Email Classifiers' }, + { value: 'cf-text', label: 'cf-text — Language Models' }, + { value: 'cf-stt', label: 'cf-stt — Speech Recognition' }, + { value: 'cf-tts', label: 'cf-tts — Text to Speech' }, + { value: 'cf-vision', label: 'cf-vision — Vision / VLM' }, + { value: 'cf-image', label: 'cf-image — Image Generation' }, + { value: 'cf-core', label: 'cf-core — Embeddings' }, + { value: 'cf-voice', label: 'cf-voice — Audio Classification' }, +] + +const SERVICE_ROLES: Record = { + 'avocet': ['classifier', 'reranker'], + 'cf-text': ['generator'], + 'cf-stt': ['stt', 'alm'], + 'cf-tts': ['tts'], + 'cf-vision': ['vision', 'vlm', 'embedding'], + 'cf-image': ['image-gen'], + 'cf-core': ['embedding'], + 'cf-voice': ['classifier'], +} + +function rolesForService(service: string): string[] { + return SERVICE_ROLES[service] ?? [] +} + +const installedByService = computed(() => { + const grouped: Record = {} + for (const model of installedModels.value) { + const key = model.service ?? 'other' + if (!grouped[key]) grouped[key] = [] + grouped[key].push(model) + } + // Return ordered sections: known services first, then anything else + const keys = [...SERVICE_ORDER.filter(s => grouped[s]), ...Object.keys(grouped).filter(k => !SERVICE_ORDER.includes(k))] + return keys.map(key => ({ service: key, models: grouped[key] })) +}) + // ── Helpers ─────────────────────────────────────────── +const SERVICE_LABELS: Record = { + 'avocet': 'Avocet — Email Classifiers', + 'cf-text': 'cf-text — Language Models', + 'cf-stt': 'cf-stt — Speech Recognition', + 'cf-tts': 'cf-tts — Text to Speech', + 'cf-vision': 'cf-vision — Vision / VLM', + 'cf-image': 'cf-image — Image Generation', + 'cf-core': 'cf-core — Embeddings', + 'cf-voice': 'cf-voice — Audio Classification', + 'other': 'Other — Unclassified', +} + +function serviceLabel(service: string): string { + return SERVICE_LABELS[service] ?? service +} + +function serviceChipClass(service: string | null): string { + if (!service) return 'chip-service-other' + return `chip-service-${service.replace(/[^a-z0-9]/g, '-')}` +} + function humanBytes(bytes: number | null): string { if (bytes == null) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] @@ -305,10 +438,11 @@ async function addToQueue() { if (!lookupResult.value) return addingToQueue.value = true try { + const { repo_id, pipeline_tag, adapter_recommendation, role, service } = lookupResult.value const res = await fetch('/api/models/queue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ repo_id: lookupResult.value.repo_id }), + body: JSON.stringify({ repo_id, pipeline_tag, adapter_recommendation, role, service }), }) if (res.ok) { lookupResult.value = { ...lookupResult.value, already_queued: true } @@ -339,12 +473,50 @@ async function dismissModel(id: string) { } catch { /* ignore */ } } +function onServiceChange(name: string, service: string) { + const roles = SERVICE_ROLES[service] ?? [] + classifyDraft.value = { + ...classifyDraft.value, + [name]: { service, role: roles.length === 1 ? roles[0] : '' }, + } +} + +function setClassifyRole(name: string, role: string) { + classifyDraft.value = { + ...classifyDraft.value, + [name]: { ...classifyDraft.value[name], role }, + } +} + +async function saveClassify(name: string) { + const draft = classifyDraft.value[name] + if (!draft?.service || !draft?.role) return + try { + const res = await fetch(`/api/models/installed/${encodeURIComponent(name)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service: draft.service, role: draft.role }), + }) + if (res.ok) { + // Update in-place so the model moves to the correct service group + installedModels.value = installedModels.value.map(m => + m.name === name ? { ...m, service: draft.service, role: draft.role } : m + ) + const updated = { ...classifyDraft.value } + delete updated[name] + classifyDraft.value = updated + await loadQueue() + } + } catch { /* non-fatal */ } +} + async function deleteInstalled(name: string) { if (!window.confirm(`Delete installed model "${name}"? This cannot be undone.`)) return try { const res = await fetch(`/api/models/installed/${encodeURIComponent(name)}`, { method: 'DELETE' }) if (res.ok) { installedModels.value = installedModels.value.filter(m => m.name !== name) + await loadQueue() } } catch { /* ignore */ } } @@ -378,21 +550,28 @@ function startSse() { return } - const { model_id, pct, status, message } = event + const { type, repo_id, pct, downloaded_bytes, error } = event + if (!repo_id) return - if (status === 'progress' && pct != null) { - downloadProgress.value = { ...downloadProgress.value, [model_id]: pct } - } else if (status === 'done') { + if (type === 'progress') { + const bytes = downloaded_bytes ?? 0 + // pct stays null when total_bytes is unknown so we can show "X MB" instead + const progress = (pct != null && pct > 0) ? pct : (bytes > 0 ? null : undefined) + downloadProgress.value = { ...downloadProgress.value, [repo_id]: { pct: progress ?? null, bytes } } + } else if (type === 'done') { const updated = { ...downloadProgress.value } - delete updated[model_id] + delete updated[repo_id] downloadProgress.value = updated - queuedModels.value = queuedModels.value.filter(m => m.id !== model_id) + queuedModels.value = queuedModels.value.filter(m => m.repo_id !== repo_id) loadInstalled() - } else if (status === 'error') { - downloadErrors.value = { - ...downloadErrors.value, - [model_id]: message ?? 'Download failed.', + } else if (type === 'error') { + const entry = queuedModels.value.find(m => m.repo_id === repo_id) + if (entry) { + downloadErrors.value = { + ...downloadErrors.value, + [entry.id]: error ?? 'Download failed.', + } } } }) @@ -595,12 +774,6 @@ onUnmounted(() => { align-self: flex-start; } -.btn-add-queue-warn { - background: var(--color-surface-raised, #e4ebf5); - color: var(--color-text-secondary, #6b7a99); - border: 1px solid var(--color-border, #d0d7e8); -} - /* ── Model cards (queue + downloads) ── */ .model-card { border: 1px solid var(--color-border, #a8b8d0); @@ -715,6 +888,35 @@ onUnmounted(() => { word-break: break-all; } +.td-actions { + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: flex-start; +} + +.classify-row { + display: flex; + gap: 0.35rem; + align-items: center; + flex-wrap: wrap; +} + +.classify-select { + font-size: 0.78rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; + border: 1px solid var(--color-border, #444); + background: var(--color-surface, #1e1e2e); + color: var(--color-text, #cdd6f4); + cursor: pointer; +} + +.classify-select:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* ── Badges ── */ .badge-group { display: flex; @@ -777,6 +979,76 @@ onUnmounted(() => { background: color-mix(in srgb, var(--color-accent, #c4732a) 12%, var(--color-surface-alt, #dde4f0)); } +.chip-role { + color: var(--color-info, #1e6091); + background: color-mix(in srgb, var(--color-info, #1e6091) 12%, var(--color-surface-alt, #dde4f0)); +} + +.chip-sm { + font-size: 0.68rem; + padding: 0.1rem 0.4rem; +} + +/* Service chips — one colour per CF service */ +.chip-service-avocet { + color: var(--color-primary, #2d5a27); + background: color-mix(in srgb, var(--color-primary, #2d5a27) 15%, var(--color-surface-alt, #dde4f0)); +} + +.chip-service-cf-text { + color: #c2410c; + background: color-mix(in srgb, #c2410c 12%, var(--color-surface-alt, #dde4f0)); +} + +.chip-service-cf-stt { + color: #5e35b1; + background: color-mix(in srgb, #5e35b1 12%, var(--color-surface-alt, #dde4f0)); +} + +.chip-service-cf-tts { + color: #0277bd; + background: color-mix(in srgb, #0277bd 12%, var(--color-surface-alt, #dde4f0)); +} + +.chip-service-cf-vision { + color: #00695c; + background: color-mix(in srgb, #00695c 12%, var(--color-surface-alt, #dde4f0)); +} + +.chip-service-cf-core { + color: #6d4c41; + background: color-mix(in srgb, #6d4c41 12%, var(--color-surface-alt, #dde4f0)); +} + +.chip-service-cf-voice { + color: #ad1457; + background: color-mix(in srgb, #ad1457 12%, var(--color-surface-alt, #dde4f0)); +} + +.chip-service-other { + color: var(--color-text-muted, #4a5c7a); + background: var(--color-surface-alt, #dde4f0); +} + +/* ── Installed group ── */ +.installed-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.installed-group-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; +} + +.installed-group-count { + font-size: 0.78rem; + color: var(--color-text-muted, #4a5c7a); +} + /* ── Buttons ── */ .btn-primary, .btn-danger { padding: 0.4rem 0.9rem; @@ -852,7 +1124,7 @@ onUnmounted(() => { .installed-table th:nth-child(3), .installed-table td:nth-child(3) { - display: none; /* hide Adapter column on very narrow screens */ + display: none; /* hide Role column on very narrow screens */ } }