From fd4cfd936ba62aeb6187cc5c171ab3e53ce8342f Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Mon, 13 Jul 2026 21:24:40 -0500 Subject: [PATCH 01/13] feat(workflows): name an agent node by its model's brand, not a mangled id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent node showed whatever string sat in `profile`, humanised. For a profile named by a readable slug that reads well ("pr-reviewer" → "PR reviewer"), but the platform mints a stored profile's id as `ap_` + random bytes, and humanising that produces noise — "ap_NROQux-n7dC7Ll30" became "Ap nro qux n7d c7 ll30". Only the host holds the catalog that resolves such an id, so the node now stays the generic agent and lets the host title it. An agent is identified by WHO runs it, so the node's mark is now the model's brand (the lab/host glyph the model picker already draws) on both the compact tile and the expanded card, falling back to the kind glyph for a model with no published mark — never an invented one. `modelBrandFor` + `ModelBrandStack` + `resolveModelBrandIdentity` are exported so a host that knows only a model id — a workflow node panel, a run header — draws the same mark rather than deriving its own. --- package.json | 4 ++-- src/dashboard/index.ts | 9 +++++++++ src/dashboard/model-picker.tsx | 17 +++++++++++++++++ src/workflows/WorkflowGraph.tsx | 2 ++ src/workflows/model.test.ts | 25 +++++++++++++++++++++++++ src/workflows/model.ts | 16 ++++++++++++---- src/workflows/node-ui.tsx | 10 ++++++++++ 7 files changed, 77 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3a035d5..ce63ce2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tangle-network/sandbox-ui", - "version": "0.77.0", - "description": "Unified UI component library for Tangle Sandbox — primitives, chat, dashboard, terminal, editor, and workspace components", + "version": "0.78.0", + "description": "Unified UI component library for Tangle Sandbox \u2014 primitives, chat, dashboard, terminal, editor, and workspace components", "repository": { "type": "git", "url": "https://github.com/tangle-network/sandbox-ui" diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts index dba7d13..68bc2cc 100644 --- a/src/dashboard/index.ts +++ b/src/dashboard/index.ts @@ -134,6 +134,15 @@ 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. + ModelBrandStack, + modelBrandFor, + resolveModelBrandIdentity, + type ModelBrandIdentity, + type ModelBrandInfo, + type ModelBrandKey, } from "./model-picker"; export { BillingDashboard, diff --git a/src/dashboard/model-picker.tsx b/src/dashboard/model-picker.tsx index 9d08d57..d3caa88 100644 --- a/src/dashboard/model-picker.tsx +++ b/src/dashboard/model-picker.tsx @@ -778,6 +778,23 @@ function ModelRow({ ); } +/** + * 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; +} + export function ModelBrandStack({ identity, size }: { identity: ModelBrandIdentity; size: "sm" | "md" }) { if (identity.combined) return ; const hasHostLogo = hasRealLogo(identity.host); diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index e3ba731..b38badc 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -354,6 +354,7 @@ export function WorkflowNode({ data }: NodeProps>) { @@ -429,6 +430,7 @@ export function WorkflowNode({ data }: NodeProps>) { diff --git a/src/workflows/model.test.ts b/src/workflows/model.test.ts index 41c4194..588373e 100644 --- a/src/workflows/model.test.ts +++ b/src/workflows/model.test.ts @@ -85,6 +85,31 @@ 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("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..71f07d1 100644 --- a/src/workflows/model.ts +++ b/src/workflows/model.ts @@ -359,12 +359,20 @@ 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. */ +/** An id the platform MINTED for a stored profile (`ap_` + random bytes). It names + * the profile to a database, not to a reader — humanising it yields noise + * ("ap_NROQux-n7dC7Ll30" → "Ap nro qux n7d c7 ll30"), and only the host holds the + * catalog that could resolve it to a name. */ +const MINTED_PROFILE_ID = /^ap_[A-Za-z0-9_-]{8,}$/; + +/** 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..00b3e9f 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 "../dashboard/model-picker"; import type { WfNodeStatus, WfNodeTone } from "./model"; import { providerLabel } from "./provider-label"; @@ -181,16 +182,23 @@ 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)); @@ -211,6 +219,8 @@ export function NodeMark({ size={mark} className="rounded-[3px]" /> + ) : brand ? ( + = 30 ? "md" : "sm"} /> ) : ( )} From 42691d265733baa4c32a4bd86c377a8a3c998b8e Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Mon, 13 Jul 2026 22:06:03 -0500 Subject: [PATCH 02/13] =?UTF-8?q?fix(test):=20back=20localStorage=20with?= =?UTF-8?q?=20in-memory=20storage=20=E2=80=94=20Node's=20own=20global=20sh?= =?UTF-8?q?adows=20jsdom's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node defines globalThis.localStorage as an own property that resolves to undefined unless --localstorage-file is passed. It shadows the one jsdom installs, so every component that persists a preference sees undefined and throws — 11 sidebar-collapse tests fail on Node 26 while CI's older Node stays green. Shim it where the other jsdom gaps are shimmed, and drop the two guards written around the hole. --- src/dashboard/dashboard-layout.test.tsx | 8 +------ src/dashboard/sidebar-layout.test.tsx | 2 +- src/test-setup.ts | 29 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/dashboard/dashboard-layout.test.tsx b/src/dashboard/dashboard-layout.test.tsx index a68f1ac..17bc534 100644 --- a/src/dashboard/dashboard-layout.test.tsx +++ b/src/dashboard/dashboard-layout.test.tsx @@ -70,14 +70,8 @@ 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. 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/sidebar-layout.test.tsx b/src/dashboard/sidebar-layout.test.tsx index aba3a33..2313f31 100644 --- a/src/dashboard/sidebar-layout.test.tsx +++ b/src/dashboard/sidebar-layout.test.tsx @@ -244,7 +244,7 @@ describe("SidebarLayout — expandable nav item", () => { describe("SidebarLayout — collapsed rail interactions", () => { beforeEach(() => { - try { localStorage.clear() } catch { /* opaque origin */ } + localStorage.clear() }) it("expands when the empty rail body is clicked (collapsed)", () => { diff --git a/src/test-setup.ts b/src/test-setup.ts index c6ffddc..ec1b692 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -22,3 +22,32 @@ if (!globalThis.ResizeObserver) { disconnect() {} } } + +// Node ships its own `localStorage` global, and it is `undefined` unless the +// process was started with --localstorage-file. That own property shadows the one +// jsdom installs on the window, so every component that persists a preference sees +// `localStorage === undefined` and throws. (`sessionStorage` is unaffected, which +// is why the hole looks arbitrary.) Back it with an in-memory Storage — per-file, +// like the rest of the jsdom environment. +if (!globalThis.localStorage) { + 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(k, String(v)) + }, + removeItem: (k) => { + entries.delete(k) + }, + clear: () => entries.clear(), + } + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + writable: true, + }) +} From 1aa6d85f78471d29954aa63416e3e5e502009503 Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Mon, 13 Jul 2026 22:06:03 -0500 Subject: [PATCH 03/13] feat(workflows): glide the graph to its new framing when density changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The density toggle already refit the viewport, but instantly — the nodes resize and the canvas jumps, which reads as the graph being replaced rather than reframed. Animate the refit so the same graph is seen moving to its new frame. --- src/workflows/WorkflowGraph.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index b38badc..a1120f3 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -71,6 +71,13 @@ 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. */ +const FIT_VIEW_ANIMATED = { ...FIT_VIEW, duration: 220 } as const; + /** 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 { @@ -648,6 +655,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 @@ -660,7 +670,7 @@ export function WorkflowGraph({ 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)); + const raf = requestAnimationFrame(() => inst.fitView(FIT_VIEW_ANIMATED)); return () => cancelAnimationFrame(raf); }, [structural]); From fe83fa4338e0e312393e3477b545951d52f351b9 Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Mon, 13 Jul 2026 22:45:15 -0500 Subject: [PATCH 04/13] fix(workflows): skip the reframe animation for a reader who asked for less motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The density toggle glides the viewport to its new framing. Honour prefers-reduced-motion by reframing instantly instead, and stop claiming the effect cleanup covers an in-flight transition — it cancels the scheduled frame, nothing more. --- src/test-setup.ts | 13 +++++++++---- src/workflows/WorkflowGraph.tsx | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/test-setup.ts b/src/test-setup.ts index ec1b692..9138070 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -24,11 +24,16 @@ if (!globalThis.ResizeObserver) { } // Node ships its own `localStorage` global, and it is `undefined` unless the -// process was started with --localstorage-file. That own property shadows the one -// jsdom installs on the window, so every component that persists a preference sees +// process was started with --localstorage-file. Vitest's jsdom environment skips +// any key that already exists on the global, so jsdom's own storage is never +// installed and every component that persists a preference sees // `localStorage === undefined` and throws. (`sessionStorage` is unaffected, which -// is why the hole looks arbitrary.) Back it with an in-memory Storage — per-file, -// like the rest of the jsdom environment. +// is why the hole looks arbitrary.) Back it with an in-memory Storage. +// +// 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 (!globalThis.localStorage) { const entries = new Map() const storage: Storage = { diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index a1120f3..c0db63d 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -75,8 +75,14 @@ const FIT_VIEW = { padding: 0.16, minZoom: 0.55, maxZoom: 1 } as const; * 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. */ -const FIT_VIEW_ANIMATED = { ...FIT_VIEW, duration: 220 } as const; + * make the toggle feel laggy, and skipped entirely for a reader who asked the + * system for less motion. */ +function fitViewOnLayoutChange() { + const still = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; + return still ? FIT_VIEW : { ...FIT_VIEW, duration: 220 }; +} /** Tracks the app's dark/light class so React Flow's chrome (edges, controls, * background) themes with the rest of the app. */ @@ -668,9 +674,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_ANIMATED)); + // 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]); From e9bf76a579311c486d43de7f08da71105eeefb0c Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Tue, 14 Jul 2026 00:00:44 -0500 Subject: [PATCH 05/13] refactor(model-brand): make the brand marks a leaf module, not the picker's tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow graph drew a model's brand mark by importing it from the model picker, and rollup cannot split a source module — so the ./workflows entry pulled in the whole picker: @radix-ui/react-dropdown-menu, lucide, the ModelPicker component and its rows. A consumer rendering a graph downloaded a dropdown it never shows. The brand table, the resolution, and the marks now live in their own leaf, which the picker re-exports (the public API is unchanged) and the graph imports directly. The workflows closure drops radix and the picker entirely. --- src/dashboard/model-picker.tsx | 483 ++------------------------------- src/lib/model-brand.tsx | 476 ++++++++++++++++++++++++++++++++ src/workflows/node-ui.tsx | 9 +- 3 files changed, 508 insertions(+), 460 deletions(-) create mode 100644 src/lib/model-brand.tsx diff --git a/src/dashboard/model-picker.tsx b/src/dashboard/model-picker.tsx index d3caa88..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,287 +630,3 @@ function ModelRow({ ); } - -/** - * 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; -} - -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/lib/model-brand.tsx b/src/lib/model-brand.tsx new file mode 100644 index 0000000..c0cd048 --- /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 "../dashboard/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/workflows/node-ui.tsx b/src/workflows/node-ui.tsx index 00b3e9f..372b0f8 100644 --- a/src/workflows/node-ui.tsx +++ b/src/workflows/node-ui.tsx @@ -30,7 +30,7 @@ import { Zap, } from "lucide-react"; import { ProviderIcon } from "../integrations/provider-logo"; -import { ModelBrandStack, modelBrandFor } from "../dashboard/model-picker"; +import { ModelBrandStack, modelBrandFor } from "../lib/model-brand"; import type { WfNodeStatus, WfNodeTone } from "./model"; import { providerLabel } from "./provider-label"; @@ -205,7 +205,7 @@ export function NodeMark({ return (