diff --git a/CHANGELOG.md b/CHANGELOG.md index 049eaf7..fcaa107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.81.0 + +### Workflows + +- **An agent node says WHO runs it.** A node whose profile is a minted catalog id (`ap_` + 16 random characters) was titled by humanising that id — "Ap nro qux n7d c7 ll30", which names nothing and cannot be resolved without the host's catalog. It is now the generic **"AI Agent"**, carrying the brand mark of the model it runs, resolved from the model slug alone. A model with no published mark keeps its kind glyph rather than getting an invented logo. A human-authored slug is untouched — the minted-id shape is pinned to its exact length, so `ap_code_review` stays "Ap code review". +- **The mark names the model the run ACTUALLY used.** The subtitle already yields to `state.model` once a run is live (a router can fall back to another lab); the mark now agrees with it, so a fallback run can't show one lab's logo beside another lab's name. +- **The graph reframes itself when density changes.** Node ids and count are unchanged across a compact/expanded flip, so React Flow won't auto-fit and the graph was left mis-zoomed. It now refits — and *glides* to the new framing rather than jumping, because the reader is watching it happen. Motion is opt-out: a reader who asked for `prefers-reduced-motion`, or an environment that cannot report the preference, gets the framing without the movement. + +### Dashboard + +- **New API:** `modelBrandFor(model: string)` and `ModelBrandStack` (exported from `./dashboard`) — the mark for a model, resolved from the id alone, so any surface holding a model string shows the same glyph the picker does instead of deriving one of its own. Returns `null` for a model with no published mark. + +### Internal + +- The brand table, its resolution, and the marks move to a leaf module. Rendering a mark previously meant importing from the model picker, and a bundler cannot split a source module — so the `./workflows` entry pulled in the entire picker (`@radix-ui/react-dropdown-menu`, the `ModelPicker` component and its rows). A consumer rendering a workflow graph downloaded a dropdown it never shows. The picker re-exports the leaf, so nothing about the public API changes. + ## 0.80.0 ### Chat diff --git a/package.json b/package.json index 89fd148..feda5bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/sandbox-ui", - "version": "0.80.0", + "version": "0.81.0", "description": "Unified UI component library for Tangle Sandbox — primitives, chat, dashboard, terminal, editor, and workspace components", "repository": { "type": "git", diff --git a/src/dashboard/dashboard-layout.test.tsx b/src/dashboard/dashboard-layout.test.tsx index a68f1ac..f459334 100644 --- a/src/dashboard/dashboard-layout.test.tsx +++ b/src/dashboard/dashboard-layout.test.tsx @@ -70,14 +70,11 @@ describe("DashboardLayout — rail collapse control", () => { // The collapse toggle persists rail state to localStorage; reset it so each // test starts from the provider's default (expanded), independent of order. - // localStorage is unavailable in some jsdom setups (opaque origin) where the - // provider's writes are already no-ops, so guard the clear. + // Unguarded on purpose: `setupFiles` runs before every test file, and the setup + // installs an in-memory Storage wherever the host does not supply a usable one — + // including where reading `localStorage` throws (see test-support/memory-storage). beforeEach(() => { - try { - localStorage.clear() - } catch { - /* localStorage unavailable — nothing was persisted to reset */ - } + localStorage.clear() }) it("renders a discoverable collapse toggle on the labeled rail", () => { diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts index dba7d13..22e8ae9 100644 --- a/src/dashboard/index.ts +++ b/src/dashboard/index.ts @@ -134,6 +134,14 @@ export { type ModelPickerProps, type ModelPickerVariant, type ModelInfo, + // The model's brand mark, so any surface that knows a model id — a workflow + // node, a run header — shows the same glyph the picker does instead of + // re-deriving one. A caller holds a model STRING, so `modelBrandFor` is the + // whole surface; the brand table, its keys, and the ModelInfo-shaped resolver + // behind it stay internal. + ModelBrandStack, + modelBrandFor, + type ModelBrandIdentity, } from "./model-picker"; export { BillingDashboard, diff --git a/src/dashboard/model-picker.tsx b/src/dashboard/model-picker.tsx index 9d08d57..1f21852 100644 --- a/src/dashboard/model-picker.tsx +++ b/src/dashboard/model-picker.tsx @@ -1,153 +1,38 @@ "use client"; -import type { ReasoningEffort } from "@tangle-network/agent-interface"; import * as React from "react"; -import { TangleKnot } from "@tangle-network/brand"; -import { ProviderLogo } from "./provider-logo"; import { ChevronDown, Search, Sparkles, Loader2 } from "lucide-react"; import * as Popover from "@radix-ui/react-dropdown-menu"; import { cn } from "../lib/utils"; -import ai21Logo from "@lobehub/icons-static-svg/icons/ai21.svg"; -import alibabaLogo from "@lobehub/icons-static-svg/icons/alibaba.svg"; -import anthropicLogo from "@lobehub/icons-static-svg/icons/anthropic.svg"; -import azureLogo from "@lobehub/icons-static-svg/icons/azure.svg"; -import bedrockLogo from "@lobehub/icons-static-svg/icons/bedrock.svg"; -import cerebrasLogo from "@lobehub/icons-static-svg/icons/cerebras.svg"; -import cohereLogo from "@lobehub/icons-static-svg/icons/cohere.svg"; -import deepseekLogo from "@lobehub/icons-static-svg/icons/deepseek.svg"; -import elevenlabsLogo from "@lobehub/icons-static-svg/icons/elevenlabs.svg"; -import falLogo from "@lobehub/icons-static-svg/icons/fal.svg"; -import fireworksLogo from "@lobehub/icons-static-svg/icons/fireworks.svg"; -import googleLogo from "@lobehub/icons-static-svg/icons/google.svg"; -import groqLogo from "@lobehub/icons-static-svg/icons/groq.svg"; -import klingLogo from "@lobehub/icons-static-svg/icons/kling.svg"; -import lumaLogo from "@lobehub/icons-static-svg/icons/luma.svg"; -import metaLogo from "@lobehub/icons-static-svg/icons/meta.svg"; -import mistralLogo from "@lobehub/icons-static-svg/icons/mistral.svg"; -import moonshotLogo from "@lobehub/icons-static-svg/icons/moonshot.svg"; -import openaiLogo from "@lobehub/icons-static-svg/icons/openai.svg"; -import openrouterLogo from "@lobehub/icons-static-svg/icons/openrouter.svg"; -import perplexityLogo from "@lobehub/icons-static-svg/icons/perplexity.svg"; -import pikaLogo from "@lobehub/icons-static-svg/icons/pika.svg"; -import replicateLogo from "@lobehub/icons-static-svg/icons/replicate.svg"; -import runwayLogo from "@lobehub/icons-static-svg/icons/runway.svg"; -import stabilityLogo from "@lobehub/icons-static-svg/icons/stability.svg"; -import togetherLogo from "@lobehub/icons-static-svg/icons/together.svg"; -import vertexLogo from "@lobehub/icons-static-svg/icons/vertexai.svg"; -import xaiLogo from "@lobehub/icons-static-svg/icons/xai.svg"; -import zaiLogo from "@lobehub/icons-static-svg/icons/zai.svg"; +import { + BrandLogo, + brandInfo, + canonicalModelId, + ModelBrandStack, + normalizeBrandKey, + resolveModelBrandIdentity, + type ModelBrandIdentity, + type ModelBrandKey, + type ModelInfo, +} from "../lib/model-brand"; + +// A model's brand identity — the types, the brand table and the marks — lives in +// `lib/model-brand`, a leaf that carries no picker UI, so a surface that draws only +// the glyph (a workflow node, a run header) does not pull the picker in with it. +// Re-exported here so the names keep coming from `./dashboard`. +export { + canonicalModelId, + modelBrandFor, + ModelBrandStack, + resolveModelBrandIdentity, + type ModelBrandIdentity, + type ModelBrandInfo, + type ModelBrandKey, + type ModelInfo, +} from "../lib/model-brand"; // ── Types ────────────────────────────────────────────────────────────────── -/** - * Wire-format model entry as returned by `/v1/models` on the Tangle Router - * (and most OpenAI-compatible gateways). Field names match the upstream - * response so consumers can pass `data.data` straight through. - */ -export interface ModelInfo { - /** Provider-local id, e.g. "gpt-5.4" or "anthropic/claude-sonnet-4-6". */ - id: string; - /** Human label (defaults to id if absent). */ - name?: string; - /** Provider key, e.g. "openai", "anthropic". Underscored for compat with router. */ - _provider?: string; - /** Alternative provider field on some gateways. */ - provider?: string; - /** - * Per-token prices in USD as decimal strings. Multiply by 1_000_000 for - * the conventional $/M tokens display. - */ - pricing?: { prompt?: string | null; completion?: string | null }; - context_length?: number; - description?: string | null; - architecture?: { - modality?: string; - input_modalities?: string[]; - output_modalities?: string[]; - }; - /** Hosting company or router that serves the request. Defaults to `_provider` / `provider`. */ - hostProvider?: string; - /** Lab/company that authored the model. Inferred from model id/name when omitted. */ - modelLab?: string; - /** Optional explicit logo keys when provider/lab ids are not stable. */ - logos?: { - host?: ModelBrandKey; - lab?: ModelBrandKey; - hostUrl?: string; - labUrl?: string; - }; - /** - * Marks a model as recommended. When any catalog row carries this flag the - * picker surfaces those rows in a "Recommended" section at the top. When no - * row is flagged the picker falls back to {@link DEFAULT_FEATURED_MODEL_IDS} - * (matched by dedup key) so the section is never empty for a router catalog. - */ - featured?: boolean; - /** - * Per-model reasoning capability, fed to the reasoning-effort picker's capability filter. Source it - * from your model catalog (e.g. the router's `supported_parameters` containing `reasoning`). When - * `false`, only `none` is offered for this model regardless of harness. - */ - supportsReasoning?: boolean; - /** The model's own reasoning ceiling, if narrower than its harness's (e.g. a model capped at `high`). */ - maxReasoningEffort?: ReasoningEffort; -} - -export type ModelBrandKey = - | "ai21" - | "alibaba" - | "anthropic" - | "azure" - | "bedrock" - | "cartesia" - | "cerebras" - | "cohere" - | "deepseek" - | "elevenlabs" - | "fal" - | "fireworks" - | "google" - | "groq" - | "kuaishou" - | "luma" - | "meta" - | "mistral" - | "moonshot" - | "openai" - | "openrouter" - | "perplexity" - | "pika" - | "replicate" - | "runway" - | "stability" - | "tangle" - | "tcloud" - | "together" - | "vertex" - | "xai" - | "zai" - | "unknown"; - -export interface ModelBrandIdentity { - host: ModelBrandInfo; - lab: ModelBrandInfo; - combined: boolean; -} - -export interface ModelBrandInfo { - key: ModelBrandKey; - label: string; - logoUrl?: string; - logo?: "tangle"; - /** - * True for the bundled single-color brand glyphs, which are rendered as a - * CSS mask filled with the foreground token so they stay visible in both - * themes. Caller-supplied logo URLs (`ModelInfo.logos.*Url`) may be - * full-color artwork and are rendered as-is. - */ - monochrome?: boolean; -} - export type ModelPickerVariant = "field" | "pill"; export interface ModelPickerProps { @@ -199,19 +84,6 @@ export interface ModelPickerProps { // ── Helpers ──────────────────────────────────────────────────────────────── -/** - * Resolve the canonical id for a model. Some upstreams already prefix the - * provider in the id (e.g. "anthropic/claude-haiku-4.5"); others put it in - * `_provider` and leave the id bare. Always returns "/" - * unless the id is already prefixed. - */ -export function canonicalModelId(model: ModelInfo): string { - const id = model.id; - if (id.includes("/")) return id; - const provider = model._provider ?? model.provider; - return provider ? `${provider}/${id}` : id; -} - /** * Stable key used to collapse catalog duplicates. The Tangle Router lists the * same underlying model under several host prefixes (e.g. `openai/gpt-5.4`, @@ -293,25 +165,6 @@ export function formatContext(ctx: number | undefined): string | null { return `${ctx} ctx`; } -export function resolveModelBrandIdentity(model: ModelInfo): ModelBrandIdentity { - const canonical = canonicalModelId(model); - const hostKey = normalizeBrandKey(model.logos?.host ?? model.hostProvider ?? model._provider ?? model.provider ?? firstIdSegment(canonical)); - const labKey = normalizeBrandKey(model.logos?.lab ?? model.modelLab ?? inferModelLab(model, canonical, hostKey)); - const hostBase = brandInfo(hostKey); - const labBase = brandInfo(labKey); - const host = model.logos?.hostUrl - ? { ...hostBase, logoUrl: model.logos.hostUrl, monochrome: false } - : hostBase; - const lab = model.logos?.labUrl - ? { ...labBase, logoUrl: model.logos.labUrl, monochrome: false } - : labBase; - return { - host, - lab, - combined: host.key === lab.key, - }; -} - const PRIORITY_MODEL_GROUPS: ModelBrandKey[] = ["anthropic", "openai", "google", "deepseek", "zai", "moonshot"]; function modelGroupKey(model: ModelInfo): string { @@ -777,270 +630,3 @@ function ModelRow({ ); } - -export function ModelBrandStack({ identity, size }: { identity: ModelBrandIdentity; size: "sm" | "md" }) { - if (identity.combined) return ; - const hasHostLogo = hasRealLogo(identity.host); - const hasLabLogo = hasRealLogo(identity.lab); - if (!hasHostLogo && !hasLabLogo) return null; - if (!hasHostLogo) return ; - if (!hasLabLogo) return ; - return ( -
- - -
- ); -} - -/** - * Provider keys for which the vendored {@link ProviderLogo} ships a real - * full-color brand mark (vs. a tinted monogram). These render in color; every - * other brand falls through to the bundled lobehub monochrome mask. Kept in - * sync with provider-logo.tsx's LOGOS table. - */ -const COLOR_MARK_PROVIDERS: ReadonlySet = new Set([ - "anthropic", - "openai", - "google", - "deepseek", - "mistral", - "xai", - "nvidia", - "meta", - "moonshot", -]); - -function BrandLogo({ brand, size, className }: { brand: ModelBrandInfo; size: "xs" | "sm" | "md"; className?: string }) { - if (!hasRealLogo(brand)) return null; - const pixelSize = size === "xs" ? 14 : size === "sm" ? 16 : 28; - // Near-black marks (xai #000, moonshot #16191E) stay inside the light - // `bg-background` ring chip so they remain visible against dark themes. - const useColorMark = - brand.logo !== "tangle" && COLOR_MARK_PROVIDERS.has(brand.key); - return ( - - {brand.logo === "tangle" ? ( - - ) : useColorMark ? ( - - ) : brand.logoUrl && brand.monochrome ? ( - - ) : brand.logoUrl ? ( - - ) : null} - - ); -} - -function hasRealLogo(brand: ModelBrandInfo): boolean { - return Boolean(brand.logoUrl || brand.logo); -} - -/** - * Wrap a URL for use inside a CSS `url("…")` value. The bundled SVG data - * URLs contain raw quotes and hashes — valid in an attribute but - * terminal inside a CSS string — so percent-encode the characters CSS - * cannot carry. - */ -function cssUrl(url: string): string { - return `url("${url.replace(/[\\"'#\n]/g, (char) => encodeURIComponent(char))}")`; -} - -function modelLogo(path: string): string { - return path; -} - -function brandLogo(key: ModelBrandKey): string | undefined { - switch (key) { - case "ai21": - return modelLogo(ai21Logo); - case "alibaba": - return modelLogo(alibabaLogo); - case "anthropic": - return modelLogo(anthropicLogo); - case "azure": - return modelLogo(azureLogo); - case "bedrock": - return modelLogo(bedrockLogo); - case "cerebras": - return modelLogo(cerebrasLogo); - case "cohere": - return modelLogo(cohereLogo); - case "deepseek": - return modelLogo(deepseekLogo); - case "elevenlabs": - return modelLogo(elevenlabsLogo); - case "fal": - return modelLogo(falLogo); - case "fireworks": - return modelLogo(fireworksLogo); - case "google": - return modelLogo(googleLogo); - case "groq": - return modelLogo(groqLogo); - case "kuaishou": - return modelLogo(klingLogo); - case "luma": - return modelLogo(lumaLogo); - case "meta": - return modelLogo(metaLogo); - case "mistral": - return modelLogo(mistralLogo); - case "moonshot": - return modelLogo(moonshotLogo); - case "openai": - return modelLogo(openaiLogo); - case "openrouter": - return modelLogo(openrouterLogo); - case "perplexity": - return modelLogo(perplexityLogo); - case "pika": - return modelLogo(pikaLogo); - case "replicate": - return modelLogo(replicateLogo); - case "runway": - return modelLogo(runwayLogo); - case "stability": - return modelLogo(stabilityLogo); - case "together": - return modelLogo(togetherLogo); - case "vertex": - return modelLogo(vertexLogo); - case "xai": - return modelLogo(xaiLogo); - case "zai": - return modelLogo(zaiLogo); - case "tangle": - case "tcloud": - case "cartesia": - case "unknown": - return undefined; - } -} - -function brandMark(key: ModelBrandKey): Pick { - if (key === "tangle" || key === "tcloud") return { logo: "tangle" }; - const logoUrl = brandLogo(key); - return logoUrl ? { logoUrl, monochrome: true } : {}; -} - -function inferModelLab(model: ModelInfo, canonical: string, hostKey: ModelBrandKey): string { - const name = model.name ?? ""; - const idSegments = canonical.split("/"); - const possibleLab = normalizeBrandKey(idSegments.length > 1 ? idSegments[1] : idSegments[0]); - const textKey = normalizeBrandKey(`${canonical} ${name}`); - if (hostKey !== possibleLab && possibleLab !== "unknown") return possibleLab; - if (textKey !== "unknown") return textKey; - return hostKey; -} - -function firstIdSegment(value: string): string { - return value.split("/")[0] ?? value; -} - -function normalizeBrandKey(value: string | undefined): ModelBrandKey { - const normalized = (value ?? "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - if (!normalized) return "unknown"; - if (/(^|-)anthropic($|-)|claude/.test(normalized)) return "anthropic"; - if (/(^|-)openai($|-)|(^|-)gpt-|o[1345]($|-)|chatgpt/.test(normalized)) return "openai"; - if (/(^|-)google($|-)|gemini|palm|imagen|veo/.test(normalized)) return "google"; - if (/(^|-)xai($|-)|grok/.test(normalized)) return "xai"; - if (/(^|-)meta($|-)|llama/.test(normalized)) return "meta"; - if (/(^|-)mistral($|-)|mixtral|codestral/.test(normalized)) return "mistral"; - if (/(^|-)cohere($|-)|command-r/.test(normalized)) return "cohere"; - if (/(^|-)deepseek($|-)/.test(normalized)) return "deepseek"; - if (/(^|-)moonshot($|-)|kimi/.test(normalized)) return "moonshot"; - if (/(^|-)zai($|-)|z-ai|glm/.test(normalized)) return "zai"; - if (/(^|-)qwen($|-)|alibaba|wan($|-)/.test(normalized)) return "alibaba"; - if (/(^|-)perplexity($|-)|sonar/.test(normalized)) return "perplexity"; - if (/(^|-)ai21($|-)|jamba/.test(normalized)) return "ai21"; - if (/(^|-)runway($|-)|gen-?[34]/.test(normalized)) return "runway"; - if (/(^|-)kling($|-)|kuaishou/.test(normalized)) return "kuaishou"; - if (/(^|-)luma($|-)|ray-?[12]/.test(normalized)) return "luma"; - if (/(^|-)pika($|-)/.test(normalized)) return "pika"; - if (/(^|-)stability($|-)|stable-diffusion|sdxl/.test(normalized)) return "stability"; - if (/(^|-)elevenlabs($|-)|eleven/.test(normalized)) return "elevenlabs"; - if (/(^|-)cartesia($|-)|sonic/.test(normalized)) return "cartesia"; - if (/(^|-)openrouter($|-)/.test(normalized)) return "openrouter"; - if (/(^|-)tcloud($|-)/.test(normalized)) return "tcloud"; - if (/(^|-)tangle($|-)/.test(normalized)) return "tangle"; - if (/(^|-)fal($|-)/.test(normalized)) return "fal"; - if (/(^|-)replicate($|-)/.test(normalized)) return "replicate"; - if (/(^|-)together($|-)/.test(normalized)) return "together"; - if (/(^|-)fireworks($|-)/.test(normalized)) return "fireworks"; - if (/(^|-)groq($|-)/.test(normalized)) return "groq"; - if (/(^|-)cerebras($|-)/.test(normalized)) return "cerebras"; - if (/(^|-)bedrock($|-)|amazon/.test(normalized)) return "bedrock"; - if (/(^|-)vertex($|-)/.test(normalized)) return "vertex"; - if (/(^|-)azure($|-)/.test(normalized)) return "azure"; - return "unknown"; -} - -function brandInfo(key: ModelBrandKey): ModelBrandInfo { - return BRAND_INFO[key] ?? BRAND_INFO.unknown; -} - -const BRAND_INFO: Record = { - ai21: { key: "ai21", label: "AI21", ...brandMark("ai21") }, - alibaba: { key: "alibaba", label: "Alibaba", ...brandMark("alibaba") }, - anthropic: { key: "anthropic", label: "Anthropic", ...brandMark("anthropic") }, - azure: { key: "azure", label: "Azure", ...brandMark("azure") }, - bedrock: { key: "bedrock", label: "AWS Bedrock", ...brandMark("bedrock") }, - cartesia: { key: "cartesia", label: "Cartesia" }, - cerebras: { key: "cerebras", label: "Cerebras", ...brandMark("cerebras") }, - cohere: { key: "cohere", label: "Cohere", ...brandMark("cohere") }, - deepseek: { key: "deepseek", label: "DeepSeek", ...brandMark("deepseek") }, - elevenlabs: { key: "elevenlabs", label: "ElevenLabs", ...brandMark("elevenlabs") }, - fal: { key: "fal", label: "Fal", ...brandMark("fal") }, - fireworks: { key: "fireworks", label: "Fireworks", ...brandMark("fireworks") }, - google: { key: "google", label: "Google", ...brandMark("google") }, - groq: { key: "groq", label: "Groq", ...brandMark("groq") }, - kuaishou: { key: "kuaishou", label: "Kling", ...brandMark("kuaishou") }, - luma: { key: "luma", label: "Luma", ...brandMark("luma") }, - meta: { key: "meta", label: "Meta", ...brandMark("meta") }, - mistral: { key: "mistral", label: "Mistral", ...brandMark("mistral") }, - moonshot: { key: "moonshot", label: "Moonshot", ...brandMark("moonshot") }, - openai: { key: "openai", label: "OpenAI", ...brandMark("openai") }, - openrouter: { key: "openrouter", label: "OpenRouter", ...brandMark("openrouter") }, - perplexity: { key: "perplexity", label: "Perplexity", ...brandMark("perplexity") }, - pika: { key: "pika", label: "Pika", ...brandMark("pika") }, - replicate: { key: "replicate", label: "Replicate", ...brandMark("replicate") }, - runway: { key: "runway", label: "Runway", ...brandMark("runway") }, - stability: { key: "stability", label: "Stability AI", ...brandMark("stability") }, - tangle: { key: "tangle", label: "Tangle", ...brandMark("tangle") }, - tcloud: { key: "tcloud", label: "tcloud", ...brandMark("tcloud") }, - together: { key: "together", label: "Together", ...brandMark("together") }, - vertex: { key: "vertex", label: "Vertex AI", ...brandMark("vertex") }, - xai: { key: "xai", label: "xAI", ...brandMark("xai") }, - zai: { key: "zai", label: "Z.ai", ...brandMark("zai") }, - unknown: { key: "unknown", label: "Unknown" }, -}; diff --git a/src/dashboard/sidebar-layout.test.tsx b/src/dashboard/sidebar-layout.test.tsx index aba3a33..fc10c7c 100644 --- a/src/dashboard/sidebar-layout.test.tsx +++ b/src/dashboard/sidebar-layout.test.tsx @@ -243,8 +243,11 @@ describe("SidebarLayout — expandable nav item", () => { }) describe("SidebarLayout — collapsed rail interactions", () => { + // Unguarded on purpose: `setupFiles` runs before every test file, and the setup + // installs an in-memory Storage wherever the host does not supply a usable one — + // including where reading `localStorage` throws (see test-support/memory-storage). beforeEach(() => { - try { localStorage.clear() } catch { /* opaque origin */ } + localStorage.clear() }) it("expands when the empty rail body is clicked (collapsed)", () => { diff --git a/src/lib/model-brand.test.ts b/src/lib/model-brand.test.ts new file mode 100644 index 0000000..f7ece2e --- /dev/null +++ b/src/lib/model-brand.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest" +import { modelBrandFor, resolveModelBrandIdentity } from "./model-brand" + +/** + * `modelBrandFor` is the whole public surface a caller holding a model STRING has: + * hand it an id, get the mark, or get `null` and keep whatever glyph you had. A + * caller cannot check the id first — the ids come from a router — so every shape one + * can arrive in has to answer, not throw. + */ +describe("modelBrandFor", () => { + it("reads the lab out of a hosted id", () => { + const brand = modelBrandFor("openrouter/anthropic/claude-sonnet-4-5") + expect(brand?.host.key).toBe("openrouter") + expect(brand?.lab.key).toBe("anthropic") + // Two different marks: the host carries it, the lab made it. + expect(brand?.combined).toBe(false) + }) + + it("collapses to ONE mark when the lab hosts its own model", () => { + const brand = modelBrandFor("anthropic/claude-sonnet-4-5") + expect(brand?.lab.key).toBe("anthropic") + expect(brand?.combined).toBe(true) + }) + + it("infers the lab from a bare id that names no host", () => { + expect(modelBrandFor("gpt-5.4")?.lab.key).toBe("openai") + expect(modelBrandFor("claude-sonnet-4-5")?.lab.key).toBe("anthropic") + }) + + it("answers null for a model with no published mark, rather than inventing one", () => { + expect(modelBrandFor("some-internal/model-x")).toBeNull() + }) + + it("answers null for nothing at all", () => { + expect(modelBrandFor("")).toBeNull() + expect(modelBrandFor(" ")).toBeNull() + }) + + it("survives an id that is only separators", () => { + // The ids come off a router, not a picker — a malformed one must not take the + // card down with it. + expect(() => modelBrandFor("/")).not.toThrow() + expect(() => modelBrandFor("///")).not.toThrow() + expect(modelBrandFor("/")).toBeNull() + }) +}) + +/** + * `resolveModelBrandIdentity` is the layer under {@link modelBrandFor}: it takes a + * whole `ModelInfo` (which the picker has and a workflow node does not) and answers + * WHO hosts the model and WHO made it. Two marks, or one where they are the same. + * + * Almost all of it is a precedence chain, and a precedence chain is exactly the kind + * of thing that reorders silently under a refactor — so each rung gets a test. + */ +describe("resolveModelBrandIdentity", () => { + it("takes the host and the lab straight from `logos` when they are given", () => { + // An explicit override outranks everything the id could imply. + const identity = resolveModelBrandIdentity({ + id: "anthropic/claude-sonnet-4-5", + _provider: "openai", + logos: { host: "groq", lab: "meta" }, + }) + expect(identity.host.key).toBe("groq") + expect(identity.lab.key).toBe("meta") + expect(identity.combined).toBe(false) + }) + + it("prefers `hostProvider` over the router's provider fields", () => { + // A gateway can serve someone else's model: the HOST is who takes the request. + const identity = resolveModelBrandIdentity({ + id: "claude-sonnet-4-5", + hostProvider: "openrouter", + _provider: "anthropic", + provider: "openai", + }) + expect(identity.host.key).toBe("openrouter") + }) + + it("falls back through `_provider`, then `provider`, then the id's first segment", () => { + expect( + resolveModelBrandIdentity({ id: "x", _provider: "groq", provider: "openai" }) + .host.key, + ).toBe("groq") + expect(resolveModelBrandIdentity({ id: "x", provider: "openai" }).host.key).toBe( + "openai", + ) + expect(resolveModelBrandIdentity({ id: "mistral/mixtral" }).host.key).toBe( + "mistral", + ) + }) + + it("takes the lab from `modelLab` before inferring it from the id", () => { + const identity = resolveModelBrandIdentity({ + id: "openrouter/some-model", + modelLab: "deepseek", + }) + expect(identity.lab.key).toBe("deepseek") + }) + + it("infers the lab from the model id when nothing declares it", () => { + // The id is the last thing left, and usually enough: a name carries its lab. + expect( + resolveModelBrandIdentity({ id: "openrouter/claude-sonnet-4-5" }).lab.key, + ).toBe("anthropic") + expect(resolveModelBrandIdentity({ id: "groq/llama-3.3-70b" }).lab.key).toBe( + "meta", + ) + }) + + it("reports ONE mark when the lab hosts its own model", () => { + // `combined` is what decides whether the card draws a stack or a single glyph. + expect( + resolveModelBrandIdentity({ id: "anthropic/claude-sonnet-4-5" }).combined, + ).toBe(true) + expect( + resolveModelBrandIdentity({ id: "openrouter/anthropic/claude-sonnet-4-5" }) + .combined, + ).toBe(false) + }) + + it("uses a caller's own artwork over the bundled glyph, and marks it full-colour", () => { + // A host-supplied logo may be full-colour art; the bundled marks are monochrome + // masks tinted with the foreground token, so the two cannot be rendered alike. + const identity = resolveModelBrandIdentity({ + id: "anthropic/claude-sonnet-4-5", + logos: { hostUrl: "https://cdn.example/host.svg" }, + }) + expect(identity.host.logoUrl).toBe("https://cdn.example/host.svg") + expect(identity.host.monochrome).toBe(false) + }) + + it("answers with the unknown brand rather than throwing on a model it cannot place", () => { + const identity = resolveModelBrandIdentity({ id: "some-internal/model-x" }) + expect(identity.host.key).toBe("unknown") + expect(identity.lab.key).toBe("unknown") + }) +}) diff --git a/src/lib/model-brand.tsx b/src/lib/model-brand.tsx new file mode 100644 index 0000000..5f63e65 --- /dev/null +++ b/src/lib/model-brand.tsx @@ -0,0 +1,476 @@ +"use client"; + +/** + * A model's brand identity and its mark — the lab/host glyph, resolved from a + * model id alone. + * + * Kept as a LEAF (no picker UI, no radix, no lucide) so a surface that only + * draws the glyph — a workflow node, a run header — pulls in the marks and the + * brand table, not the whole model picker. `model-picker.tsx` re-exports the + * public names from here, so this module stays internal. + */ + +import type { ReasoningEffort } from "@tangle-network/agent-interface"; +import { TangleKnot } from "@tangle-network/brand"; +import { ProviderLogo } from "./provider-logo"; +import { cn } from "./utils"; +import ai21Logo from "@lobehub/icons-static-svg/icons/ai21.svg"; +import alibabaLogo from "@lobehub/icons-static-svg/icons/alibaba.svg"; +import anthropicLogo from "@lobehub/icons-static-svg/icons/anthropic.svg"; +import azureLogo from "@lobehub/icons-static-svg/icons/azure.svg"; +import bedrockLogo from "@lobehub/icons-static-svg/icons/bedrock.svg"; +import cerebrasLogo from "@lobehub/icons-static-svg/icons/cerebras.svg"; +import cohereLogo from "@lobehub/icons-static-svg/icons/cohere.svg"; +import deepseekLogo from "@lobehub/icons-static-svg/icons/deepseek.svg"; +import elevenlabsLogo from "@lobehub/icons-static-svg/icons/elevenlabs.svg"; +import falLogo from "@lobehub/icons-static-svg/icons/fal.svg"; +import fireworksLogo from "@lobehub/icons-static-svg/icons/fireworks.svg"; +import googleLogo from "@lobehub/icons-static-svg/icons/google.svg"; +import groqLogo from "@lobehub/icons-static-svg/icons/groq.svg"; +import klingLogo from "@lobehub/icons-static-svg/icons/kling.svg"; +import lumaLogo from "@lobehub/icons-static-svg/icons/luma.svg"; +import metaLogo from "@lobehub/icons-static-svg/icons/meta.svg"; +import mistralLogo from "@lobehub/icons-static-svg/icons/mistral.svg"; +import moonshotLogo from "@lobehub/icons-static-svg/icons/moonshot.svg"; +import openaiLogo from "@lobehub/icons-static-svg/icons/openai.svg"; +import openrouterLogo from "@lobehub/icons-static-svg/icons/openrouter.svg"; +import perplexityLogo from "@lobehub/icons-static-svg/icons/perplexity.svg"; +import pikaLogo from "@lobehub/icons-static-svg/icons/pika.svg"; +import replicateLogo from "@lobehub/icons-static-svg/icons/replicate.svg"; +import runwayLogo from "@lobehub/icons-static-svg/icons/runway.svg"; +import stabilityLogo from "@lobehub/icons-static-svg/icons/stability.svg"; +import togetherLogo from "@lobehub/icons-static-svg/icons/together.svg"; +import vertexLogo from "@lobehub/icons-static-svg/icons/vertexai.svg"; +import xaiLogo from "@lobehub/icons-static-svg/icons/xai.svg"; +import zaiLogo from "@lobehub/icons-static-svg/icons/zai.svg"; + +// ── Types ────────────────────────────────────────────────────────────────── + +/** + * Wire-format model entry as returned by `/v1/models` on the Tangle Router + * (and most OpenAI-compatible gateways). Field names match the upstream + * response so consumers can pass `data.data` straight through. + */ +export interface ModelInfo { + /** Provider-local id, e.g. "gpt-5.4" or "anthropic/claude-sonnet-4-6". */ + id: string; + /** Human label (defaults to id if absent). */ + name?: string; + /** Provider key, e.g. "openai", "anthropic". Underscored for compat with router. */ + _provider?: string; + /** Alternative provider field on some gateways. */ + provider?: string; + /** + * Per-token prices in USD as decimal strings. Multiply by 1_000_000 for + * the conventional $/M tokens display. + */ + pricing?: { prompt?: string | null; completion?: string | null }; + context_length?: number; + description?: string | null; + architecture?: { + modality?: string; + input_modalities?: string[]; + output_modalities?: string[]; + }; + /** Hosting company or router that serves the request. Defaults to `_provider` / `provider`. */ + hostProvider?: string; + /** Lab/company that authored the model. Inferred from model id/name when omitted. */ + modelLab?: string; + /** Optional explicit logo keys when provider/lab ids are not stable. */ + logos?: { + host?: ModelBrandKey; + lab?: ModelBrandKey; + hostUrl?: string; + labUrl?: string; + }; + /** + * Marks a model as recommended. When any catalog row carries this flag the + * picker surfaces those rows in a "Recommended" section at the top. When no + * row is flagged the picker falls back to {@link DEFAULT_FEATURED_MODEL_IDS} + * (matched by dedup key) so the section is never empty for a router catalog. + */ + featured?: boolean; + /** + * Per-model reasoning capability, fed to the reasoning-effort picker's capability filter. Source it + * from your model catalog (e.g. the router's `supported_parameters` containing `reasoning`). When + * `false`, only `none` is offered for this model regardless of harness. + */ + supportsReasoning?: boolean; + /** The model's own reasoning ceiling, if narrower than its harness's (e.g. a model capped at `high`). */ + maxReasoningEffort?: ReasoningEffort; +} + +export type ModelBrandKey = + | "ai21" + | "alibaba" + | "anthropic" + | "azure" + | "bedrock" + | "cartesia" + | "cerebras" + | "cohere" + | "deepseek" + | "elevenlabs" + | "fal" + | "fireworks" + | "google" + | "groq" + | "kuaishou" + | "luma" + | "meta" + | "mistral" + | "moonshot" + | "openai" + | "openrouter" + | "perplexity" + | "pika" + | "replicate" + | "runway" + | "stability" + | "tangle" + | "tcloud" + | "together" + | "vertex" + | "xai" + | "zai" + | "unknown"; + +export interface ModelBrandIdentity { + host: ModelBrandInfo; + lab: ModelBrandInfo; + combined: boolean; +} + +export interface ModelBrandInfo { + key: ModelBrandKey; + label: string; + logoUrl?: string; + logo?: "tangle"; + /** + * True for the bundled single-color brand glyphs, which are rendered as a + * CSS mask filled with the foreground token so they stay visible in both + * themes. Caller-supplied logo URLs (`ModelInfo.logos.*Url`) may be + * full-color artwork and are rendered as-is. + */ + monochrome?: boolean; +} + +// ── Identity ─────────────────────────────────────────────────────────────── + +/** + * Resolve the canonical id for a model. Some upstreams already prefix the + * provider in the id (e.g. "anthropic/claude-haiku-4.5"); others put it in + * `_provider` and leave the id bare. Always returns "/" + * unless the id is already prefixed. + */ +export function canonicalModelId(model: ModelInfo): string { + const id = model.id; + if (id.includes("/")) return id; + const provider = model._provider ?? model.provider; + return provider ? `${provider}/${id}` : id; +} + +export function resolveModelBrandIdentity(model: ModelInfo): ModelBrandIdentity { + const canonical = canonicalModelId(model); + const hostKey = normalizeBrandKey(model.logos?.host ?? model.hostProvider ?? model._provider ?? model.provider ?? firstIdSegment(canonical)); + const labKey = normalizeBrandKey(model.logos?.lab ?? model.modelLab ?? inferModelLab(model, canonical, hostKey)); + const hostBase = brandInfo(hostKey); + const labBase = brandInfo(labKey); + const host = model.logos?.hostUrl + ? { ...hostBase, logoUrl: model.logos.hostUrl, monochrome: false } + : hostBase; + const lab = model.logos?.labUrl + ? { ...labBase, logoUrl: model.logos.labUrl, monochrome: false } + : labBase; + return { + host, + lab, + combined: host.key === lab.key, + }; +} + +/** + * The brand identity to show for a model id, or `null` when no published mark + * exists for it — a caller then falls back to its own generic glyph rather than + * rendering an empty chip or inventing a logo. + * + * Takes the bare id ("anthropic/claude-sonnet-4-5") so a surface that knows only + * the model string — a workflow node, a run header — can render the same mark the + * picker does. + */ +export function modelBrandFor(model: string): ModelBrandIdentity | null { + const trimmed = model.trim(); + if (!trimmed) return null; + const identity = resolveModelBrandIdentity({ id: trimmed }); + const real = hasRealLogo(identity.host) || hasRealLogo(identity.lab); + return real ? identity : null; +} + +// ── Marks ────────────────────────────────────────────────────────────────── + +export function ModelBrandStack({ identity, size }: { identity: ModelBrandIdentity; size: "sm" | "md" }) { + if (identity.combined) return ; + const hasHostLogo = hasRealLogo(identity.host); + const hasLabLogo = hasRealLogo(identity.lab); + if (!hasHostLogo && !hasLabLogo) return null; + if (!hasHostLogo) return ; + if (!hasLabLogo) return ; + return ( +
+ + +
+ ); +} + +/** + * Provider keys for which the vendored {@link ProviderLogo} ships a real + * full-color brand mark (vs. a tinted monogram). These render in color; every + * other brand falls through to the bundled lobehub monochrome mask. Kept in + * sync with provider-logo.tsx's LOGOS table. + */ +const COLOR_MARK_PROVIDERS: ReadonlySet = new Set([ + "anthropic", + "openai", + "google", + "deepseek", + "mistral", + "xai", + "nvidia", + "meta", + "moonshot", +]); + +export function BrandLogo({ brand, size, className }: { brand: ModelBrandInfo; size: "xs" | "sm" | "md"; className?: string }) { + if (!hasRealLogo(brand)) return null; + const pixelSize = size === "xs" ? 14 : size === "sm" ? 16 : 28; + // Near-black marks (xai #000, moonshot #16191E) stay inside the light + // `bg-background` ring chip so they remain visible against dark themes. + const useColorMark = + brand.logo !== "tangle" && COLOR_MARK_PROVIDERS.has(brand.key); + return ( + + {brand.logo === "tangle" ? ( + + ) : useColorMark ? ( + + ) : brand.logoUrl && brand.monochrome ? ( + + ) : brand.logoUrl ? ( + + ) : null} + + ); +} + +function hasRealLogo(brand: ModelBrandInfo): boolean { + return Boolean(brand.logoUrl || brand.logo); +} + +/** + * Wrap a URL for use inside a CSS `url("…")` value. The bundled SVG data + * URLs contain raw quotes and hashes — valid in an attribute but + * terminal inside a CSS string — so percent-encode the characters CSS + * cannot carry. + */ +function cssUrl(url: string): string { + return `url("${url.replace(/[\\"'#\n]/g, (char) => encodeURIComponent(char))}")`; +} + +function modelLogo(path: string): string { + return path; +} + +function brandLogo(key: ModelBrandKey): string | undefined { + switch (key) { + case "ai21": + return modelLogo(ai21Logo); + case "alibaba": + return modelLogo(alibabaLogo); + case "anthropic": + return modelLogo(anthropicLogo); + case "azure": + return modelLogo(azureLogo); + case "bedrock": + return modelLogo(bedrockLogo); + case "cerebras": + return modelLogo(cerebrasLogo); + case "cohere": + return modelLogo(cohereLogo); + case "deepseek": + return modelLogo(deepseekLogo); + case "elevenlabs": + return modelLogo(elevenlabsLogo); + case "fal": + return modelLogo(falLogo); + case "fireworks": + return modelLogo(fireworksLogo); + case "google": + return modelLogo(googleLogo); + case "groq": + return modelLogo(groqLogo); + case "kuaishou": + return modelLogo(klingLogo); + case "luma": + return modelLogo(lumaLogo); + case "meta": + return modelLogo(metaLogo); + case "mistral": + return modelLogo(mistralLogo); + case "moonshot": + return modelLogo(moonshotLogo); + case "openai": + return modelLogo(openaiLogo); + case "openrouter": + return modelLogo(openrouterLogo); + case "perplexity": + return modelLogo(perplexityLogo); + case "pika": + return modelLogo(pikaLogo); + case "replicate": + return modelLogo(replicateLogo); + case "runway": + return modelLogo(runwayLogo); + case "stability": + return modelLogo(stabilityLogo); + case "together": + return modelLogo(togetherLogo); + case "vertex": + return modelLogo(vertexLogo); + case "xai": + return modelLogo(xaiLogo); + case "zai": + return modelLogo(zaiLogo); + case "tangle": + case "tcloud": + case "cartesia": + case "unknown": + return undefined; + } +} + +function brandMark(key: ModelBrandKey): Pick { + if (key === "tangle" || key === "tcloud") return { logo: "tangle" }; + const logoUrl = brandLogo(key); + return logoUrl ? { logoUrl, monochrome: true } : {}; +} + +function inferModelLab(model: ModelInfo, canonical: string, hostKey: ModelBrandKey): string { + const name = model.name ?? ""; + const idSegments = canonical.split("/"); + const possibleLab = normalizeBrandKey(idSegments.length > 1 ? idSegments[1] : idSegments[0]); + const textKey = normalizeBrandKey(`${canonical} ${name}`); + if (hostKey !== possibleLab && possibleLab !== "unknown") return possibleLab; + if (textKey !== "unknown") return textKey; + return hostKey; +} + +function firstIdSegment(value: string): string { + return value.split("/")[0] ?? value; +} + +export function normalizeBrandKey(value: string | undefined): ModelBrandKey { + const normalized = (value ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + if (!normalized) return "unknown"; + if (/(^|-)anthropic($|-)|claude/.test(normalized)) return "anthropic"; + if (/(^|-)openai($|-)|(^|-)gpt-|o[1345]($|-)|chatgpt/.test(normalized)) return "openai"; + if (/(^|-)google($|-)|gemini|palm|imagen|veo/.test(normalized)) return "google"; + if (/(^|-)xai($|-)|grok/.test(normalized)) return "xai"; + if (/(^|-)meta($|-)|llama/.test(normalized)) return "meta"; + if (/(^|-)mistral($|-)|mixtral|codestral/.test(normalized)) return "mistral"; + if (/(^|-)cohere($|-)|command-r/.test(normalized)) return "cohere"; + if (/(^|-)deepseek($|-)/.test(normalized)) return "deepseek"; + if (/(^|-)moonshot($|-)|kimi/.test(normalized)) return "moonshot"; + if (/(^|-)zai($|-)|z-ai|glm/.test(normalized)) return "zai"; + if (/(^|-)qwen($|-)|alibaba|wan($|-)/.test(normalized)) return "alibaba"; + if (/(^|-)perplexity($|-)|sonar/.test(normalized)) return "perplexity"; + if (/(^|-)ai21($|-)|jamba/.test(normalized)) return "ai21"; + if (/(^|-)runway($|-)|gen-?[34]/.test(normalized)) return "runway"; + if (/(^|-)kling($|-)|kuaishou/.test(normalized)) return "kuaishou"; + if (/(^|-)luma($|-)|ray-?[12]/.test(normalized)) return "luma"; + if (/(^|-)pika($|-)/.test(normalized)) return "pika"; + if (/(^|-)stability($|-)|stable-diffusion|sdxl/.test(normalized)) return "stability"; + if (/(^|-)elevenlabs($|-)|eleven/.test(normalized)) return "elevenlabs"; + if (/(^|-)cartesia($|-)|sonic/.test(normalized)) return "cartesia"; + if (/(^|-)openrouter($|-)/.test(normalized)) return "openrouter"; + if (/(^|-)tcloud($|-)/.test(normalized)) return "tcloud"; + if (/(^|-)tangle($|-)/.test(normalized)) return "tangle"; + if (/(^|-)fal($|-)/.test(normalized)) return "fal"; + if (/(^|-)replicate($|-)/.test(normalized)) return "replicate"; + if (/(^|-)together($|-)/.test(normalized)) return "together"; + if (/(^|-)fireworks($|-)/.test(normalized)) return "fireworks"; + if (/(^|-)groq($|-)/.test(normalized)) return "groq"; + if (/(^|-)cerebras($|-)/.test(normalized)) return "cerebras"; + if (/(^|-)bedrock($|-)|amazon/.test(normalized)) return "bedrock"; + if (/(^|-)vertex($|-)/.test(normalized)) return "vertex"; + if (/(^|-)azure($|-)/.test(normalized)) return "azure"; + return "unknown"; +} + +export function brandInfo(key: ModelBrandKey): ModelBrandInfo { + return BRAND_INFO[key] ?? BRAND_INFO.unknown; +} + +const BRAND_INFO: Record = { + ai21: { key: "ai21", label: "AI21", ...brandMark("ai21") }, + alibaba: { key: "alibaba", label: "Alibaba", ...brandMark("alibaba") }, + anthropic: { key: "anthropic", label: "Anthropic", ...brandMark("anthropic") }, + azure: { key: "azure", label: "Azure", ...brandMark("azure") }, + bedrock: { key: "bedrock", label: "AWS Bedrock", ...brandMark("bedrock") }, + cartesia: { key: "cartesia", label: "Cartesia" }, + cerebras: { key: "cerebras", label: "Cerebras", ...brandMark("cerebras") }, + cohere: { key: "cohere", label: "Cohere", ...brandMark("cohere") }, + deepseek: { key: "deepseek", label: "DeepSeek", ...brandMark("deepseek") }, + elevenlabs: { key: "elevenlabs", label: "ElevenLabs", ...brandMark("elevenlabs") }, + fal: { key: "fal", label: "Fal", ...brandMark("fal") }, + fireworks: { key: "fireworks", label: "Fireworks", ...brandMark("fireworks") }, + google: { key: "google", label: "Google", ...brandMark("google") }, + groq: { key: "groq", label: "Groq", ...brandMark("groq") }, + kuaishou: { key: "kuaishou", label: "Kling", ...brandMark("kuaishou") }, + luma: { key: "luma", label: "Luma", ...brandMark("luma") }, + meta: { key: "meta", label: "Meta", ...brandMark("meta") }, + mistral: { key: "mistral", label: "Mistral", ...brandMark("mistral") }, + moonshot: { key: "moonshot", label: "Moonshot", ...brandMark("moonshot") }, + openai: { key: "openai", label: "OpenAI", ...brandMark("openai") }, + openrouter: { key: "openrouter", label: "OpenRouter", ...brandMark("openrouter") }, + perplexity: { key: "perplexity", label: "Perplexity", ...brandMark("perplexity") }, + pika: { key: "pika", label: "Pika", ...brandMark("pika") }, + replicate: { key: "replicate", label: "Replicate", ...brandMark("replicate") }, + runway: { key: "runway", label: "Runway", ...brandMark("runway") }, + stability: { key: "stability", label: "Stability AI", ...brandMark("stability") }, + tangle: { key: "tangle", label: "Tangle", ...brandMark("tangle") }, + tcloud: { key: "tcloud", label: "tcloud", ...brandMark("tcloud") }, + together: { key: "together", label: "Together", ...brandMark("together") }, + vertex: { key: "vertex", label: "Vertex AI", ...brandMark("vertex") }, + xai: { key: "xai", label: "xAI", ...brandMark("xai") }, + zai: { key: "zai", label: "Z.ai", ...brandMark("zai") }, + unknown: { key: "unknown", label: "Unknown" }, +}; diff --git a/src/dashboard/provider-logo.tsx b/src/lib/provider-logo.tsx similarity index 100% rename from src/dashboard/provider-logo.tsx rename to src/lib/provider-logo.tsx diff --git a/src/test-setup.ts b/src/test-setup.ts index 147bfed..88138e6 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1,4 +1,8 @@ import "@testing-library/jest-dom/vitest" +import { + installMemoryStorage, + needsMemoryStorage, +} from "./test-support/memory-storage" // jsdom lacks the pointer-capture, scroll, and resize-observer APIs that // Radix UI primitives (e.g. Select) call during interaction. Provide no-op @@ -23,6 +27,19 @@ if (!globalThis.ResizeObserver) { } } +// A usable `localStorage` for anything that persists a preference. Both the +// "missing" and the "throws on read" cases, and why they exist, are documented on +// the helper — which is exported so they can be TESTED rather than asserted in a +// comment (see memory-storage.test.ts). +// +// The store is per-PROCESS, not per-file: this property is not one jsdom created, so +// the environment teardown does not delete it. That is only harmless while vitest +// runs each test file in its own process (`isolate`, the default) — turn isolation +// off and this Map would carry state between files. +if (needsMemoryStorage(globalThis)) { + installMemoryStorage(globalThis) +} + // jsdom does not implement DataTransfer (https://github.com/jsdom/jsdom/issues/1568), // so components that build one for paste/drag-drop (new DataTransfer(), items.add) // need a minimal stand-in to run under test. diff --git a/src/test-support/memory-storage.test.ts b/src/test-support/memory-storage.test.ts new file mode 100644 index 0000000..20c3088 --- /dev/null +++ b/src/test-support/memory-storage.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest" +import { installMemoryStorage, needsMemoryStorage } from "./memory-storage" + +/** A stand-in global whose `localStorage` behaves the way a hostile host's does. */ +function hostWhere(localStorage: PropertyDescriptor): typeof globalThis { + const host = {} as typeof globalThis + Object.defineProperty(host, "localStorage", { + configurable: true, + ...localStorage, + }) + return host +} + +describe("needsMemoryStorage", () => { + it("says yes when the host has no localStorage at all", () => { + // Node's own global: present, and `undefined` without --localstorage-file. + expect(needsMemoryStorage(hostWhere({ value: undefined }))).toBe(true) + }) + + it("says yes — and does NOT throw — when merely READING it raises", () => { + // jsdom raises SecurityError from the getter on an opaque origin. A probe that + // touches it unguarded takes the whole setup file down before the replacement it + // is probing for can be installed, which is the one case it exists for. + const host = hostWhere({ + get() { + throw new Error("SecurityError: localStorage is not available") + }, + }) + expect(() => needsMemoryStorage(host)).not.toThrow() + expect(needsMemoryStorage(host)).toBe(true) + }) + + it("says no when the host already has a working one", () => { + const real = { getItem: () => null } as unknown as Storage + expect(needsMemoryStorage(hostWhere({ value: real }))).toBe(false) + }) +}) + +describe("installMemoryStorage", () => { + it("replaces a THROWING localStorage with one a test can call", () => { + const host = hostWhere({ + get() { + throw new Error("SecurityError: localStorage is not available") + }, + }) + installMemoryStorage(host) + + // The whole point: `clear()` in a beforeEach no longer needs a guard around it. + expect(() => host.localStorage.clear()).not.toThrow() + host.localStorage.setItem("rail", "collapsed") + expect(host.localStorage.getItem("rail")).toBe("collapsed") + }) + + it("behaves like Storage: length, key order, null for a miss, string coercion", () => { + const host = hostWhere({ value: undefined }) + const storage = installMemoryStorage(host) + + expect(storage.getItem("absent")).toBeNull() + expect(storage.length).toBe(0) + + storage.setItem("a", "1") + storage.setItem("b", "2") + expect(storage.length).toBe(2) + expect(storage.key(0)).toBe("a") + expect(storage.key(1)).toBe("b") + expect(storage.key(9)).toBeNull() + + // A Storage stores strings, whatever it is handed. + storage.setItem("n", 42 as unknown as string) + expect(storage.getItem("n")).toBe("42") + + storage.removeItem("a") + expect(storage.getItem("a")).toBeNull() + expect(storage.length).toBe(2) + + storage.clear() + expect(storage.length).toBe(0) + }) +}) diff --git a/src/test-support/memory-storage.ts b/src/test-support/memory-storage.ts new file mode 100644 index 0000000..a3331e8 --- /dev/null +++ b/src/test-support/memory-storage.ts @@ -0,0 +1,55 @@ +/** + * An in-memory `localStorage`, for test environments that do not supply a working + * one. Used by `test-setup.ts`; exported so the behaviour that matters can be + * asserted rather than assumed. + * + * There are two ways a host fails to give tests a usable `localStorage`, and the + * install has to survive both: + * + * **It is missing.** Node ships its own `localStorage` global, `undefined` unless + * the process was started with `--localstorage-file`. Vitest's jsdom environment + * skips any key already present on the global, so jsdom's own Storage is never + * installed and the Node one — `undefined` — is what a component sees. (Node does + * not do this to `sessionStorage`, which is why the hole looks arbitrary.) + * + * **Reading it THROWS.** jsdom raises `SecurityError` from the getter on an opaque + * origin. A probe that touches it unguarded takes the setup file down with it — + * before the replacement it was probing for could be installed. + */ + +/** Does `target` already have a `localStorage` a test can use? Never throws: a + * getter that raises (the opaque-origin case) means NO, not "crash the caller". */ +export function needsMemoryStorage(target: typeof globalThis): boolean { + try { + return !target.localStorage + } catch { + return true + } +} + +/** Back `target.localStorage` with an in-memory Storage, replacing a missing or + * throwing one. Defined as an OWN property, so it shadows an inherited getter + * rather than trying to write through it. */ +export function installMemoryStorage(target: typeof globalThis): Storage { + const entries = new Map() + const storage: Storage = { + get length() { + return entries.size + }, + key: (i) => [...entries.keys()][i] ?? null, + getItem: (k) => entries.get(k) ?? null, + setItem: (k, v) => { + entries.set(String(k), String(v)) + }, + removeItem: (k) => { + entries.delete(String(k)) + }, + clear: () => entries.clear(), + } + Object.defineProperty(target, "localStorage", { + value: storage, + configurable: true, + writable: true, + }) + return storage +} diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index e3ba731..3539e98 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -71,6 +71,22 @@ const MUTED_TRACK = */ const FIT_VIEW = { padding: 0.16, minZoom: 0.55, maxZoom: 1 } as const; +/** The same framing, animated. Used when the layout changes UNDER a reader who is + * already looking at the graph (the density toggle) — an instant jump to the new + * viewport reads as the graph being replaced, while a short glide reads as the + * same graph being reframed, which is what actually happened. Short enough not to + * make the toggle feel laggy, and skipped entirely for a reader who asked the + * system for less motion. */ +export function fitViewOnLayoutChange() { + // Motion is opt-OUT, so it is only added where the reader's preference can be + // read and says nothing against it. Somewhere without `matchMedia` cannot say a + // reader tolerates movement, so the graph reframes without it. + const moves = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === false; + return moves ? { ...FIT_VIEW, duration: 220 } : FIT_VIEW; +} + /** Tracks the app's dark/light class so React Flow's chrome (edges, controls, * background) themes with the rest of the app. */ function useColorMode(): ColorMode { @@ -247,6 +263,10 @@ export function WorkflowNode({ data }: NodeProps>) { // doesn't grow a vendor prefix the moment the run starts. const subtitle = isAgent && state?.model ? shortModel(state.model) : d.subtitle; + // The brand mark names the SAME model the subtitle does. A run that fell back to + // another lab would otherwise sit under its requested lab's logo — an Anthropic + // mark beside the words "gpt-5.4". + const markModel = isAgent && state?.model ? state.model : d.model; // Whether this card may render the bands a RUN adds — the metrics line, the // output block, the "nothing to report" line, and the status footer. It mirrors // `nodeHeight` (model.ts), which reserves those rows for an action but NOT for a @@ -354,6 +374,7 @@ export function WorkflowNode({ data }: NodeProps>) { @@ -429,6 +450,7 @@ export function WorkflowNode({ data }: NodeProps>) { @@ -646,6 +668,9 @@ export function WorkflowGraph({ // graph would otherwise be left mis-zoomed. Deferred a frame so it runs after // the re-seeded nodes (with their new sizes) commit. A run-state tick doesn't // change `structural`, so a live run is never yanked around. + // + // Animated, because a reader is watching: the nodes resize in place and the + // viewport glides to the new framing. const didInitialFitRef = useRef(false); useEffect(() => { // ReactFlow's `fitView` prop already frames the initial render — skip this @@ -656,9 +681,10 @@ export function WorkflowGraph({ } const inst = rfRef.current; if (!inst || typeof requestAnimationFrame === "undefined") return; - // cancelAnimationFrame is universally paired with requestAnimationFrame, so - // the single guard above covers the cleanup too. - const raf = requestAnimationFrame(() => inst.fitView(FIT_VIEW)); + // Cancelling the frame un-schedules a fit that hasn't run. One already in + // flight is left to finish: it only writes the viewport of a store nobody is + // reading any more, and React Flow exposes no way to interrupt it. + const raf = requestAnimationFrame(() => inst.fitView(fitViewOnLayoutChange())); return () => cancelAnimationFrame(raf); }, [structural]); diff --git a/src/workflows/WorkflowGraphFitView.test.tsx b/src/workflows/WorkflowGraphFitView.test.tsx new file mode 100644 index 0000000..b43e55e --- /dev/null +++ b/src/workflows/WorkflowGraphFitView.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +/** + * The density toggle reframes the graph. `fitViewOnLayoutChange` decides HOW (see + * WorkflowNode.test.tsx) — this covers the wiring: that the graph actually calls + * React Flow with what that function returned, rather than the un-animated constant. + * A regression there is invisible to a unit test of the helper alone. + * + * React Flow itself is stubbed: it measures a real viewport, which jsdom does not + * have, and it is not the thing under test. The stub hands the component the one + * thing it reaches for — the instance, via `onInit` — and renders the Panel so the + * toggle is clickable. + */ +const fitView = vi.fn() + +vi.mock("@xyflow/react", async (importOriginal) => { + const actual = await importOriginal() + const { useEffect } = await import("react") + return { + ...actual, + ReactFlow: ({ + children, + onInit, + }: { + children?: React.ReactNode + onInit?: (instance: { fitView: typeof fitView }) => void + }) => { + useEffect(() => { + onInit?.({ fitView }) + }, [onInit]) + return
{children}
+ }, + Background: () => null, + Controls: () => null, + Handle: () => null, + Panel: ({ children }: { children?: React.ReactNode }) =>
{children}
, + } +}) + +const { WorkflowGraph } = await import("./WorkflowGraph") + +const YAML = ` +do: + - agent.run: + model: anthropic/claude-sonnet-4-5 + prompt: Review it. + - integration.invoke: + path: github.issues.create +` + +afterEach(() => { + cleanup() + fitView.mockClear() + vi.unstubAllGlobals() +}) + +/** A browser that can report the preference, and says the reader has none. jsdom has + * no `matchMedia` at all — and a host that cannot report the preference gets no + * motion (see WorkflowNode.test.tsx), so the animated path has to be asked for. */ +const readerToleratesMotion = () => + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + media: query, + })) + +describe("reframing on the density toggle", () => { + it("hands React Flow the ANIMATED framing, not the bare constant", async () => { + readerToleratesMotion() + render() + // Mounting does not refit — React Flow's own `fitView` prop already framed it. + expect(fitView).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) + + await waitFor(() => expect(fitView).toHaveBeenCalledTimes(1)) + expect(fitView).toHaveBeenCalledWith( + expect.objectContaining({ duration: 220 }), + ) + }) + + it("reframes without moving for a reader who asked for less motion", async () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + })) + render() + + fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) + + await waitFor(() => expect(fitView).toHaveBeenCalledTimes(1)) + // Same framing, no transition. + const options = fitView.mock.calls[0][0] + expect(options).not.toHaveProperty("duration") + expect(options).toHaveProperty("padding") + }) +}) diff --git a/src/workflows/WorkflowNode.test.tsx b/src/workflows/WorkflowNode.test.tsx index 5559185..190510e 100644 --- a/src/workflows/WorkflowNode.test.tsx +++ b/src/workflows/WorkflowNode.test.tsx @@ -1,14 +1,15 @@ // @vitest-environment jsdom import type { Edge, Node, NodeProps } from "@xyflow/react"; import { ReactFlowProvider } from "@xyflow/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { WfNodeData, WfNodeState } from "./model"; +import { buildWorkflowGraph, type WfNodeData, type WfNodeState } from "./model"; import { classifyOutput, NodeOutputBody } from "./node-output"; import { buildStyledEdges, DensityContext, DirectionContext, + fitViewOnLayoutChange, WorkflowGraph, WorkflowNode, } from "./WorkflowGraph"; @@ -32,6 +33,57 @@ function renderNode(data: WfNodeData) { ); } +describe("reframing the viewport when the layout changes", () => { + // The density toggle re-frames the graph under a reader who is already looking at + // it, so the viewport GLIDES to its new framing rather than jumping. A reader who + // asked the system for less motion gets the framing without the glide. + const prefersReducedMotion = (reduce: boolean) => + vi.stubGlobal( + "matchMedia", + (query: string) => ({ + matches: reduce && query.includes("prefers-reduced-motion"), + media: query, + }), + ); + + afterEach(() => vi.unstubAllGlobals()); + + it("glides by default", () => { + prefersReducedMotion(false); + expect(fitViewOnLayoutChange()).toHaveProperty("duration", 220); + }); + + it("reframes instantly for a reader who asked for less motion", () => { + prefersReducedMotion(true); + expect(fitViewOnLayoutChange()).not.toHaveProperty("duration"); + }); + + it("does not move at all where the preference cannot be read", () => { + // Motion is opt-OUT. Somewhere without matchMedia cannot tell us a reader + // tolerates movement, so it does not get any. + vi.stubGlobal("matchMedia", undefined); + expect(fitViewOnLayoutChange()).not.toHaveProperty("duration"); + }); + + it("keeps the SAME framing either way — only the transition differs", () => { + prefersReducedMotion(true); + const still = fitViewOnLayoutChange(); + prefersReducedMotion(false); + const glide = fitViewOnLayoutChange(); + expect(glide.padding).toBe(still.padding); + expect(glide.minZoom).toBe(still.minZoom); + expect(glide.maxZoom).toBe(still.maxZoom); + }); +}); + +/** The brand mark inside a node's tile. Queried off the DOM rather than by its + * accessible name: the tile is `aria-hidden` (the mark is decorative — see the test + * below), and `getByLabelText` does NOT prune an aria-hidden subtree, so a name-based + * query would assert something a screen reader never receives. */ +function brandMark(container: HTMLElement, label: string): HTMLElement | null { + return container.querySelector(`[aria-label="${label}"]`) +} + describe("buildStyledEdges", () => { const edge = (source: string, target: string): Edge => ({ id: `${source}->${target}`, @@ -212,6 +264,101 @@ describe("WorkflowNode", () => { expect(screen.queryByText("the prompt")).toBeNull(); }); + it("marks an agent node with its model's brand", () => { + const { container } = renderNode({ + ...BASE, + model: "anthropic/claude-sonnet-4-5", + }); + expect(brandMark(container, "Anthropic")).toBeTruthy(); + }); + + it("does not announce the mark — the model is already read out as TEXT", () => { + // The tile is DECORATIVE: it stands for the model, and the model is on the card + // in words (the subtitle). A screen reader gets "AI Agent, claude-sonnet-4-5"; + // announcing the logo too would only say the same thing a second time, so the + // tile stays out of the accessibility tree — the same treatment the kind glyph + // and the provider icon get. + // + // Note the mark can only be found by querying the DOM directly: `getByLabelText` + // would happily return it (Testing Library does not prune an aria-hidden + // subtree), which is exactly the false comfort this test exists to deny. + // + // Built the way the graph builds it, so the subtitle under test is the real one + // rather than a fixture that assumes the answer. + const built = buildWorkflowGraph(` +do: + - agent.run: + model: anthropic/claude-sonnet-4-5 + prompt: Review it. +`).nodes.find((n) => n.id === "a0"); + const { container } = renderNode(built?.data as WfNodeData); + + expect( + brandMark(container, "Anthropic")?.closest("[aria-hidden]"), + ).toBeTruthy(); + // …and the thing it stands for IS announced, in words. + expect(screen.getByText("claude-sonnet-4-5")).toBeTruthy(); + }); + + it("marks the model the run ACTUALLY used, not the one it asked for", () => { + // The subtitle already shows the actual model once a run is live (a router can + // fall back to another lab). The mark has to agree with it, or the card shows + // an Anthropic logo beside the words "gpt-5.4". + const { container } = renderNode({ + ...BASE, + model: "anthropic/claude-sonnet-4-5", + state: { status: "succeeded", model: "openai/gpt-5.4" }, + }); + expect(brandMark(container, "OpenAI")).toBeTruthy(); + expect(brandMark(container, "Anthropic")).toBeNull(); + }); + + it("steps a HOSTED model's two-mark stack down so it can't overflow the tile", () => { + // One lab's own model is a single 28px mark and fills the expanded card's 34px + // tile. A hosted model stacks host + lab, and that pair is 36px wide — wider + // than the tile, so the lab chip would hang over the border. + const { container } = renderNode({ + ...BASE, + model: "openrouter/anthropic/claude-sonnet-4-5", + }); + const stack = container.querySelector('[aria-label*="hosting"]'); + expect(stack).toBeTruthy(); + // The narrow stack (h-4 w-6 = 16×24), not the wide one (h-7 w-9 = 28×36). + expect(stack?.className).toContain("w-6"); + expect(stack?.className).not.toContain("w-9"); + }); + + it("keeps a single lab's mark at full size — it was never the one that overflowed", () => { + const { container } = renderNode({ + ...BASE, + model: "anthropic/claude-sonnet-4-5", + }); + expect(container.querySelector('[aria-label="Anthropic"]')?.className).toContain( + "h-7", + ); + }); + + it("leaves an integration node's PROVIDER mark alone", () => { + // A model brand belongs to an agent. On an integration node the provider IS the + // identity (n8n's rule, and what the title says), so it keeps its own logo — the + // model brand must not take the tile from it. + const { container } = renderNode({ + ...BASE, + kind: "integration.invoke", + provider: "github", + model: "anthropic/claude-sonnet-4-5", + }); + expect(container.querySelector('[aria-label="Anthropic"]')).toBeNull(); + }); + + it("keeps the kind glyph for a model with no published mark", () => { + // An unknown provider keeps the generic icon rather than getting an invented + // logo. + const { container } = renderNode({ ...BASE, model: "some-internal/model-x" }); + expect(brandMark(container, "Anthropic")).toBeNull(); + expect(brandMark(container, "OpenAI")).toBeNull(); + }); + it("anchors a compact node's handles to its TILE, not to the box that holds its name", () => { // The compact box spans the tile AND the name beneath it (so a name can't // collide with the node below). Left at the box's default center, the edges diff --git a/src/workflows/model.test.ts b/src/workflows/model.test.ts index 41c4194..3787621 100644 --- a/src/workflows/model.test.ts +++ b/src/workflows/model.test.ts @@ -85,6 +85,69 @@ do: expect(agent?.data.model).toBe("anthropic/claude-sonnet-5"); }); + it("does not mangle a minted catalog id into a name", () => { + // The platform mints a stored profile's id as `ap_` + random bytes. Humanising + // it produces noise ("Ap nro qux n7d c7 ll30") that names nothing, and the + // catalog that could resolve it lives in the host, not here — so the node stays + // the generic agent and the host titles it if it can. + const yaml = ` +do: + - agent.run: + profile: ap_NROQux-n7dC7Ll30 + model: anthropic/claude-sonnet-5 + prompt: Review it. +`; + const agent = buildWorkflowGraph(yaml).nodes.find((n) => n.id === "a0"); + expect(agent?.data.title).toBe("AI Agent"); + // The readable-slug case is untouched. + expect( + buildWorkflowGraph(` +do: + - agent.run: + profile: pr-reviewer + prompt: Review it. +`).nodes.find((n) => n.id === "a0")?.data.title, + ).toBe("PR reviewer"); + }); + + it("keeps a human-authored slug that merely BEGINS with the minted prefix", () => { + // A minted id is `ap_` + exactly 16 base64url chars. Matching `ap_` + "8 or + // more" would also swallow a name a person wrote, replacing it with "AI Agent". + const title = (profile: string) => + buildWorkflowGraph(` +do: + - agent.run: + profile: ${profile} + prompt: Review it. +`).nodes.find((n) => n.id === "a0")?.data.title; + + expect(title("ap_code_review")).toBe("Ap code review"); + expect(title("ap_analytics_bot")).toBe("Ap analytics bot"); + // …while the real thing is still recognised. + expect(title("ap_NROQux-n7dC7Ll30")).toBe("AI Agent"); + }); + + it("recognises a minted id by its EXACT length, one character either side", () => { + // `ap_` + 12 random bytes as base64url is exactly 16 characters. The boundary is + // where an off-by-one would hide: one short and a real minted id gets humanised + // into noise; one long and a name a person wrote gets replaced by "AI Agent". + const title = (profile: string) => + buildWorkflowGraph(` +do: + - agent.run: + profile: ${profile} + prompt: Review it. +`).nodes.find((n) => n.id === "a0")?.data.title; + + const minted = "ap_NROQux-n7dC7Ll30"; + expect(minted.length - "ap_".length).toBe(16); + expect(title(minted)).toBe("AI Agent"); + + // 15 and 17 are not the shape the platform mints, so they are somebody's name. + expect(title(minted.slice(0, -1))).not.toBe("AI Agent"); + expect(title(`${minted}X`)).not.toBe("AI Agent"); + }); + it("falls back to a generic agent name when the profile is inline (unnamed)", () => { const yaml = ` do: diff --git a/src/workflows/model.ts b/src/workflows/model.ts index b056507..2e139f5 100644 --- a/src/workflows/model.ts +++ b/src/workflows/model.ts @@ -359,12 +359,28 @@ function actionKind(rec: Record): string | undefined { return Object.keys(rec).find((k) => !CONTROL_FLOW.has(k)); } -/** The agent's name: a named profile reads as the role it plays ("pr-reviewer" → - * "PR reviewer"); an inline profile object has no name to show, so the node is - * the generic agent. */ +/** The SHAPE of an id the platform minted for a stored profile: `ap_` followed by 16 + * base64url characters (what 12 random bytes encode to). Shape only — it says + * nothing about the bytes behind it, and nothing about whether such a profile + * exists; the host holds the catalog that could answer either. + * + * What it buys is a title. A minted id names the profile to a DATABASE, not to a + * reader — humanising it yields noise ("ap_NROQux-n7dC7Ll30" → "Ap nro qux n7d c7 + * ll30") — so a node named by one is titled generically instead. + * + * The length is pinned rather than open-ended: `{8,}` would also swallow a slug a + * person wrote that happens to start with `ap_` ("ap_code_review"), replacing their + * name with a generic one. */ +const MINTED_PROFILE_ID = /^ap_[A-Za-z0-9_-]{16}$/; + +/** The agent's name: a profile named by a readable slug reads as the role it plays + * ("pr-reviewer" → "PR reviewer"). An inline profile object, or one named only by + * a minted catalog id, has no readable name here — the node is the generic agent + * rather than a mangled identifier. */ function agentTitle(profile: unknown): string { const named = str(profile); - return named ? humanizeIdentifier(named) : "AI Agent"; + if (!named || MINTED_PROFILE_ID.test(named)) return "AI Agent"; + return humanizeIdentifier(named); } /** Build the card-facing node data for a single `do` leaf or top-level action. diff --git a/src/workflows/node-ui.tsx b/src/workflows/node-ui.tsx index 5a611fa..6f4715b 100644 --- a/src/workflows/node-ui.tsx +++ b/src/workflows/node-ui.tsx @@ -30,6 +30,7 @@ import { Zap, } from "lucide-react"; import { ProviderIcon } from "../integrations/provider-logo"; +import { ModelBrandStack, modelBrandFor } from "../lib/model-brand"; import type { WfNodeStatus, WfNodeTone } from "./model"; import { providerLabel } from "./provider-label"; @@ -181,23 +182,30 @@ export const KIND_ICON: Record = { export function NodeMark({ kind, provider, + model, accent, tile, }: { kind?: string; provider?: string; + /** The model an agent runs, e.g. "anthropic/claude-sonnet-4-5". Its lab/host + * brand mark identifies the node far faster than a generic sparkle. */ + model?: string; accent: string; /** Edge length of the square the mark is centered in. */ tile: number; }) { const Icon = (kind && KIND_ICON[kind]) || Circle; + // An agent is identified by WHO runs it. A published brand mark says that at a + // glance; a model with no mark keeps the kind glyph rather than an invented one. + const brand = !provider && model ? modelBrandFor(model) : null; // A logo is a full-bleed image, so it fills more of the tile than a line glyph, // which needs the surrounding air to stay legible. const mark = Math.round(tile * (provider ? 0.58 : 0.5)); return (